import { getContext, setContext, untrack } from "svelte"; import { MediaQuery } from "svelte/reactivity"; /* * The thread: one indigo line through the whole page. It's tied off over the hero's * notification stack, drops out from under it, swings over to the rail and runs down beside * every section into the CTA's knot. * * On load it draws in order (Intro): the hero's top thread, then the stack, then the line * out from under it. After that scroll draws it. * * The whole line lies in the page and draws with the scroll: its pen sits a little below * the middle of the screen, and knots are tied beside their headings as the line reaches * them. On desktop it swings out of the hero's curl over to the rail; below, it hooks off the * stack's knot. * * Sections take part through anchors: `{@attach thread.knot({ ... })}` on a heading ties a * knot for it. The rail (ThreadRail.svelte) measures and draws; this class only holds the * shared state. */ const DESKTOP = "(width >= 64rem)"; /** Where the pen sits, as a share of the viewport height. */ export const PEN = 0.62; export type Breakpoint = "all" | "desktop" | "narrow"; export interface KnotAnchor { id: string; /** What the knot marks: its section's or its beat's heading. */ label: string; el: HTMLElement; on: Breakpoint; } export type IntroStep = "waiting" | "top" | "stack" | "bottom" | "done"; export class Thread { readonly desktop = new MediaQuery(DESKTOP, true); /** Every knot anchor on the page, in document order. */ anchors = $state.raw([]); /** The load sequence: which part of the hero is drawing. */ intro = $state("waiting"); /** * How far the line has run past its end into the CTA (px). The rail sets it; the CTA's * knot pulls when its lead is drawn. */ spill = $state(0); /** The CTA's lead: how far the line runs into it, from the rail to "Come" (px). The CTA * sets it, so the rail can run the line all the way in at the foot of the page. */ lead = $state(0); /** The CTA has come into view (the CTA sets it): the line runs the rest of the way into it * then and there, whatever the scroll. */ reached = $state(false); /** The rail's x on the page, where the CTA picks the thread up. */ railX = $state(0); /** * The line has run all the way into the CTA (the CTA sets it). From then on it stays * drawn, knots tied: scrolling back up doesn't undo it. */ finished = $state(false); /** Elements the rail measures. */ els = $state.raw>>({}); /** Registers a knot on the element it's attached to. */ knot = (spec: Omit & { on?: Breakpoint }) => (el: HTMLElement) => { const anchor: KnotAnchor = { on: "all", ...spec, el }; // Attachments run in an effect: don't let it depend on the list it writes. untrack(() => { this.anchors = [...this.anchors, anchor].sort((a, b) => a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1 ); }); return () => { untrack(() => { this.anchors = this.anchors.filter((a) => a !== anchor); }); }; }; /** Registers an element the rail measures. */ mark = (name: keyof Thread["els"]) => (el: HTMLElement) => { untrack(() => { this.els = { ...this.els, [name]: el }; }); return () => { untrack(() => { if (this.els[name] === el) this.els = { ...this.els, [name]: undefined }; }); }; }; /** The anchors that tie a knot at the current width. */ get active() { const on = this.desktop.current ? "desktop" : "narrow"; return this.anchors.filter((a) => a.on === "all" || a.on === on); } } const KEY = Symbol("thread"); export const setThread = (thread: Thread) => setContext(KEY, thread); export const getThread = () => getContext(KEY);