S/Y Celestine logoCode reference

JavaScript reference

Generated from the JSDoc comments in the site's two scripts. The homepage's is assets/home.js; the three inner pages share assets/pages.js. Neither has a dependency; what the pages load is the minified copy tools/minify.py writes beside each one, so the comments here cost a visitor nothing. The two data files beside them, assets/availability.js and assets/instagram.js, are generated and are not documented here; ARCHITECTURE.md explains what they hold.

assets/home.js

The homepage is plain HTML, and this file is its only script. It has no dependencies, and it runs deferred, once the document has been parsed, inside one IIFE so that nothing leaks onto window. The inner pages have their own script, assets/pages.js, which mirrors several of the behaviors here (menu, reveals, floating bar, weather).

What the page actually loads is assets/home.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. That is why the comments below can be as thorough as they like: not one byte of them reaches a visitor.

The page reads without it. What script adds: the entrances, the nav's two states, the menu, the gallery and its two dialogs, the tour and its videos, the guest reviews, the log, the availability calendar, the footer weather and the floating action bar. The homepage's stylesheet has a "Without script" section for the few states that would otherwise wait for this file for ever.

Four parts of the page are written here, because they are made of data that is not in the markup:

the log                   from window.__instagram (assets/instagram.js, written by tools/sync-instagram.py)
the guest reviews         from window.__reviews (assets/reviews.js, written by tools/sync-reviews.py)
the availability calendar from window.__availability (assets/availability.js, written by
                          tools/sync-availability.py) and today's date
the gallery's thumbnails  from GALLERY_COUNT

Each is copied from a template element that sits in index.html where its copies go, so the markup and its inline styles stay in the page and this file only fills them in. The footer weather comes from Open-Meteo.

State lives in the DOM (classes, data attributes, the hidden attribute, custom properties) so that the stylesheet can do the drawing; this file does not set a color or a transition. The hooks it writes:

nav.scrolled, nav.menu-open, .nav-burger.active     the nav's state, and the menu's
aria-current="location"                              the nav link for the section being read
hidden                                               the menu and its backdrop, the two dialogs, #guests, #log, the open windows, the calendar's readout
data-revealed="yes"                                  an element that has entered
data-bg-on="yes"                                     a section whose background image may load
data-cal-day, data-lit="yes" / "maybe"               a calendar day's status, and whether it is in the chosen dates or a previewed set
aria-pressed on a day, data-chosen on a window link  which of the two offered the dates that are chosen
data-chosen on the readout, tabindex on a day        the readout's two states, and the grid's one tab stop
data-zoomed, --zoom-natural                          the plate zoom's size
data-wx-faded, data-plane-run, data-cta-shown        the weather's fade, the seaplane, the floating bar
--i, --cta-vv                                        a log entry's column, and the phone toolbar's height
data-settled="yes" on the root element               the page has loaded: in-page links scroll smoothly from here on

Doc comments follow JSDoc, plus one house tag, @section, which opens a named group; the stylesheets use the same tag. tools/build-docs.py turns them into docs/javascript.html.

Types

AvailabilityDatatypedef

Object · assets/home.js:53

window.__availability, as written to assets/availability.js by tools/sync-availability.py.

PropertyTypeDescription
sourcestringThe listing the blocks were read from.
asOfstringMonth the dates were last confirmed, as YYYY-MM.
first?stringFirst month worth drawing, as YYYY-MM, or null when there is nothing to show.
last?stringLast month worth drawing.
blocksArray<Array<string>>Runs of taken days, each [first day, last day, state], the days as YYYY-MM-DD and inclusive, the state one of "booked", "hold" or "unavailable". A day in no block is open.

InstagramDatatypedef

Object · assets/home.js:64

window.__instagram, as written to assets/instagram.js by tools/sync-instagram.py.

PropertyTypeDescription
sourcestringThe profile the posts were read from.
accountstringThe account's name, without the @.
postsArray<{shortcode: string, url: string, date: string, place: string, kind: string, caption: string, image: string}>The nine latest posts the sync has seen, newest first. date is the islands' day as YYYY-MM-DD; place may be empty; kind is "photo", "video" or "album"; caption is the whole caption, hashtags and all; image is the local copy's path.

ReviewsDatatypedef

Object · assets/home.js:75

window.__reviews, as written to assets/reviews.js by tools/sync-reviews.py.

