diff --git a/site/README.md b/site/README.md index 967b04d9..a67a5af7 100644 --- a/site/README.md +++ b/site/README.md @@ -194,13 +194,13 @@ of the same period: motion safety is `requestAnimationFrame` at native rate plus the standard `prefers-reduced-motion` query, full stop — no bespoke per-frame luminance-budget instrumentation anywhere in this codebase. -**Without the simulation.** The page's layout is the plain one until the -wasm module is up and motion is wanted; `body.anim-ready` is what hands the -hero and the footer beat to the camera. So a blocked or failed wasm fetch, -or `prefers-reduced-motion`, leaves both as ordinary blocks in the document, -and the footer beat's two calls to action are simply on the page rather than -waiting on a reveal that is not coming. `site/tests/check-wasm.mjs` checks -that the served markup carries those links and does not carry the class. +**Without the simulation.** The document places every beat, one per +viewport down the scroller, and the simulation only paints behind them. So a +blocked or failed wasm fetch, or `prefers-reduced-motion`, leaves the page +readable end to end, and the footer beat's two calls to action are simply on +it rather than waiting on a reveal that is not coming. +`site/tests/check-wasm.mjs` checks that the served markup carries those +links and that no beat ships styled transparent. **Where the copy lives.** The landing page prints no hand-written string of its own: every beat comes from diff --git a/site/src/config/anim.ts b/site/src/config/anim.ts index 9a85adc3..1d6d4684 100644 --- a/site/src/config/anim.ts +++ b/site/src/config/anim.ts @@ -15,7 +15,7 @@ // TypeScript is exactly the bug this file's structure exists to make // impossible. See `loadDefaultSchedule` below. // 2. Everything else — lattice size, camera/zoom pacing, the reveal -// band's position in *scroll* space, mesh/caption timing, the +// band's position in *scroll* space, mesh timing, the // free-run threshold — has no Rust equivalent (it is page // composition, not simulation physics), so it lives here as the // single source, read by the landing page and nowhere else. @@ -85,16 +85,6 @@ export const animConfig = { /** Flicker rate, radians/sec. */ meshFlickerRate: 0.8, - /** Beat 1 (the pinned origin-cell caption)'s fade-out window, expressed - * as camera scale relative to its own starting scale. */ - capOneFadeHigh: 0.35, - capOneFadeLow: 0.08, - - /** Beat 3 (the getting-started caption)'s margin below the wordmark's - * own rendered bounding box, and the progress past which it may appear. */ - capSixMarginPx: 28, - captionFadeProgress: 0.9, - /** Scroll fraction past which the simulation leaves deterministic replay * and free-runs instead — see FreeRun in the landing page's script. * Exposed here, not hardcoded in the page, per the brief: "a threshold diff --git a/site/src/data/landing-beats.ts b/site/src/data/landing-beats.ts index 6bce7cab..b47764c0 100644 --- a/site/src/data/landing-beats.ts +++ b/site/src/data/landing-beats.ts @@ -16,9 +16,7 @@ // blank line is a blank line. export interface LandingBeat { - /** DOM id (`cap-${id}`), and the handle index.astro's script uses for - * the hero and footer beats, which are camera-placed rather than - * scroll-placed. */ + /** DOM id (`cap-${id}`). */ id: string; /** Small-caps label above the text; omitted for a beat without one. */ eyebrow?: string; @@ -28,10 +26,10 @@ export interface LandingBeat { } /** - * In scroll order. The first entry is the hero — pinned to the origin cell - * and scaled with it as the camera pulls back — and the last is the - * footer, faded in under the finished wordmark. Everything in between is - * placed down the scroller by its index, alternating sides. + * In scroll order. Every entry is placed down the scroller by its index, + * centred on the viewport: the first is at rest on screen before any + * scrolling, and the last is still on screen when the scroll runs out, + * under the finished wordmark. */ export const landingBeats: readonly LandingBeat[] = [ { diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index baeb7674..eb61a699 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -12,11 +12,10 @@ // from the wasm module at startup via `loadDefaultSchedule` — see // anim.ts's own comment for why that, and not a second hand-copied set // of numbers, is the single source of truth. -// - Copy: every beat on this page — the hero the scroller opens on, the -// long-dwell captions, the post-reveal footer line — is one entry in -// `landingBeats` (site/src/data/landing-beats.ts), rendered by the loop -// below. Nothing here is a hardcoded string, and the scroller's height -// and each beat's position are derived from that list's length, so a +// - Copy: every beat on this page is one entry in `landingBeats` +// (site/src/data/landing-beats.ts), rendered by the one loop below. +// Nothing here is a hardcoded string, and the scroller's height and +// each beat's position are derived from that list's length, so a // beat is added or reordered in the data file alone. The old // hero/lede/cta strings and the three duplicate feature cards the // previous landing page carried are dropped, not folded in — the @@ -39,16 +38,27 @@ import { headerNav, footerNav, navAttrs } from "../config/nav"; import { landingBeats } from "../data/landing-beats"; import { siteDescription, siteName } from "../data/meta"; -// The hero and the footer beat are placed by the script below, against the -// simulation's camera; every beat between them is placed down the scroller -// here, one BEAT_SPACING_VH step apart. The scroller is long enough to hold -// them plus a lead-in before the first and the reveal dwell after the last. +// Every beat is placed down the scroller by its index, one BEAT_SPACING_VH +// step apart, and each is positioned by its own centre rather than its top +// edge: the inline `top` carries BEAT_ANCHOR_VH, which puts that centre +// where the eye rests — just above the middle of the viewport as the beat +// comes up. The first beat is therefore on screen at rest before any +// scrolling, and the last is still on screen when the scroll runs out, +// which is what the scroller's height works out to. const BEAT_SPACING_VH = 100; -const SCROLLER_TAIL_VH = 180; -const heroBeat = landingBeats[0]; -const footerBeat = landingBeats[landingBeats.length - 1]; -const scrollBeats = landingBeats.slice(1, -1); -const scrollerHeightVh = scrollBeats.length * BEAT_SPACING_VH + SCROLLER_TAIL_VH; +const BEAT_ANCHOR_VH = 45; +const VIEWPORT_VH = 100; +// The scroll stops this far short of the last beat reaching the resting +// line the others pass through, which is what leaves the finished wordmark +// clear above the closing beat rather than behind it. +const FINAL_BEAT_CLEARANCE_VH = 25; +const scrollerHeightVh = + (landingBeats.length - 1) * BEAT_SPACING_VH + VIEWPORT_VH - FINAL_BEAT_CLEARANCE_VH; +// The opening and closing beats differ from the rest in type treatment +// only — centred, and the opening one larger. Nothing about where they sit +// or when they arrive. +const typeClass = (i: number) => + i === 0 ? " cap-hero" : i === landingBeats.length - 1 ? " cap-footer" : ""; --- @@ -109,38 +119,20 @@ const scrollerHeightVh = scrollBeats.length * BEAT_SPACING_VH + SCROLLER_TAIL_VH /* A beat's text is plain text carrying its own line breaks (see landing-beats.ts) — never markup — so it renders pre-line. */ .caption p { margin: 0; white-space: pre-line; } - /* The hero: an ordinary block at the top of the document until the - simulation is running, and pinned to the origin cell via the camera - transform once it is — see updateHeroCap in the script below. */ - .cap-hero { position: static; margin: 96px auto 0; max-width: min(480px, 70vw); - text-align: center; font-size: 1.1em; padding: 20px 26px; } - body.anim-ready .cap-hero { position: fixed; top: 50%; left: 50%; margin: 0; - z-index: 3; transform: translate(-50%, -50%); will-change: transform, opacity; } - .cap-hero p { font-size: 1.3em; } - /* The beats that carry body copy — the ones down the scroller and the - footer beat under the finished wordmark — are one size, half again - the base caption, since they are what the page is actually there to - say. The hero keeps its own scale: the camera transform sizes it. */ - .cap-scroll, .cap-footer { padding: 18px 24px; font-size: 18.5px; } - /* The beats in between: placed down the scroller from their index (the - inline `top`), all on the same side so the eye returns to one column - rather than being thrown across the page between beats, and inset - from the viewport edge rather than pinned to it — closer to the - centre line is a shorter trip back to the start of the next line, - which is what a narrow screen needs most. */ - .cap-scroll { max-width: min(480px, 58vw); left: max(24px, 9vw); } - /* The footer beat carries the page's two calls to action, so by default - it is an ordinary block below the scroller: a reader whose browser - never ran the simulation, or who asked for reduced motion, scrolls to - it and reads it. `anim-ready` — which the script sets only once the - wasm module is running and motion is wanted — hands it to the camera - instead, faded in below the finished wordmark's own measured bounding - box (see updateFooterCap). */ - .cap-footer { position: static; margin: 0 auto 48px; max-width: min(560px, 80vw); - text-align: center; } - body.anim-ready .cap-footer { position: fixed; margin: 0; left: 50%; - z-index: 3; transform: translateX(-50%); opacity: 0; transition: opacity 1.4s ease; } - body.anim-ready .cap-footer.visible { opacity: 1; } + /* Every beat, placed the same way: down the scroller from its index + (the inline `top`, which is the beat's centre — see BEAT_ANCHOR_VH + above) and centred on the viewport in both axes, so the eye returns + to the same spot for every beat rather than being thrown across the + page or down it. One size, half again the base caption, since these + are what the page is actually there to say. The document places all + of them, so what the page says — the footer beat's two calls to + action included — is on it whether or not the simulation ever runs. */ + .cap-scroll { max-width: min(480px, 58vw); left: 50%; + transform: translate(-50%, -50%); padding: 18px 24px; font-size: 18.5px; } + /* The opening and closing beats: type treatment only. */ + .cap-hero { max-width: min(560px, 70vw); text-align: center; } + .cap-hero p { font-size: 1.25em; } + .cap-footer { max-width: min(560px, 80vw); text-align: center; } .cap-footer .links { display: flex; gap: 14px; justify-content: center; margin-top: 10px; } .cap-footer a { color: var(--accent); } #scroll-hint { position: fixed; z-index: 4; left: 50%; bottom: 18px; @@ -159,16 +151,14 @@ const scrollerHeightVh = scrollBeats.length * BEAT_SPACING_VH + SCROLLER_TAIL_VH @media (prefers-reduced-motion: reduce) { #scroll-hint .arrow { animation: none; } #scroll-hint { transition: none; } - body.anim-ready .cap-footer { transition: none; } #ov-grain { animation: none; } } @media (max-width: 700px) { .readout { max-width: 46vw; font-size: 11px; padding: 6px 10px; } .caption { max-width: 62vw; font-size: 11px; } .caption .eyebrow { font-size: 13.5px; } - .cap-scroll, .cap-footer { padding: 14px 18px; font-size: 16.5px; } - .cap-scroll { max-width: 74vw; left: 7vw; } - .cap-hero { max-width: 84vw; font-size: 1em; } + .cap-scroll { padding: 14px 18px; font-size: 16.5px; max-width: 74vw; } + .cap-hero, .cap-footer { max-width: 84vw; } header nav.site-links { display: none; } } @@ -231,38 +221,25 @@ const scrollerHeightVh = scrollBeats.length * BEAT_SPACING_VH + SCROLLER_TAIL_VH
{heroBeat.text}
-{beat.text}
+ {beat.links && ( +