The client
The client is the website's update and its boot. Most messages change one field and run nothing. The example does more, advancing a frame at a time while it plays. Every other animation on the site is an SVG that drives itself.
The update return
The update returns the next model and the commands to run, as a record of the two that Foldkit calls Return. A step that runs nothing leaves the commands out.
import { Array, Effect, Filter, Match, Option, pipe, Schema as S, Stream } from 'effect'
import { Dom, Runtime, Subscription } from 'foldkit'
import { define } from 'foldkit/command'
import { afterCommit } from 'foldkit/render'
import { animationFrameEntry } from 'foldkit/subscription'
import type { Return } from 'foldkit/update'
import { view } from './view'
import {
ClosedSearch,
Copied,
CopyReset,
drawsInPlace,
FocusedPalette,
Found,
SearchGroup,
GrewCard,
type Message,
Model,
type Player,
SectionScrolled,
SelectedSection,
SpottedSection,
StageMoved,
Ticked,
ToggledFullHeight,
MovedCaret,
OpenedSearch,
pathOf,
} from './model'
import { CHARACTERS, RUN, struckBy } from './example'
type Step = Return<Model, Message>The timed commands
One command carries a timer. Copying writes the text to the clipboard, waits out the flash, and resets — the write may fail on a browser that refuses the clipboard, so it flashes regardless of whether it landed, since a button that silently does nothing is worse than one that lies.
const CopyThenReset = define('CopyThenReset', {
args: { text: S.String },
messages: [CopyReset],
execute: ({ text }) =>
Effect.tryPromise(() => navigator.clipboard.writeText(text)).pipe(
Effect.ignore,
Effect.andThen(Effect.sleep('1400 millis')),
Effect.as(CopyReset()),
),
})Focus where the reader will type
A palette that opens without the keyboard's focus makes a reader click into it before they can type, which defeats the point of opening it from the keyboard. So opening the palette runs FocusPalette. It waits for the render that draws the palette to commit, since the field does not exist until then, and focuses it. A field that has already gone, because the reader closed the palette in the same breath, fails to focus, and the command lets that pass quietly.
const FocusPalette = define('FocusPalette', {
messages: [FocusedPalette],
execute: afterCommit.pipe(
Effect.andThen(Dom.focus('.palette-input')),
Effect.ignore,
Effect.as(FocusedPalette()),
),
})Jumping to a section
A section in the documentation's menu points at its heading by id, and clicking it scrolls that heading into view. The scroll waits a beat first, so the browser measures where the heading sits after the menu has redrawn rather than before. scrollIntoView scrolls the window itself, and the heading clears the nav pinned above it by its own scroll-margin-top from the stylesheet. A heading that never rendered, in a chapter the reader has not opened, does nothing.
Opening a chapter scrolls the other way, back to the top of the window. A reader who opens the next chapter from the pager at the foot of the page should land on that chapter's head, not halfway down it.
const scrollIntoPreview = (id: string): void =>
Option.match(Option.fromNullishOr(document.getElementById(id)), {
onNone: () => undefined,
onSome: (target) => target.scrollIntoView({ behavior: 'smooth', block: 'start' }),
})
const ScrollToSection = define('ScrollToSection', {
args: { id: S.String },
messages: [SectionScrolled],
execute: ({ id }) =>
Effect.sleep('30 millis').pipe(
Effect.andThen(Effect.sync(() => scrollIntoPreview(id))),
Effect.as(SectionScrolled()),
),
})
const ScrollToTop = define('ScrollToTop', {
messages: [SectionScrolled],
execute: Effect.sync(() => window.scrollTo(0, 0)).pipe(Effect.as(SectionScrolled())),
})Finding your place in the menu
The menu lists every chapter of the book, so it runs taller than the window and scrolls inside its own card. Each chapter is a page of its own, and the browser opens every page with the menu scrolled to its top. Left at that, a reader who opens a chapter far down the list lands with that chapter out of sight, along with the sections listed beneath it.
So once the page is up, the client scrolls each menu until the open chapter is its first line. Two menus can hold the open chapter: the card beside the article, and the drawer that stands in for the card on a narrow screen.
chapterOffset measures how far the open chapter's row sits below the menu's first line, the label of the first part. Scrolling the menu to exactly that distance lifts the chapter's row into the label's place, with the chapter's sections listed under it. Every chapter follows the same rule, so the menu opens the same way on every page. A chapter near the end of the book cannot rise that far, and there the menu stops at its foot.
The book's first chapter is the one exception. Its row is already the first under its part's label, so the menu leaves it there, at offset nought, with the label in view. Opening the first chapter from the menu then looks the same as opening the source page from the nav.
const MENUS = '.menu-card, .drawer-body'
const chapterOffset = (menu: HTMLElement): Option.Option<number> =>
Option.all([
Option.fromNullishOr(menu.firstElementChild),
Option.fromNullishOr(menu.querySelector('.menu-item')),
Option.fromNullishOr(menu.querySelector('.menu-item.here')),
]).pipe(
Option.map(([first, opening, chapter]) =>
chapter === opening
? 0
: chapter.getBoundingClientRect().top - first.getBoundingClientRect().top,
),
)
const revealChapter = (menu: HTMLElement): void =>
Option.match(chapterOffset(menu), {
onNone: () => undefined,
onSome: (offset) => menu.scrollTo({ top: offset }),
})The stylesheet hides one of the two menus at every width: the drawer on a wide screen, the card on a narrow one. A hidden menu measures zero, so it stays at its top. A page with no menu has no open chapter to find, and the command does nothing there.
RevealChapter scrolls the menus twice. The first time is as soon as the hydrated page has painted, so the reader never watches the menu sit at its top and then jump. The page may still be drawing in a fallback font at that moment. When the web fonts arrive they can rewrap a long chapter title and move every row below it, so the chapter's row lands a little off. So the command waits for the page to finish loading, with its fonts, and scrolls the menus once more. It answers with the same quiet SectionScrolled the other scrolls answer with.
const revealChapters = Effect.sync(() =>
Array.fromIterable(document.querySelectorAll<HTMLElement>(MENUS)),
).pipe(
Effect.flatMap((menus) =>
Effect.forEach(menus, (menu) => Effect.sync(() => revealChapter(menu)), { discard: true }),
),
)
const pageLoaded = Effect.promise(
() =>
new Promise<void>((resolve) =>
document.readyState === 'complete'
? resolve()
: window.addEventListener('load', () => resolve(), { once: true }),
),
)
const RevealChapter = define('RevealChapter', {
messages: [SectionScrolled],
execute: afterCommit.pipe(
Effect.andThen(revealChapters),
Effect.andThen(pageLoaded),
Effect.andThen(Effect.promise(() => document.fonts.ready)),
Effect.andThen(revealChapters),
Effect.as(SectionScrolled()),
),
})Leaving for the source page
The search palette moves between pages without a link, by the message alone, so it can ask for the source page from a page that holds no book. Switching there on the spot would draw the source page's frame around nothing. So the update keeps the current page and runs LoadPage, which hands the address to the browser. The browser fetches the source page, already drawn by the server, and keeps the current page up until it arrives. LoadPage answers with ClosedSearch, so the palette is shut by the time the page goes.
const LoadPage = define('LoadPage', {
args: { href: S.String },
messages: [ClosedSearch],
execute: ({ href }) =>
Effect.sync(() => window.location.assign(href)).pipe(Effect.as(ClosedSearch())),
})Asking the server
The palette's search runs on the server, which holds the index of every page. Search asks it for a query's results. It waits a moment first, so a reader typing a word sends one request for the word rather than one for every letter. A request for each letter would still go out, but each arrives after the next letter has started a newer one, and the update keeps only the answer to the query still in the field. So Search answers with Found, naming the query it answered. A server that cannot be reached, or an answer that does not decode, finds nothing rather than failing the palette. A query of fewer than two characters is never sent, since the server finds nothing for it either.
const SETTLE = '140 millis'
const searchable = (query: string): boolean => query.trim().length >= 2
const decodeGroups = S.decodeUnknownOption(S.Array(SearchGroup))
const Search = define('Search', {
args: { query: S.String },
messages: [Found],
execute: ({ query }) =>
Effect.sleep(SETTLE).pipe(
Effect.andThen(
Effect.tryPromise(() =>
fetch(`/search?q=${encodeURIComponent(query)}`).then((response) => response.json()),
),
),
Effect.map((body) => Option.getOrElse(decodeGroups(body), () => [])),
Effect.catchCause(() => Effect.succeed([])),
Effect.map((groups) => Found({ query, groups })),
),
})Moving the stage under the reader
The transport moves the stage to three places, and StageMark names them. The caret is where the run wants it while the document is being typed. The foot is where skipping and the end of a run leave it. The head is where the back button puts it, since a reader asking for the document from the start is asking to see its first line, not the line they happened to stop on.
An editor does not creep a line at a time. It holds still while the caret runs down the visible text, then steps when the caret would fall off the bottom. So the caret's mark scrolls three lines on rather than one, and only once the caret is within three lines of the foot. A line is twenty-eight pixels, which is the stage's own size at its own leading.
Every mark is clamped to the room the stage actually has, which is what answers a stage with nothing to scroll: the room is zero or less, and every mark comes out at the top.
const LINE = 28
const TAIL = LINE * 3
const StageMark = S.Literals(['caret', 'foot', 'head'])
type StageMark = typeof StageMark.Type
const markedTop = (mark: StageMark, stage: HTMLElement): number =>
Match.value(mark).pipe(
Match.when('head', () => 0),
Match.when('foot', () => stage.scrollHeight - stage.clientHeight),
Match.when('caret', () => stage.scrollTop + TAIL),
Match.exhaustive,
)
const moveStage = (mark: StageMark): void =>
Option.match(Option.fromNullishOr(document.getElementById('loom-stage')), {
onNone: () => undefined,
onSome: (stage) => {
const room = stage.scrollHeight - stage.clientHeight
stage.scrollTop = Math.max(0, Math.min(room, markedTop(mark, stage)))
},
})The foot waits a beat before it measures. It arrives with the whole rest of the document — and, at the end of a run, the two command buttons under it — so the stage it has to reach is taller than the one the browser has drawn. The other two marks measure at once, because neither depends on how tall the document has become.
const MoveStage = define('MoveStage', {
args: { to: StageMark },
messages: [StageMoved],
execute: ({ to }) =>
(to === 'foot' ? Effect.sleep('30 millis') : Effect.void).pipe(
Effect.andThen(Effect.sync(() => moveStage(to))),
Effect.as(StageMoved()),
),
})Gliding to the card before it opens
Growing the card is worth nothing if the reader cannot see the whole of it, so the page comes to the card before the card takes the height. The two do not happen together: the sheet's top comes to rest first, and only then does the height open under it. Running them at once reads as the page and the card both lurching, and the reader loses which of the two moved.
The card keeps its place in the page and opens downwards, so its top does not move while the page glides. CARD_AIR is the air left above it when the page arrives. A page already standing within four pixels of that skips the glide, because a scroll that short reads as a twitch.
const CARD_AIR = 24
const GLIDE = 320
const NUDGE = 4
const cardRest = (): Option.Option<number> =>
Option.map(
Option.fromNullishOr(document.getElementById('loom-card')),
(card) => card.getBoundingClientRect().top + window.scrollY - CARD_AIR,
)The page is tweened by hand rather than left to scrollTo's own smooth behaviour, because the glide has to say when it has finished and the browser's does not. Each frame reads the real clock, so a late frame lands where the elapsed time says it should rather than where the frame count would have put it, and the glide always takes its three hundred and twenty milliseconds.
The easing runs slow, fast, slow — a cubic either side of the halfway point. A page that starts and stops abruptly reads as a jump; this reads as the page being carried.
const eased = (part: number): number =>
part < 0.5 ? 4 * part * part * part : 1 - Math.pow(-2 * part + 2, 3) / 2
const glide = (from: number, distance: number, started: number): Effect.Effect<void> =>
Effect.sync(() => Math.min(1, (performance.now() - started) / GLIDE)).pipe(
Effect.tap((part) =>
Effect.sync(() => window.scrollTo(0, Math.round(from + distance * eased(part)))),
),
Effect.flatMap((part) =>
part < 1
? Effect.sleep('16 millis').pipe(Effect.andThen(glide(from, distance, started)))
: Effect.void,
),
)
const GlideToCard = define('GlideToCard', {
messages: [GrewCard],
execute: Effect.suspend(() =>
Option.match(cardRest(), {
onNone: () => Effect.void,
onSome: (rest) => {
const from = window.scrollY
return Math.abs(rest - from) <= NUDGE
? Effect.void
: glide(from, rest - from, performance.now())
},
}),
).pipe(Effect.as(GrewCard())),
})The update
Most messages set one field: the query, the focus clamped to the results, the loom tab's view. Following a link records the page it led to and closes the palette behind it, unless the page is the source page and the client holds no book to draw it with. Then the current page stays, and LoadPage sends the browser for the source page instead. Opening a documentation chapter records its slug, clears the section the menu was following, since the previous chapter's headings are no longer on the page, shuts the drawer behind the reader, and scrolls back to the top. Opening the palette puts the focus on its first result and the keyboard in its field; closing it clears the query, the focus, the caret and the results together, so the palette opens empty the next time. Moving the caret records where it now sits. Typing records the query and, once it is long enough to search, marks a search as on its way and runs Search. Results arrive as Found, and the update keeps them only when they answer the query still in the field. Opening a result with Enter hands its address to LoadPage, as a click on its link would. The example's messages move a position rather than set a flag: playing from the end starts again from the first character, a frame advances the clock and asks the stage to follow the caret, and skipping jumps both the clock and the stage to the foot. Going back to the start puts the clock, the height and the stage all back where they began — the document at its first character, the card down from full height, and the stage at the head of the document rather than wherever the reader stopped reading.
Asking for full height is the one press that changes nothing at once. It runs the glide and waits: the page arrives at the card, GrewCard comes back, and only then does the height open. Giving the height back is immediate, since nothing has to travel first. Asking for a command's file toggles it, so pressing the same button twice closes what it opened. Selecting a section sets the active one, shuts the drawer if the section was picked from it, and asks the window to scroll to the section's heading. A section spotted while the reader scrolls sets the active one too, but runs nothing, so the outline follows the scroll rather than fighting it. Copying flashes the pressed button and runs its reset.
const clamp = (value: number, max: number): number => Math.max(0, Math.min(max, value))
const CHARS_PER_SECOND = 26
const toggled = (player: Player): Player => {
if (player.playing) {
return { ...player, playing: false }
}
const from = player.struck >= CHARACTERS ? 0 : player.struck
return {
...player,
struck: from,
beat: from === 0 ? 0 : player.beat,
playing: true,
output: Option.none(),
}
}
const stepped = (model: Model, delta: number): Step => {
const beat = model.player.beat + (Math.min(delta, 32) / 1000) * CHARS_PER_SECOND
if (beat >= RUN) {
return {
model: {
...model,
player: { ...model.player, struck: CHARACTERS, beat: RUN, playing: false },
},
commands: [MoveStage({ to: 'foot' })],
}
}
const struck = struckBy(beat)
return {
model: { ...model, player: { ...model.player, struck, beat } },
commands: struck === model.player.struck ? [] : [MoveStage({ to: 'caret' })],
}
}
const update = (model: Model, message: Message): Step =>
Match.value(message).pipe(
Match.withReturnType<Step>(),
Match.tagsExhaustive({
ToggledPlay: () => ({ model: { ...model, player: toggled(model.player) } }),
Ticked: ({ delta }) => stepped(model, delta),
SkippedToEnd: () => ({
model: {
...model,
player: { ...model.player, struck: CHARACTERS, beat: RUN, playing: false },
},
commands: [MoveStage({ to: 'foot' })],
}),
ReplayedDocument: () => ({
model: {
...model,
player: { struck: 0, beat: 0, playing: false, output: Option.none(), full: false },
},
commands: [MoveStage({ to: 'head' })],
}),
ToggledFullHeight: () =>
model.player.full
? { model: { ...model, player: { ...model.player, full: false } } }
: { model, commands: [GlideToCard()] },
GrewCard: () => ({ model: { ...model, player: { ...model.player, full: true } } }),
ShowedOutput: ({ which }) => ({
model: {
...model,
player: {
...model.player,
output: Option.contains(model.player.output, which)
? Option.none()
: Option.some(which),
},
},
}),
StageMoved: () => ({ model }),
SelectedSection: ({ id }) => ({
model: { ...model, activeSection: id, drawerOpen: false },
commands: [ScrollToSection({ id })],
}),
SectionScrolled: () => ({ model }),
SpottedSection: ({ id }) =>
id === model.activeSection ? { model } : { model: { ...model, activeSection: id } },
WentTo: ({ route }) =>
drawsInPlace(route) || Option.isSome(model.book)
? { model: { ...model, route, searchOpen: false } }
: { model, commands: [LoadPage({ href: pathOf(route) })] },
OpenedChapter: ({ slug }) => ({
model: { ...model, chapter: slug, activeSection: '', drawerOpen: false },
commands: [ScrollToTop()],
}),
PickedRuntime: ({ runtime }) => ({ model: { ...model, runtime } }),
OpenedDrawer: () => ({ model: { ...model, drawerOpen: true } }),
ClosedDrawer: () => ({ model: { ...model, drawerOpen: false } }),
OpenedSearch: () => ({
model: { ...model, searchOpen: true, focus: 0 },
commands: [FocusPalette()],
}),
FocusedPalette: () => ({ model }),
ClosedSearch: () => ({
model: {
...model,
searchOpen: false,
query: '',
focus: 0,
caret: 0,
searching: false,
found: [],
},
}),
MovedCaret: ({ at }) => ({ model: { ...model, caret: at } }),
Typed: ({ query }) =>
searchable(query)
? { model: { ...model, query, focus: 0, searching: true }, commands: [Search({ query })] }
: { model: { ...model, query, focus: 0, searching: false, found: [] } },
Found: ({ query, groups }) =>
query === model.query
? { model: { ...model, found: groups, searching: false } }
: { model },
OpenedHit: ({ href }) => ({ model, commands: [LoadPage({ href })] }),
MovedFocus: ({ delta, count }) => ({
model: { ...model, focus: clamp(model.focus + delta, Math.max(0, count - 1)) },
}),
Copied: ({ id, text }) => ({
model: { ...model, copied: id },
commands: [CopyThenReset({ text })],
}),
CopyReset: () => ({ model: { ...model, copied: '' } }),
}),
)Spotting the section on scroll
The menu should follow the reader, not only lead them. While the documentation is on show, a subscription watches the window scroll, finds the heading nearest the top of the article, and marks that section in the menu. activeSectionId reads the article's own second-level headings, the same ones the menu lists under the open chapter. It keeps the ones scrolled up past a small offset and takes the last. When none has reached the offset yet, it falls back to the first.
const SPY_OFFSET = 100
const SPY_TARGETS = '.docs-article h2[id]'
const activeSectionId = (): Option.Option<string> => {
const targets = Array.fromIterable(document.querySelectorAll<HTMLElement>(SPY_TARGETS))
return pipe(
Array.last(Array.filter(targets, (el) => el.getBoundingClientRect().top <= SPY_OFFSET)),
Option.orElse(() => Array.head(targets)),
Option.map((el) => el.id),
Option.filter((id) => id.length > 0),
)
}Listening only when there is something to hear
Every listener here is conditional. Each one asks the model whether its work is happening at all, and listens only while it is. The section spy runs only while the documentation or the source page is on show. The other two belong to the example: the frame loop advances the clock while the document plays, and the escape key gives the page back while the card stands at full height.
The spy and the escape key both listen on the window. The windowEvents function turns a window event into a stream and keeps only the events that name a message, so a key other than escape passes by without a sound.
Two more belong to the search palette. While it is closed, a slash opens it, unless the reader is typing into a field of their own, where a slash is just a slash. The slash that opens the palette is kept from also landing in its field, so the opening key never becomes the first character of the query.
One more belongs to the prose of the documentation and the source page. A command written inside a sentence draws as a small copy button, but the prose is HTML that Markdown produced, so the button carries no handler of its own. While either is on show, a listener on the window catches any click inside an element marked data-copy, and copies the command it carries, reporting it under the id the button checks for.
While the palette is open, its caret follows the field's own. The palette draws a block caret of its own rather than the browser's, so it needs the field's caret position as state. A reader moves that position by typing, by the arrow keys, by clicking, or by selecting, and no single event covers all four. So the listener takes the selection changes the document reports, and the key, pointer and input events besides, and after each one reads where the field's caret now stands. It reads nothing while the field has lost focus.
const windowEvents = <E extends Event>(
type: string,
toMessage: (event: E) => Option.Option<Message>,
options?: { readonly passive?: boolean },
): Stream.Stream<Message> =>
Stream.fromEventListener<E>(window, type, options).pipe(
Stream.filterMap(Filter.fromPredicateOption(toMessage)),
)
const typingIn = (target: EventTarget | null): boolean =>
target instanceof HTMLElement &&
(target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName))
const slashOpens = (event: KeyboardEvent): Option.Option<Message> =>
event.key === '/' && !event.metaKey && !event.ctrlKey && !event.altKey && !typingIn(event.target)
? Option.some(OpenedSearch())
: Option.none()
const paletteCaret = (): Option.Option<number> =>
pipe(
Option.fromNullishOr(document.querySelector<HTMLInputElement>('.palette-input')),
Option.filter((field) => field === document.activeElement),
Option.flatMapNullishOr((field) => field.selectionStart),
)
const inlineCopy = (event: MouseEvent): Option.Option<Message> =>
pipe(
Option.liftPredicate(event.target, (target): target is Element => target instanceof Element),
Option.flatMapNullishOr((target) => target.closest<HTMLElement>('[data-copy]')),
Option.flatMapNullishOr((button) => button.dataset.copy),
Option.map((text) => Copied({ id: `inline:${text}`, text })),
)
const caretMoves: Stream.Stream<Message> = pipe(
[
Stream.fromEventListener(document, 'selectionchange'),
Stream.fromEventListener(window, 'keyup'),
Stream.fromEventListener(window, 'mouseup'),
Stream.fromEventListener(window, 'input'),
],
Stream.mergeAll({ concurrency: 'unbounded' }),
Stream.filterMap(
Filter.fromPredicateOption(() => Option.map(paletteCaret(), (at) => MovedCaret({ at }))),
),
)
const subscriptions = Subscription.make<Model, Message>()((entry) => ({
sectionSpy: entry(
{ spying: S.Boolean },
{
modelToDependencies: (model) => ({
spying: model.route === 'docs' || model.route === 'source',
}),
dependenciesToStream: ({ spying }) =>
spying
? windowEvents(
'scroll',
() => Option.map(activeSectionId(), (id) => SpottedSection({ id })),
{ passive: true },
)
: Stream.empty,
},
),
inlineCopies: entry(
{ reading: S.Boolean },
{
modelToDependencies: (model) => ({
reading: model.route === 'docs' || model.route === 'source',
}),
dependenciesToStream: ({ reading }) =>
reading ? windowEvents<MouseEvent>('click', inlineCopy) : Stream.empty,
},
),
opening: entry(
{ closed: S.Boolean },
{
modelToDependencies: (model) => ({ closed: !model.searchOpen }),
dependenciesToStream: ({ closed }) =>
closed
? Dom.streamFromEventFilterMapPreventDefault({
target: window,
type: 'keydown',
filterMapEvent: slashOpens,
})
: Stream.empty,
},
),
caret: entry(
{ open: S.Boolean },
{
modelToDependencies: (model) => ({ open: model.searchOpen }),
dependenciesToStream: ({ open }) => (open ? caretMoves : Stream.empty),
},
),
typing: animationFrameEntry<Model, Message>({
isActive: (model) => model.player.playing,
toMessage: (delta) => Ticked({ delta }),
}),
shrinking: entry(
{ full: S.Boolean },
{
modelToDependencies: (model) => ({ full: model.player.full }),
dependenciesToStream: ({ full }) =>
full
? windowEvents<KeyboardEvent>('keydown', (event) =>
event.key === 'Escape' ? Option.some(ToggledFullHeight()) : Option.none(),
)
: Stream.empty,
},
),
}))Booting
The server has already rendered the page, and it wrote the model it rendered from into the page beside it. Runtime.hydrate reads that model back, runs the same init over it, and adopts the server's markup in place rather than drawing the page a second time. The init function returns the model unchanged, because the server already chose the route, the version and everything else a first render needs.
The server cannot choose how far the menu scrolls, because only the browser knows how tall the window is. So init runs one command, RevealChapter, which brings the open chapter into the menu's view. Importing the stylesheet here, in the browser entry, keeps it out of the server's path.
import './landing.css'
const application = Runtime.makeApplication({
Model,
Flags: Model,
init: (seed: Model): Step => ({ model: seed, commands: [RevealChapter()] }),
update,
view,
subscriptions,
container: document.getElementById('root'),
})Hydration also checks that the page and the script come from the same build. A page rendered by one build and booted by the script of another can look identical and still mean something different, so Foldkit compares a build id the server stamps on the page with the one the client carries, and refuses a page whose id differs. The site agrees on that id without a build step of its own: the id is the entry script's path. Vite names the entry after a hash of its contents, so two builds never share one. The server reads the path from the shell it serves, and the client reads it from its own import.meta.url.
Runtime.hydrate(application, { buildId: new URL(import.meta.url).pathname })The module
::[The update return]
::[The timed commands]
::[Focus where the reader will type]
::[Jumping to a section]
::[Finding your place in the menu]
::[Leaving for the source page]
::[Asking the server]
::[Moving the stage under the reader]
::[Gliding to the card before it opens]
::[The update]
::[Spotting the section on scroll]
::[Listening only when there is something to hear]
::[Booting]