PropertyTypeDescription
sourcestringThe listing the reviews were read from.
reviewsArray<{id: string, yacht: string, when: string, month: string, who: string, where: string, text: string, images: Array<{plate: string, full: string}>}>Every review on the listing, in its order. yacht is "celestine" or "previous", the second being a charter the captain ran before her; when is the month as the listing writes it ("June 2026") and month the same as YYYY-MM, both empty on a previous-yacht review; who and where are the signature and the hometown, either of which may be empty; text is the guest's words, paragraphs separated by newlines; images holds up to three photographs, each {plate, full}: the square cut the bubble draws and the whole frame its zoom opens, both local paths. It is empty when the review came with no photograph.

Datestypedef

Object · assets/home.js:89

A stay a visitor is being shown, or the dates they have chosen.

PropertyTypeDescription
aDateThe first day: the day a charter would board.
bDateThe last day.
kindstring"week" for a week begun on a day in the grid, "window" for a whole open window. The two can share a first and last day, so a choice this tab is holding names which it was.
spanstringHow long it is, in words: "7 nights" for a week begun in the grid, "21 days open" for a whole window, which is how long the boat is free rather than a charter's length.
cell?HTMLElementThe control that offered them, so that it can say it is the chosen one and take the focus back when they are let go: a day in the grid, or a window's link.

Readingtypedef

Object · assets/home.js:102

One harbor's current conditions.

PropertyTypeDescription
namestringPlace label, as shown.
tempnumberTemperature in whole degrees Fahrenheit.
windnumberWind speed in whole knots.
codenumberWMO weather interpretation code, as Open-Meteo reports it.

The log

assets/home.js:124

The nine latest Instagram posts. assets/instagram.js says what was posted; how it reads is decided here. Written before the reveal observer is set up, so that the entries enter like everything else. If the file does not load there are no entries, and the section stays hidden.

renderLog()function

assets/home.js:131

Fills #log from window.__instagram and shows it. One list item per post, copied from the list's template.

Guest reviews

assets/home.js:169

The reviews on Celestine's broker listing, as a row of speech bubbles read by scrolling sideways. assets/reviews.js says who wrote what; how it reads is decided here. Written before the reveal observer below, so the cards enter like everything else: a card out to the right of the track is clipped by it, so it enters when it is scrolled to, not before.

Only Celestine's own reviews are drawn. The file also carries the reviews from the charters the captain ran before her, which are true and are not hers.

renderGuests()function

assets/home.js:180

Fills #guests from window.__reviews and shows it. The row has no controls to wire: it is swiped, scrolled sideways, or moved with the arrow keys once the track has focus.

showWhole(card, open)function

assets/home.js:240

Shows a card's whole review or its pull-quote again, and says so on the button.

ParameterTypeDescription
cardElementOne [data-guest], original or copy.
openbooleanWhole words, or back to the quote.

EASE_REVEALmember

assets/home.js:273

The stylesheet's --ease-reveal, written out for the Web Animations API: one soft ease-out, the same curve the page's entrances use, so a card unfolding belongs to the same hand as everything else.

easeReveal(p)function

assets/home.js:277

That same curve as a number, for the slide below, which is tweened by hand and cannot hand a string to the browser. The curve is read the way CSS reads it: find the t whose x is p, then take its y. Sixteen halvings put x within a pixel of where it should be at any duration the page uses.

ParameterTypeDescription
pnumberHow far through the tween, 0 to 1.

Returns number How far along the movement, 0 to 1.

GUEST_OPEN_MSmember

assets/home.js:289

How long a card takes to unfold its whole review, in milliseconds. Long enough to follow the words down the card, short enough not to feel like waiting.

GUEST_CLOSE_MSmember

assets/home.js:292

And to fold back to the pull-quote. An exit is quicker than an entrance: the visitor has finished reading.

foldWords(said, from, open)function

assets/home.js:295

Unfolds a card's words from the height they stood at to the height they now want, and brings them up from half-lit as they go, so a review opens and closes as one movement rather than as a jump the eye has to catch up with. The bubble, its tail, the photographs and the row's own height all follow the element, so only this one is animated.

Height is a layout property, which is the reason it is animated on one element in one card rather than anywhere a cheaper property would do: the words have to reflow to their new shape, and nothing a transform can express would be honest about that.

Under reduced motion nothing moves: the words arrive at their new height at once and only come up from half-lit, which is the part that says something changed.

ParameterTypeDescription
saidHTMLElementThe [data-guest-words] element, already carrying its new words.
fromnumberThe height it stood at before the words changed, in pixels.
openbooleanUnfolding to the whole review, or folding back to the quote.

GUEST_WORD_LIMITmember

assets/home.js:328

The most words a card quotes. The row is read at a glance while it turns, so a card holds a pull-quote rather than a letter: the rest of the guest's words are on the broker listing.

shorten(text)function

assets/home.js:332

Cuts a review to GUEST_WORD_LIMIT words, ending on a sentence where one falls late enough and on a word otherwise, and marks the cut with an ellipsis. Shorter reviews come back untouched.

