The inner pages are plain HTML written by tools/chart-pages.py and styled by assets/pages.css; this is their only script. It has no dependencies and is written in ES5 inside one IIFE, so 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, theme). What the pages load is assets/pages.min.js, which tools/minify.py builds from this file with the comments and spacing taken out; this is the one to read and edit.
Everything here is an enhancement. Without script a chart shows no divider, each anchorage's map control is a plain link to Apple Maps, and entrances fail open: .reveal blocks, pins and routes are hidden only while the root element carries data-entrances, which a one-line script in the head takes off again if this file has not started within four seconds. Each block below looks its elements up first and does nothing when they are missing, so one file serves every page.
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;
while it overflows it is given tabindex, role="group" and a name, as is the style guide's .scroll
data-entrances on the root element: set "armed" by the head script, marked "live" here once entrances are watched
.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. The exceptions are --split and --cta-vv, which are measurements only script can take.
Doc comments follow JSDoc, plus one house tag, @section, which opens a named group; the stylesheets use it too. tools/build-docs.py turns them into docs/javascript.html and docs/css.html.
Static capture switch
assets/pages.js:42
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:123
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.
scrollersmember
assets/pages.js:143
A box that scrolls sideways has to be reachable without a pointer, or a keyboard cannot see the far end of what is in it: a chart below 900px (which is also a desktop at 200% zoom) and the style guide's ledgers on a phone. While a box overflows it is a tab stop, so the arrow keys scroll it as they do any focused scroller, with a group role and a name that says why it took focus. While everything fits it is none of those, so a desktop at full size gains no empty stop.
Chart / satellite split
assets/pages.js:163
Every chart but the itineraries hero carries a satellite plate of exactly the same window, revealed by a draggable divider. Its 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 means something only while the whole chart is on screen. Once the chart is wider than the screen and pans, the divider rests at the middle of what is on screen instead, and is measured again whenever the geometry moves, unless the visitor has taken it 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": aria-valuenow is the divider's position, and aria-valuetext names the share of satellite, which is what a listener wants to know.
stillmember
boolean · assets/pages.js:189
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:195
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:201
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:218
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:231
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:243
Ends the entrance, so that later moves follow the pointer at once instead of easing over 1.5s.
at(e)function
assets/pages.js:245
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:251
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:267
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:278
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:284
Ends a touch drag: let the pointer drag go too, and forget the touch.
touchIdmember
assets/pages.js:286
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, claimed 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. touchId: the touch being followed; owns: null until the axis is decided.
tracked(list)function
assets/pages.js:297
Returns Touch|null The touch this drag follows, if it is still down.
touchDown(e)function
assets/pages.js:302
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:313
Follows a touch, deciding on the first move whose gesture it is.
Listens TouchEvent#touchmove
note
assets/pages.js:362
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:374
This chart's entrance: from closed (chart only), the satellite wipes in from the east and settles at rest.
relax()function
assets/pages.js:382
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:396
Every inset links to a satellite map at its pin, and the whole plate answers for it: Apple Maps on an Apple device, Google Maps anywhere else. 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. Nothing contacts Apple before a click: MapKit JS is fetched on the first.
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. It is empty today, so the dialog code below is dormant and the insets behave as plain links.
MapRequesttypedef
Object · assets/pages.js:408
What the dialog needs to draw one anchorage, read from the opener link's data attributes.
mapMetamember
?HTMLMetaElement · assets/pages.js:419
The meta tag carrying the MapKit JS token, if the generator wrote one.
mapTokenmember
string · assets/pages.js:421
The token, or "" when the in-page map is off.
mapboxmember
?HTMLElement · assets/pages.js:423
The map dialog.
openMapmember
?function(HTMLAnchorElement): void · assets/pages.js:425
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:441
Shows the fallback line over the map, unless MapKit is known to be working.
mapKeys(e)function
assets/pages.js:443
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:459
Closes the dialog, lets the page scroll again, and returns focus to the link that opened it.
loadKit()function
assets/pages.js:467
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:490
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:526
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:552
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:560
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:569
The shared observer; null in a browser without IntersectionObserver, where everything enters at once.
Chart and log linking
assets/pages.js:584
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:592
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:625
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 Book button, so two never share a view (The One Action Rule, DESIGN.md).
onScroll()function
assets/pages.js:635
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:654
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:748
The footer's switch between the site's two themes, Night Watch and Ensign, as on the homepage (assets/home.js has the full account): the theme is the root element's data-theme attribute and nothing else, kept in localStorage under "moonstruck-theme" and put on before first paint by a one-line script in the head. 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.
themeSwitchmember
?HTMLElement · assets/pages.js:757
The switch in the footer.
themeMetamember
?HTMLMetaElement · assets/pages.js:759
The meta tag that colors the browser's own chrome.
syncTheme()function
assets/pages.js:761
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 stylesheet's --ink-page so that no color is written here.