The inner pages are plain HTML written by tools/chart-pages.py and styled by assets/pages.css. This file is their only script. It has no dependencies, and it is written in ES5 inside one IIFE so that nothing leaks onto window. The homepage does not load it: index.html has its own script, assets/home.js, which mirrors several of the behaviors here (menu, reveals, floating bar, weather).
What the pages actually load is assets/pages.min.js, the same code with the comments and the spacing taken out by tools/minify.py. This file is the one to read and the one to edit; the minified copy is built from it and committed beside it.
The split, the map dialog and the chart / log linking are progressive enhancements: without script a chart shows no divider, and each anchorage's map control is a plain link to Apple Maps. The entrances are not. .reveal blocks, pins and routes start hidden in pages.css and wait for this file to mark them as entered, so with script off they stay hidden.
Each block below looks its elements up first and does nothing when they are missing, so the same file serves all three pages (and the style guide) whatever subset of components a page has.
The contract with the markup is a small set of hooks, all emitted by tools/chart-pages.py:
.chart[data-split] a chart that carries a satellite plate; the value is where the divider rests
.split-knob, .split-line the divider's two draggable parts
[data-pin], [data-seg] a numbered pin and a route segment inside a chart, keyed by leg or entry
[data-for][data-key] a log entry or key row: names the chart id and the key it lights
.inset, .inset-open an anchorage inset and the link that opens its satellite map
#mapbox the map dialog, present only when a MapKit token is set
.reveal, .gauges elements that enter once when scrolled into view
.chart-scroll the horizontal scroller around a chart, live at 900px and below, and at every width in Reader
.float-cta, .close the floating action bar, and the closing section it hides over
footer .weather the footer conditions widget
[data-theme-switch], [data-theme-pick] the footer's theme switch and its buttons; the value is the theme's name, "" for Night Watch
State lives in the DOM (classes, data attributes, custom properties) so that pages.css can do the drawing; this file rarely sets a style directly. The exceptions are --split and --cta-vv, which are measurements only script can take.
Doc comments here follow JSDoc, plus one house tag, @section, which opens a named group; the stylesheets use the same tag. tools/build-docs.py turns both into docs/javascript.html and docs/css.html. The line-by-line comments are for reading straight through, and cost nothing on the wire: the minifier removes every one of them.
Static capture switch
assets/pages.js:47
Adding ?static to any inner page's URL resolves every entrance at once: reveals shown, routes drawn, pins placed, the chart / satellite divider already at rest. It exists for screenshots and print. The class is set here, synchronously and before anything else runs, because headless browsers capture at the load event, before a requestAnimationFrame chain or a timer can settle. pages.css carries the matching html.static rules.
Chart panning
assets/pages.js:128
At 900px and below a chart keeps a minimum width (--chart-min) and pans sideways inside its .chart-scroll box. It opens scrolled to its subject, not to the left edge: data-focus-x is the fraction of the chart's width to center. Applied at once and again at load, when the chart's final width is known. It runs before the split below, which rests its divider in the middle of whatever this leaves on screen.
Chart / satellite split
assets/pages.js:147
Every chart but the itineraries hero carries a satellite plate of exactly the same window, revealed by a draggable divider. The divider's position is the custom property --split on the chart, in percent from the left: chart to its left, satellite to its right. pages.css turns that one number into the plate's clip-path and the positions of the line and the knob.
Only the knob and the line take the drag, never the whole plate, because on a phone the plan charts already pan sideways and the page has to scroll past them (both set touch-action: pan-y).
Where it comes to rest is measured, not fixed. data-split is a percent of the whole chart, which is what it means while the whole chart is on screen; once the chart is wider than the screen and pans, that percent lands off to one side, so the divider rests at the middle of what is on screen instead and is within reach whatever the screen's size. The measurement is taken again whenever the geometry moves under it, unless the visitor has taken the divider somewhere.
A chart moves through four classes:
.split-on script is running: the plate, line and knob are displayed (they are display: none without it)
.split-intro the entrance is in flight: --split changes are transitioned, after --split-delay
.split-live the line and knob are visible
.is-dragging a pointer holds the divider
The knob is a role="slider". Its aria-valuenow is the divider's position; its aria-valuetext names the share of satellite, which is what a listener wants to know.
stillmember
boolean · assets/pages.js:174
True when nothing should animate: the ?static switch, or the visitor prefers reduced motion. Still pages open with the divider already at rest instead of playing the entrance.
splitsmember
Object<string, function(): void> · assets/pages.js:180
Entrance functions for the charts that animate, keyed by chart id. enter() calls one when its chart first scrolls into view, so the satellite wipes in after the route has drawn.
relaxersmember
Array<function(): void> · assets/pages.js:186
One per chart: puts its divider back where it should rest, after the geometry has moved. Called for every chart on the page at once, by relax() below.
restAt()function
assets/pages.js:203
Where the divider should rest. The authored rest is a percent of the whole chart, so it only says what it means while the whole chart is on screen; while the chart pans inside .chart-scroll the divider rests at the middle of the part that is on screen, whatever the screen's size.
Returns number Percent from the chart's left edge.
set(v)function
assets/pages.js:216
Moves the divider. The one place --split is written, so the custom property and the slider's ARIA values cannot disagree.
settle()function
assets/pages.js:228
Ends the entrance, so that later moves follow the pointer at once instead of easing over 1.5s.
at(e)function
assets/pages.js:230
The divider position a pointer event asks for, allowing for where the knob was taken.
Returns number Percent from the left; the current value if the chart has no width yet.
down(e)function
assets/pages.js:236
Starts a drag from the knob or the line. Only the primary button counts. The pointer is captured so the drag carries on when it leaves the 40px knob; capture can throw for a synthetic event, which is harmless, hence the empty catch.
Listens PointerEvent#pointerdown
move(e)function
assets/pages.js:252
Follows the pointer during a drag. Pointer events can arrive several times a frame, so only the newest position is kept and it is written once per animation frame.
Listens PointerEvent#pointermove
up()function
assets/pages.js:263
Ends a pointer drag, however it ended: release, cancel, or losing the capture. It leaves the touch state below alone, because the cancel this fix is for arrives in the middle of a touch drag that is still going.
touchUp()function
assets/pages.js:269
Ends a touch drag: let the pointer drag go too, and forget the touch.
touchIdmember
assets/pages.js:271
A touch drag, held alongside the pointer one. Below 900px the chart sits in a .chart-scroll that pans sideways, and iOS decides whether that scroller has taken a sideways gesture before a pointer event can claim it, ignoring the touch-action: pan-y the knob and line declare: pointerdown fires, the scroller takes the gesture, and pointercancel ends the drag before it has moved. So the first move of a touch decides who owns the gesture -- more along than up is the divider's, and says so with preventDefault(), which no scroller argues with; more up than along is left to the page, so the full-height line does not trap vertical scrolling. While it is ours these events drive the divider themselves, and the drag survives the pointer stream being cancelled under it. touchId: the identifier of the touch being followed; owns: null until the axis is decided.
tracked(list)function
assets/pages.js:283
Returns Touch|null The touch this drag follows, if it is still down.
touchDown(e)function
assets/pages.js:288
Takes note of a touch on the divider. Nothing is claimed yet: the first move decides.
Listens TouchEvent#touchstart
touchMove(e)function
assets/pages.js:299
Follows a touch, deciding on the first move whose gesture it is.
Listens TouchEvent#touchmove
note
assets/pages.js:348
Takes this chart's resting position again, after the geometry has moved under it: the chart's final width at load, a resize, a phone turned on its side. Nothing moves before the entrance has run or after the visitor has taken the divider, and a chart that does not pan measures the same authored rest and stays where it is.
splits[chart.id]()function
assets/pages.js:360
This chart's entrance: from closed (chart only), the satellite wipes in from the east and settles at rest.
relax()function
assets/pages.js:368
Takes every divider's resting position again, once per animation frame, when the geometry the measurement was made against has moved: the charts' final widths at load, a resize, a phone turned on its side. One listener for the page.
Listens Window#load, Window#resize
Anchorage maps
assets/pages.js:382
Every inset carries a link to a satellite map at its pin, and the whole plate answers for it: Apple Maps on an Apple device, Google Maps anywhere else (the link is re-aimed where the insets are wired). With a MapKit token on the page the click opens a live map in a dialog instead; without one, or if anything about the map fails, the link still goes to Apple Maps. Apple is not contacted before a click in either form: MapKit JS is fetched on the first one.
The token is MAPKIT_TOKEN in tools/chart-pages.py, which writes it into a meta tag and emits the #mapbox dialog only when it is set. While it is empty, which it is today, everything inside the if block below is dormant and the insets behave as plain links.
MapRequesttypedef
Object · assets/pages.js:395
What the dialog needs to draw one anchorage, read from the opener link's data attributes.
mapMetamember
?HTMLMetaElement · assets/pages.js:406
The meta tag carrying the MapKit JS token, if the generator wrote one.
mapTokenmember
string · assets/pages.js:408
The token, or "" when the in-page map is off.
mapboxmember
?HTMLElement · assets/pages.js:410
The map dialog.
openMapmember
?function(HTMLAnchorElement): void · assets/pages.js:412
Opens the map dialog for an inset's link. Stays null when there is no token or no dialog, which is how the inset wiring below knows to leave the links alone.
mapFailed()function
assets/pages.js:428
Shows the fallback line over the map, unless MapKit is known to be working.
mapKeys(e)function
assets/pages.js:430
The dialog's keys: Escape closes, Tab wraps. The focusable set is rebuilt on every press because MapKit adds and removes its own controls, and an element counts only if it has a box on screen.
Listens document#keydown
closeMap()function
assets/pages.js:446
Closes the dialog, lets the page scroll again, and returns focus to the link that opened it.
loadKit()function
assets/pages.js:454
Loads and initializes MapKit JS, once. Later calls return the same promise.
Returns Promise<Object> Resolves with window.mapkit; rejects if the script cannot load or init throws.
drawMap(mk)function
assets/pages.js:477
Aims the map at the wanted anchorage: satellite view, framed to about the inset's own window, with one marker in the pin's colors (Depth Cyan disc, Deep Water numeral).
onApplemember
boolean · assets/pages.js:513
Whether this is an Apple device, where a maps.apple.com link opens the Maps app. Anywhere else (Android, Windows, Linux, ChromeOS) the insets go to Google Maps instead. An iPad asking for the desktop site calls itself a Mac, which is Apple either way.
Reveals, chart entrances and gauges
assets/pages.js:539
Three kinds of element enter once, when 15% of them is on screen and they are 60px clear of the viewport's bottom edge: .reveal blocks fade up, charts draw their route and place their pins, and the "how the week leans" gauges fill. Script only adds a class; pages.css owns the motion and its reduced-motion counterpart. Each element is unobserved after it has entered, so nothing replays.
enter(el)function
assets/pages.js:547
Marks one element as entered. Charts and gauges take .is-in; everything else takes .reveal-visible. A chart that registered a split entrance starts it here.
iomember
?IntersectionObserver · assets/pages.js:556
The shared observer; null in a browser without IntersectionObserver, where everything enters at once.
Chart and log linking
assets/pages.js:568
Pointing at, or focusing, a log entry lights its leg and pin on the chart, and pointing at a pin lights its entry: the link runs both ways. A row names its chart with data-for and its key with data-key; pins carry data-pin and route segments data-seg with the same key (a leg number on the itineraries page, an anchorage number on the destination pages).
setActive(chartId, key)function
assets/pages.js:576
Lights one key on one chart, or clears the chart. Sets data-active on the chart, which is what dims the other routes and pins in pages.css, and toggles .active on the matching pin, route segment and rows.
Floating action bar
assets/pages.js:609
Call, Email and Book, fixed to the viewport's right edge (its bottom edge on a phone). It shows once the hero has passed the middle of the viewport and hides over the closing section, which carries its own coral Book button: two corals never share a view (The One Coral Rule, DESIGN.md).
onScroll()function
assets/pages.js:619
Scroll handler, limited to one measurement per animation frame. Writes data-shown on the bar; pages.css fades it and takes it out of the pointer's way.
Listens window#scroll
ctaShiftmember
assets/pages.js:638
Keeps the bar on the visible bottom edge of a phone. A fixed element is laid out against the layout viewport, but a mobile browser's toolbar or keyboard shrinks the visual viewport inside it. The difference is written to --cta-vv on the root element, and the bar's bottom offset adds it.
Listens window#resize, VisualViewport#resize, VisualViewport#scroll
Theme
assets/pages.js:720
The footer's switch between the site's three themes: Night Watch, which is the stylesheet's own tokens; Ensign, which is the [data-theme="ensign"] block beside them; and Reader, which is the stylesheet's last section and rolls the design back to the browser's own. The theme is the root element's data-theme attribute and nothing else, so the stylesheet does all the drawing.
The choice is kept in localStorage under "moonstruck-theme" and never leaves the browser. It is put on before first paint by a one-line script in the head, because this file is deferred and the page would otherwise be drawn in Night Watch first; what is here shows the switch (it ships hidden, since without script it would do nothing), keeps its pressed state in step, and remembers a change. assets/home.js carries the same thing for the homepage.
themeSwitchmember
?HTMLElement · assets/pages.js:734
The switch in the footer.
themeMetamember
?HTMLMetaElement · assets/pages.js:736
The meta tag that colors the browser's own chrome.
syncTheme()function
assets/pages.js:738
Bring everything that follows the theme into line with the root element's attribute: each button's pressed state, and the browser's theme color, which is read back from the body's computed background so that no color is written here. The computed value, not the --ink-page token, because Reader's field is the system color Canvas, and only the computed value says what that is today.