ParameterTypeDescription
textstringthe guest's words, already one paragraph

Returns string what the card quotes

GUEST_LOOP_MINmember

assets/home.js:347

The fewest reviews that make a loop. With fewer, the row could not fill a wide window in its copies, so it keeps its ends.

GUEST_ADVANCE_MSmember

assets/home.js:349

How long the row rests on a card before it slides to the next, in milliseconds.

GUEST_SLIDE_MSmember

assets/home.js:351

How long that slide itself takes. The page's motion is slow and calm, and a card is a wide thing to move: at 900ms on the reveal curve the row sets off, carries, and settles, which is the movement of a page being turned rather than of something being flicked.

loopGuests(section, track)function

assets/home.js:356

Makes the row of reviews endless, and slides it on by one card every eight seconds.

Endless: the cards are written out three times, the guests' own in the middle and a copy either side, and the row rests in the middle copy. Every copy is the same width, so when the row has drifted into an outer one it is moved back by exactly one copy's width, with snapping switched off for that instant, and nothing on screen changes. The copies are aria-hidden, and their focusable parts are taken out of the tab order, so a screen reader and the Tab key meet each review once while a pointer still reaches the photographs on the copy in front of it. The copies do not carry the reveal class, so a card that turns up from a jump is simply there.

Sliding: one card's width, carried by hand over GUEST_SLIDE_MS on the page's reveal curve rather than by the browser's own smooth scrolling, so the pace is the page's everywhere and can be stopped mid-movement; the scroll snapping, off for the length of it, finishes the alignment. It stops, and starts its eight seconds again, whenever the visitor takes hold of the row (pointer, touch, wheel, keys) or has focus resting on it; it does not run while the section is off screen or the tab is hidden; and under reduced motion it does not run at all, though the row is still endless by hand.

ParameterTypeDescription
sectionHTMLElementThe #guests section.
trackHTMLElementThe scrolling row, with the guests' own cards already in it.

startOf(li)function

assets/home.js:395

Where a card starts along the row's scrollable width, from the layout as it stands.

ParameterTypeDescription
liElement

Returns number

shift(by)function

assets/home.js:399

Puts the row a whole number of copies away without moving anything the eye can see.

ParameterTypeDescription
bynumberPixels, a multiple of setWidth().

recenter()function

assets/home.js:406

Keeps the row inside the middle copy. Called when the row comes to rest, and before every slide.

unglide()function

assets/home.js:417

Stops a slide where it stands and gives the snapping back, which settles the row on the nearest card.

glide(to)function

assets/home.js:419

Slides the row to a place along its own scroll, on the reveal curve over GUEST_SLIDE_MS.

scrollTo({ behavior: "smooth" }) would do this, but at whatever speed and on whatever curve each browser has decided on, which on some is a quick mechanical slide that belongs to no design. Tweened by hand, every browser turns the page at the same unhurried pace, and the movement can be stopped the instant a visitor takes hold of the row. Snapping is off for the length of the tween, or it would pull the row back toward the card it is leaving, and comes back at the end, on the new card.

ParameterTypeDescription
tonumberWhere along the row to come to rest, in pixels of scrollLeft.

Availability calendar

assets/home.js:506

From the blocks in assets/availability.js and today's date: each day's status, the open windows worth offering, the days a charter can start on with the week each would begin, and the months to draw. Dates are compared as YYYY-MM-DD strings, which sort the same way the days do. The sync script says only which days are taken; everything a visitor sees is worked out here. If that file does not load there are no months to draw, and the section goes on saying that the calendar is being brought up to date instead of showing every week as open.

Choosing dates is the one thing the section does. A day a charter can start on is a button, and choosing it takes the week it would give; a window's link above the grid takes that window whole. Either way there is one choice (calChosen), and it travels: the grid inks it, the readout under the grid names it, every charter-inquiry Email on the page carries it into its subject line, and the tab remembers it. No price is worked out anywhere near the grid; that is the rate table's to say.

availmember

AvailabilityData · assets/home.js:523

MIN_WINDOW_DAYSmember

assets/home.js:534

The owner's floor for dates worth offering: four calendar days as drawn in the grid, so three nights.

blockOf(key)function

assets/home.js:537

The state of the block a day falls in, or "open".

ParameterTypeDescription
keystringThe day, as YYYY-MM-DD.

Returns string

statusOf(y, m, d)function

assets/home.js:547

A day's status as the grid draws it: "past", a block's state, "turnaround" or "open". The crew needs a clear day between charters, so an open day that touches a booking or a hold is that turnaround day: it reads as unavailable and is never offered. Neighbors are read from the blocks, not from this function, so a charter that ended yesterday still claims today.

