From 378097ee024cd619e30801a7a3a07eb2a354440e Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Mon, 14 Sep 2026 14:43:22 -0400 Subject: [PATCH] fix(site): place every landing beat the same way, ends included Each beat now sits one viewport apart down the scroller, centred and positioned by its own centre, so the opening beat scrolls away and the closing one scrolls in like the rest; the camera no longer places either end, which retires its scale-fade, its measured wordmark offset and the config those read. The scroll stops a quarter-viewport short of the last beat's resting line, which is what keeps the finished wordmark clear above the closing beat rather than behind it. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I6b4815322b436ee3ab54dde181c0356624049a4e --- site/README.md | 14 +-- site/src/config/anim.ts | 12 +-- site/src/data/landing-beats.ts | 12 +-- site/src/pages/index.astro | 190 +++++++++------------------------ site/tests/check-wasm.mjs | 17 +-- 5 files changed, 74 insertions(+), 171 deletions(-) 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
scroll
↓
-
-

{heroBeat.eyebrow ?? siteName}

-

{heroBeat.text}

-
- {scrollBeats.map((beat, i) => ( + {landingBeats.map((beat, i) => (
- {beat.eyebrow && {beat.eyebrow}} + {i === 0 + ?

{beat.eyebrow ?? siteName}

+ : beat.eyebrow && {beat.eyebrow}}

{beat.text}

+ {beat.links && ( + + )}
))}
- -