ParameterTypeDescription
ynumberYear.
mnumberMonth, from zero.
dnumberDay of the month.

Returns string

STATUS_WORDSmember

assets/home.js:567

Each status in words, for the hidden text in every day: state is never carried by color alone.

openRuns()function

assets/home.js:570

The open windows: runs of at least MIN_WINDOW_DAYS open days, from today to the end of the last month drawn. They are what is left once the turnaround days are taken out, so a week between two charters lists as six days. Shorter runs stay in the grid but are not offered.

Returns Array<{a: Date, b: Date}> Each window's first and last day.

calDaysmember

HTMLElement[] · assets/home.js:594

Every day drawn, so that lighting a stay is one pass over them.

calStartsmember

Array<{key: string, stay: Dates}> · assets/home.js:597

The days a charter can start on, in date order, each with the stay it would begin.

calWindowsmember

Dates[] · assets/home.js:599

The open windows as dates, in the same order as the links that offer them.

calChosenmember

?Dates · assets/home.js:605

calPreviewmember

?Dates · assets/home.js:607

calBarmember

?HTMLElement · assets/home.js:609

The readout under the grid: the prompt, or the dates and what to do about them.

calStatusmember

?HTMLElement · assets/home.js:611

The same, said once for a screen reader.

keyboardFocus(el)function

assets/home.js:615

A mouse click's focus does not count as keyboard focus. Older browsers cannot tell, so they say yes.

ParameterTypeDescription
elElement

Returns boolean

countWords(n, unit)function

assets/home.js:618

"7 nights", "1 night".

ParameterTypeDescription
nnumberHow many.
unitstringThe thing counted, singular.

Returns string

stayFrom(day)function

assets/home.js:621

The stay a visitor would get by boarding on a day: that day and the seven after it (seven nights span eight dates), or as far as the open days run when a charter, a hold, a turnaround day or the end of the calendar comes first. A day can start a charter only where at least MIN_WINDOW_DAYS open dates run from it; every other day returns null and stays a plain cell in the grid. Turnaround days are never open, so a stay always leaves the day either side of a charter that the crew needs. A window longer than a week is not offered whole here; that is what its link above the grid is for.

ParameterTypeDescription
dayDateThe day they would board.

Returns ?Dates The stay, or null where there is none worth offering.

dateWords(dates, names)function

assets/home.js:644

A set of dates in words: long for the page ("January 6 to 13, 2027"), short for a mail subject ("Jan 6 to 13, 2027"). The short form stays ASCII, and reads like the other mailto links on the site: some mail clients hand back a mailto whose subject carries an en dash or a colon, and nothing opens.

ParameterTypeDescription
datesDatesThe stay or window.
namesstring[]MONTHS or SHORT_MONTHS.

Returns string

subjectFor(dates)function

assets/home.js:657

The subject line a charter inquiry carries for a set of dates.

ParameterTypeDescription
datesDates

Returns string

bodyFor(dates)function

assets/home.js:660

The first lines of that inquiry, so that a visitor who has chosen a week finds the email already written and only has to say who they are. The long date form reads better here than the subject's short one, and both stay ASCII for the same reason. Lines are broken with CRLF, which every mail client understands; a bare newline is dropped by some of them.

ParameterTypeDescription
datesDatesThe stay or window chosen.

Returns string

paintDays()function

assets/home.js:670

Lights the days of the chosen dates, and the days of any being looked at more quietly. Where the two overlap the chosen ink wins: it is the answer to which dates are the visitor's, and a preview is only the question. The ink is state, so it is never the only sign: a chosen day says it is pressed, and the readout under the grid says the dates in words.

previewDates(dates)function

assets/home.js:687

Shows the dates being looked at, or takes that preview away again.

ParameterTypeDescription
dates?DatesThe stay or window pointed at or focused, or null on leaving it.

markChoice(el, on)function

assets/home.js:696

Says on a control that its dates are the chosen ones. A start day is a button and says it is pressed; a window's link is a link, where pressed would be a lie, and carries data-chosen for the stylesheet.

ParameterTypeDescription
el?HTMLElementThe control, or null where the dates came with none.
onbooleanWhether it is the chosen one.

savedAs(dates)function

assets/home.js:709

How a choice is written down for the tab to bring back.

ParameterTypeDescription
datesDates

Returns string

chooseDates(dates, quiet)function

assets/home.js:712

Chooses dates, or lets them go. One choice at a time, whether it came from a day in the grid or from an open window's link: the grid inks it, the readout under the grid names it and offers a way out, every charter-inquiry email on the page takes it into its subject line, and the printed address offers that subject to copy. No price is shown or worked out here, on purpose: what a week costs is the rate table's to say.

ParameterTypeDescription
dates?DatesThe stay or window, or null to clear.
[quiet]booleanTrue to skip the spoken announcement, for a choice restored on load.

bringIntoView(dates)function

assets/home.js:737

Brings the month a set of dates falls in, or both of its months, clear of the nav. Dates chosen in the grid are already in view; this is for the window links above it, so that choosing one does not leave the grid's answer off screen.

ParameterTypeDescription
datesDatesThe stay or window just chosen.

focusDay(i)function

assets/home.js:759

Moves the keyboard's place in the grid. The days a charter can start on share one tab stop (a roving tabindex), so Tab passes the calendar in one step and the arrow keys move inside it.

ParameterTypeDescription
inumberIndex into calStarts.

Calendar length

assets/home.js:771

Nine months in one column is three and a half screens of a phone, between the open weeks and everything after them. At 440px and below the stylesheet shows the first three months while the grid says data-cal-all="no", and the button under it opens the rest. The state is the attribute; wider windows ignore it, and the button is not displayed there. Choosing an open week whose month is folded away, or reaching a folded month with the arrow keys, opens the grid first (bringIntoView and focusDay), so nothing ever points at something hidden.

CAL_PHONE_MONTHSmember

assets/home.js:781

How many months a phone shows before it is asked for more. The stylesheet has the same number.

calMoremember

?HTMLButtonElement · assets/home.js:783

The button under the grid.

calMonthsmember

assets/home.js:785

How many months were drawn, for the button's label. Set by renderCalendar().

showAllMonths(all)function

assets/home.js:788

Opens or folds the grid, and says so on the button.

ParameterTypeDescription
allbooleanWhether every month shows.

The email actions

assets/home.js:804

Every Email action on the page is a mailto link, and each one that asks about a charter is marked data-mail-dates in the markup: once dates are chosen, all of them carry those dates in their subject line and in the first lines of the message, so whichever Email the visitor reaches for opens half written, asking about the week they just picked. The footer's is an address rather than an action, and the one beside "current as of" asks for the latest dates, so neither is marked.

calMailsmember

Array<{el: HTMLAnchorElement, plain: string}> · assets/home.js:813

Every charter-inquiry mailto, as it reads when no dates are chosen.

paintMailto(dates)function

assets/home.js:816

Writes the chosen dates into every charter-inquiry mailto: the subject line, and a first line or two of the message itself, so the email opens half written. Clearing puts the plain links back.

ParameterTypeDescription
dates?DatesThe stay or window chosen, or null.

rateSeasons()function

assets/home.js:826

The rate seasons, read out of the rate table so that a price is still written in one place. Each season's column header carries data-season-months ("1-12": the whole year, while the listing has one rate), and its lowest per-person figure is the smallest [data-rate-pp] in that column, which is the largest party's.

Returns Array<{label: string, from: number, to: number, low: number}>

windowHeading(a, b, seasons)function

assets/home.js:841

What an open window is filed under: its rate season and that season's lowest per-person figure, so that nobody has to carry a price down from the rate table. A window that holds Christmas Day or New Year's Day is a holiday charter, which the rate table prices separately; a window that begins outside every listed season says so. The season is the one the window begins in.

ParameterTypeDescription
aDateThe window's first day.
bDateIts last day.
seasonsArrayWhat rateSeasons() returned.

Returns string

renderWindows(months)function

assets/home.js:861

Draws the open windows as links above the grid, grouped under the rate season each begins in, each a mailto with its dates in the subject line and each a way to make that window the chosen dates.

ParameterTypeDescription
monthsnumberHow many months the grid drew: with none there is nothing to list against.

listsmember

Object<string, Element> · assets/home.js:872

The groups drawn so far, by their heading, so that a season's windows share one list.

renderCalendar()function

assets/home.js:903

Draws the section: the "current as of" line, one grid per month from its template, in which every day a charter can start on is a button, then the open windows as links grouped under their rate season, each a mailto with its dates in the subject line. Then it wires the readout under the grid, the arrow keys inside it, and brings back any choice this tab was holding.

Reveals

assets/home.js:1003

Elements with the class .reveal enter once, when any of them is on screen and they are 80px clear of the viewport's bottom edge. Entering means data-revealed="yes"; the stylesheet owns the motion, including the slide-ins, the polaroid plates and the gallery stage's blur.

Deferred loading

assets/home.js:1018

What is kept off the first paint's back: the five section backgrounds and the 360 tour.

mountTour()function

assets/home.js:1034

Gives the 360 tour's iframe its address. Called at window load, so the tour cannot compete with the first paint. The iframe is lazy as well, so Kuula is only contacted once the tour nears the screen.

Listens window#load

Navigation

assets/home.js:1055

The bar's two states, the mark under the link for the section being read, and the mobile menu.

onMenuKeys(e)function

assets/home.js:1085

Keys while the mobile menu is open: Escape closes it, and Tab wraps from the burger (which stays on screen as the close control) through the drawer's links.

ParameterTypeDescription
eKeyboardEvent

Listens document#keydown

toggleMenu()function

assets/home.js:1101

Opens the drawer if it is closed and closes it if it is open. Open or closed is the drawer's hidden attribute; nothing else remembers it. Kept in step with it: the backdrop, the classes the stylesheet keys on (nav.menu-open, .nav-burger.active), aria-expanded, the page's scroll lock, the focus trap, and focus itself, which goes to the first link on opening and back to the burger on closing.

Scroll state

assets/home.js:1143

handleScroll()function

assets/home.js:1153

Reads the scroll position and settles three things:

the nav          past 100px it takes .scrolled: the glass bar over the hero becomes the navy bar
the floating bar shows once the hero has passed the middle of the viewport, as on the inner pages, and
                 hides while a coral Book of the page's own is on screen: the closing section's, or
                 the row under the calendar (The One Coral Rule). It stays over the calendar itself,
                 which on a phone is four screens long and is where an open week is found; at the
                 widths where the bar used to cover the grid's third column it is now a rail in the margin
the seaplane     flies once across the closing section when that section fills the middle of the
                 viewport (the log follows it now, so the page's foot is no longer the cue), and
                 is re-armed after 15 seconds

Each is written only when it changes. Called once at the start as well, for a page opened part-way down.

Floating action bar

assets/home.js:1194

ctaShiftmember

assets/home.js:1198

The last offset written to --cta-vv; zero is also what the stylesheet assumes before any write.

syncViewportOffset()function

assets/home.js:1200

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

Overlays and focus

assets/home.js:1226

The photo viewer and the plate zoom are real dialogs (role="dialog", aria-modal) that share one mechanism. Both are in the page, hidden, and carry data-overlay. Opening one remembers the element that had focus, stops the page scrolling behind it and lands focus on its Close button; closing returns focus to where it came from. Escape closes, Tab wraps inside. The mobile menu has the same trap, with the burger standing in as its close control.

overlaymember

?HTMLElement · assets/home.js:1235

The overlay that is open, or null.

overlayReturnmember

?Element · assets/home.js:1237

The element that had focus when it opened.

openOverlay(node)function

assets/home.js:1242

Opens an overlay.

ParameterTypeDescription
nodeHTMLElementThe viewer or the zoom dialog.

closeOverlay()function

assets/home.js:1256

Closes the open overlay and hands focus back. Its image is let go of, so the next opening does not begin by showing the last one.

onOverlayKeys(e)function

assets/home.js:1274

Keys while an overlay is open: Escape closes it, the left and right arrows turn the photo in the viewer, and Tab wraps among the overlay's buttons.

ParameterTypeDescription
eKeyboardEvent

Listens document#keydown

Plate zoom

assets/home.js:1302

Any element carrying data-zoom opens the zoom dialog on the file it names, under its own alt text. The image opens fitted to the window; a click enlarges it to 260% and the dialog scrolls to pan, and another click fits it again. The enlarged files carry their width in their names (-2052.webp), which is how the zoom knows never to enlarge past the source's own pixels.

setZoomed(zoomed)function

assets/home.js:1312

Sets the zoom's size and words its hint for the pointer in use.

ParameterTypeDescription
zoomedbooleanEnlarged, not fitted.

Arriving with a fragment

assets/home.js:1563

index.html#rates from an inner page's nav, or a shared link. The browser makes the jump itself as soon as it finds the target, but the log, the calendar and the thumbnails are written after that and push the target down, and so do images arriving above it; Safari does not anchor the scroll. So the jump is made again here, last of all, and once more at window load.

Smooth scrolling is switched on only after that (data-settled on the root element, which the stylesheet reads). A smooth scroll begun by the browser on arrival would still be traveling to where the target used to be, and would carry the page past every correction made here.

scrollToFragment()function

assets/home.js:1576

Instant, not smooth: this is arriving, not traveling. scrollIntoView() honors the sections' scroll-margin-top, so the landing clears the bar exactly as a nav link's does.

settle()function

assets/home.js:1587

The page has loaded: the last correction, then smooth scrolling from here on.

Listens window#load

assets/pages.js

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 (Google Maps off Apple devices, once script runs). 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
.scroll, .sg-main pre     the style guide's scrollers: a wide table, a code sample; all three take focus while they overflow
.float-cta, .close        the floating action bar, and the closing section it hides over
.between, .close          the two sections whose blueprint plate waits until it is within reach (.bg-on)
footer .weather           the footer conditions widget

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:49

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.

Mobile menu

assets/pages.js:59

The burger (shown at 920px and below) opens #mobile-menu, a navy drawer from the right that is marked up as a modal dialog. It mirrors the homepage's menu: focus moves to the first link on opening and back to the burger on closing, Tab wraps inside the drawer, Escape or a click on the backdrop closes it, and the page behind does not scroll meanwhile. Open or closed is the drawer's data-open attribute ("yes" / "no"), which pages.css reads.

burgermember

?HTMLButtonElement · assets/pages.js:68

The burger button in the nav bar.

menumember

?HTMLElement · assets/pages.js:70

The drawer itself.

siteNavmember

?HTMLElement · assets/pages.js:72

The bar the burger sits in. While the drawer is open it rises above it, so the burger stays in reach.

menuBackdropmember

?HTMLElement · assets/pages.js:74

The scrim over the page behind the drawer. pages.css shows it from the drawer's data-open.

menuOpen()function

assets/pages.js:76

Whether the drawer is open. The attribute is the state; nothing else remembers it.

Returns boolean

onMenuKeys(e)function

assets/pages.js:81

Keyboard handling while the drawer is open: Escape closes it, and Tab is wrapped so focus cannot leave the dialog. The trap runs from the burger (which stays on screen as the close control) through every link in the drawer. Focus that has strayed outside the set is pulled back to the burger. Attached to document only while the drawer is open.

ParameterTypeDescription
eKeyboardEvent

Listens document#keydown

toggleMenu()function

assets/pages.js:99

Opens the drawer if it is closed and closes it if it is open, keeping these in step: the drawer's data-open (which also shows the backdrop), the bar's .menu-open class (which lifts the burger above the drawer as its close control), the burger's .active class (which turns its three waves into a cross), its aria-expanded, the page's scroll lock, and focus. The focus call is deferred a tick so the drawer is displayed before it receives focus; preventScroll keeps the page where it was.

note

assets/pages.js:130

The drawer closes itself when the window grows past the breakpoint that hides the burger. The burger is the only close control, so without this a window widened while the drawer was open left the drawer standing with nothing to shut it and the page still locked behind it. The query is the inverse of the 920px one in the stylesheet: they have to move together.

Listens MediaQueryList#change

Chart panning

assets/pages.js:145

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.

Keyboard panning

assets/pages.js:165

A scroller that a finger can drag has to answer to the keyboard as well (WCAG 2.1.1). While its content is wider than it is, a .chart-scroll box takes focus as a named region, and the arrow keys pan it. So do the style guide's two scrollers, a .scroll box around a wide table and a code sample's pre, but as named groups: a page has three charts at most and some twenty tables, and twenty landmarks would bury the real ones. While everything fits a box is none of those things, because a tab stop that scrolls nothing is noise. pages.css draws the ring.

scrollersmember

NodeList · assets/pages.js:175

Every sideways scroller on the page.

scrollerName(box)function

assets/pages.js:177

What a scroller is called. A chart takes the name its drawing already has. A table or a code sample takes the heading of its section and says what it is, numbered when the section has several, so that no two names on a page are the same and none repeats the section's own.

ParameterTypeDescription
boxElementThe scroller.

Returns string

syncScrollers()function

assets/pages.js:194

Gives each scroller focus, a role and a name while it overflows, and takes them back when it does not.

Chart / satellite split

assets/pages.js:212

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). A finger is taken twice over: by the pointer events, and, where a chart pans, by touchmove as well, since that is the only thing an iOS scroller will let go of. See tmove below.

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:241

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:247

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:253

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:270

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:283

Moves the divider. The one place --split is written, so the custom property and the slider's ARIA values cannot disagree.

ParameterTypeDescription
vnumberPosition in percent from the chart's left edge; clamped to 0 to 100.

settle()function

assets/pages.js:295

Ends the entrance, so that later moves follow the pointer at once instead of easing over 1.5s.

at(e)function

assets/pages.js:297

The divider position a pointer event asks for, allowing for where the knob was taken.

ParameterTypeDescription
ePointerEvent|Touch

Returns number Percent from the left; the current value if the chart has no width yet.

down(e)function

assets/pages.js:303

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.

ParameterTypeDescription
ePointerEvent

Listens PointerEvent#pointerdown

move(e)function

assets/pages.js:319

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. A Touch is as good as a pointer event here: all it reads is clientX.

ParameterTypeDescription
ePointerEvent|Touch

Listens PointerEvent#pointermove

up()function

assets/pages.js:331

Ends a drag, however it ended: release, cancel, or losing the capture.

touchmember

?{x: number, y: number, mine: number} · assets/pages.js:333

The gesture in hand, while a finger is down: where it began, and whose it turned out to be (0 undecided, 1 the divider's, -1 the page's). Null between gestures and for a second finger.

tdown(e)function

assets/pages.js:339

Notes where a touch began. One finger only: a second means a gesture of the browser's.

ParameterTypeDescription
eTouchEvent

Listens TouchEvent#touchstart

tmove(e)function

assets/pages.js:345

The touch half of the same drag, and the reason there is one. Below 900px a chart pans sideways inside .chart-scroll, and iOS settles whether that scroller takes a sideways gesture before any pointer event can claim it: on a tablet held upright the divider would not move at all, while the same tablet on its side, where the chart fits and nothing scrolls, dragged perfectly. touch-action: pan-y already says a sideways drag on the knob is ours, and is not enough there, so the first move of each gesture decides instead: a drag more along than up has its default prevented, which no scroller argues with, and then drives the divider itself, so the drag survives whether or not pointermove does. A drag more up than along stays the page's, because the line is a full-height strip and must not trap the scroll.

ParameterTypeDescription
eTouchEvent

Listens TouchEvent#touchmove

tup()function

assets/pages.js:367

Ends a touch gesture, whichever way it went.

note

assets/pages.js:400

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:412

This chart's entrance: from closed (chart only), the satellite wipes in from the east and settles at rest.

relax()function

assets/pages.js:420

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:434

Every inset carries a link to Apple's satellite map at its pin, and the whole plate answers for it. On anything that is not an Apple device the link is pointed at Google Maps' satellite view instead. 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:447

What the dialog needs to draw one anchorage, read from the opener link's data attributes.

PropertyTypeDescription
latnumberLatitude of the pin: where the yacht lies.
lonnumberLongitude of the pin.
dlatnumberHeight of the region to show, in degrees; about the inset's own window.
dlonnumberWidth of the region to show, in degrees.
nstringThe anchorage's number on the index chart, shown as the marker's glyph.
namestringThe anchorage's name.

mapMetamember

?HTMLMetaElement · assets/pages.js:458

The meta tag carrying the MapKit JS token, if the generator wrote one.

mapTokenmember

string · assets/pages.js:460

The token, or "" when the in-page map is off.

mapboxmember

?HTMLElement · assets/pages.js:462

The map dialog.

openMapmember

?function(HTMLAnchorElement): void · assets/pages.js:464

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:480

Shows the fallback line over the map, unless MapKit is known to be working.

mapKeys(e)function

assets/pages.js:482

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.

ParameterTypeDescription
eKeyboardEvent

Listens document#keydown

closeMap()function

assets/pages.js:498

Closes the dialog, lets the page scroll again, and returns focus to the link that opened it.

loadKit()function

assets/pages.js:506

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:529

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 (Shallow Water disc, Midnight Hull numeral).

ParameterTypeDescription
mkObjectThe mapkit namespace.

appleDevicemember

boolean · assets/pages.js:564

Whether this is an Apple device, where the generator's Apple Maps links stay as written. Everything else (Android, Windows, Linux, ChromeOS) is sent to Google Maps instead. An iPad asking for the desktop site calls itself a Mac, which is the right answer here too.

googleHref(link)function

assets/pages.js:571

The Google Maps form of an inset's Apple Maps link: the same center and zoom, satellite view, in Google's documented Maps URLs. That form draws no pin, so the view is centered on the anchorage.

ParameterTypeDescription
linkHTMLAnchorElementAn inset's map control, as the generator wrote it.

Returns string The Google Maps address for the same view.

Reveals, chart entrances and gauges

assets/pages.js:601

Three kinds of element enter once, when any of them is on screen and they are 80px 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:609

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.

ParameterTypeDescription
elElement

iomember

?IntersectionObserver · assets/pages.js:618

The shared observer; null in a browser without IntersectionObserver, where everything enters at once.

Deferred plates

assets/pages.js:630

The blueprint plates behind .between and .close are background images, which a browser fetches with the first render however far down the page they are. pages.css withholds them until the section takes .bg-on, which happens here once it comes within two screens or so of the viewport. Under ?static, and without IntersectionObserver, every plate is let through at once.

Chart and log linking

assets/pages.js:648

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:656

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.

ParameterTypeDescription
chartIdstringThe chart element's id.
key?stringThe key to light, or null to clear.

Floating action bar

assets/pages.js:689

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:699

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:718

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

Generated by tools/build-docs.py from the comments in the source. To change this page, change the comment and run it again.