diff --git a/.claude/skills/cinematic-scroll-portfolio/SKILL.md b/.claude/skills/cinematic-scroll-portfolio/SKILL.md index def7789..fcfeca3 100644 --- a/.claude/skills/cinematic-scroll-portfolio/SKILL.md +++ b/.claude/skills/cinematic-scroll-portfolio/SKILL.md @@ -1,113 +1,156 @@ --- name: cinematic-scroll-portfolio -description: Build a cinematic, scroll-driven portfolio or landing page where giant typography is the layout, images live inside letterforms, and a pinned scroll timeline tells a story project by project, while staying fast (60fps on phones) and fully indexable by search engines. Use this for any request mentioning a cinematic, editorial, awwwards-style, big-type or "not a template" site, a hero word that zooms open as you scroll, text masked over an image or 3D scene, a project timeline or journey that animates as you scroll, alternating left/right project chapters with a moving graphic in the center, giant section numbers or outlined ghost words, or making such a page SEO-friendly. Also use it to fix an existing scroll page that feels laggy, bouncy, noisy, or leaves pieces of a zoom on screen. +description: Build a cinematic, scroll-driven site (portfolio, product launch, agency, event page) where giant typography is the layout, images live inside letterforms, a hero word zooms open as you scroll, and a pinned timeline tells a story chapter by chapter, while staying fast on phones and fully indexable by search engines. Also covers a custom cursor with hover labels and damage effects, a WebGL particle cloud that reacts to the visitor (light pulses, swarm reactions, particles that attack the cursor), a generative Web Audio soundtrack (intro hit, section drones, scroll-scrubbed effects, attack music, a start gate and a mute toggle), a scroll progress bar, and build-time prerendering. Use it whenever a request mentions a cinematic, editorial, awwwards-style, immersive or "not a template" site, giant or outlined type, text masked over an image or 3D scene, a scroll timeline or journey, alternating left/right chapters with a moving graphic in the middle, a custom or reactive cursor, interactive particles, sound design or background music on a web page, or making such a page SEO friendly. Also use it to fix an existing scroll page that feels laggy, bouncy, noisy, staggers on mobile, leaves pieces of a zoom on screen, or has sound that is too loud or annoying. --- -# Cinematic scroll portfolio - -This skill is a complete recipe. Follow it in order and copy the code in `references/` -nearly as written. Every rule below exists because breaking it produced a visible bug -in a real build. The failure is named next to each rule so you can recognise it. - -Read this file fully first. Then open only the reference file for the step you are on. - -## What you are building - -A single long page on a near-black ground: - -1. **Hero.** One or two giant words (for example FULL over STACK) cut out of a dark - veil laid over a background (a WebGL scene, a video, or an image). Scrolling zooms - the camera *into* one letter until that letter's solid stem fills the screen, and - only then does the veil dissolve. See `references/02-hero-knockout.md`. -2. **Sections.** Each section opens with a small index (`02 / 05`), one display word - fitted to the full column width, a short lede, and a giant outlined section - number bleeding off the right edge. Content inside uses typographic rows, not - cards. See `references/03-section-patterns.md`. -3. **Scroll timeline.** A pinned stage with a line running down the center that draws - itself as you scroll, a glowing comet on its tip, one node per project, a giant - outlined project number behind everything, and copy plus a 3D screenshot deck - that alternate left and right per project. See `references/04-scroll-timeline.md`. -4. **SEO layer.** All copy is in the served HTML through a build-time prerender, - with real headings, JSON-LD, and a share image. See `references/05-seo-prerender.md`. - -Foundations (tokens, fitted type, reveal system) are in `references/01-foundations.md`. -Performance rules and the checks that prove the page works are in -`references/06-verify-and-perf.md`. - -## Build order - -Do these in order. Each step is usable on its own, so stop and verify after each. - -1. Tokens, fonts, ground, grain (`01-foundations.md`, "Tokens"). -2. Reveal system and fitted type (`01-foundations.md`). Nothing else works without these. -3. Section shell with fitted title and ghost numeral (`03-section-patterns.md`, "Section shell"). -4. Section contents: index rows, marquee, numbers, rows, contact, footer (`03`). -5. Scroll timeline (`04-scroll-timeline.md`). Build desktop first, then the mobile override. -6. Hero knockout (`02-hero-knockout.md`). Hardest piece, needs the rest of the page to scroll into. -7. Prerender, head tags, JSON-LD, share image (`05-seo-prerender.md`). -8. Verification scripts (`06-verify-and-perf.md`). Run them, fix what fails, run again. - -## The ten rules that matter most - -1. **Fit display type, do not clamp it.** Measure at a reference size and scale: - `size = ref * targetWidth / measuredWidth`, then step down until it fits. A clamp - overflows long words and leaves gutters beside short ones. -2. **Hide content only under a class the script adds** (`html.reveal-armed`). If the - hidden state is plain CSS, the prerendered page and any page whose script failed - show blank sections. Symptom: sections are empty with JS disabled. -3. **Put the IntersectionObserver on the static clipping parent**, never on an element - that hides itself by translating out of its own clip. Symptom: a title that never appears. -4. **Every per-frame write is a transform or opacity.** Never animate width, height, - top, left, clip-path, filter or box-shadow per frame. Blur only on desktop, never - on phones. -5. **One rAF-throttled scroll handler per effect, writing straight to the DOM.** - React state changes only at discrete moments (a project handover), never per frame. -6. **Cache layout, read it only on resize.** Measure document offsets once, re-measure - from a `ResizeObserver` on `document.body` (debounced ~80ms). Reading - `getBoundingClientRect` inside the scroll handler forces layout every frame. -7. **Scroll-driven motion must never reverse while readable.** Drive entrances from - the distance to a beat's center on the way in, and only fade on the way out. - Ease the entrance (`1 - (1 - t)^3`) so it settles. Symptom: text that "bounces" - or kicks backwards as it passes the center. -8. **No counter-scroll parallax on reading copy.** Holding copy against the page - makes it lag, then catch up. It reads as bouncing. Parallax only decorative giants. -9. **Image-filled text must be softened and outlined.** Downscale-blur the image, - tone it into a fixed brightness band, tint it toward an accent, and add an - `em`-sized hairline stroke. Raw screenshots inside letters look like noise, dark - ones vanish into the ground, and light ones turn flat grey. -10. **A zoom through a word must end inside a solid part of a letter.** Pick a stem, - measure it from pixels, pan it to the frame center while scaling, and stop drawing - glyphs as text past ~6000px (swap in a rectangle). Chrome's SVG mask breaks on - glyphs around 10,000px tall and the veil comes back. Symptom: pieces of letters - left on screen, then a sudden cut, or the frame dimming near the end. +# Cinematic scroll site + +This skill is a complete recipe, written so that a small model can follow it. Read +this file fully first. Then open only the reference files your scenario needs (see +the map below), one at a time, and copy their code nearly as written. Every rule +here exists because breaking it produced a bug a real person saw or heard. The +symptom is named next to each rule so you can recognise it. + +## Reference files + +| File | What it covers | +|---|---| +| `references/01-foundations.md` | Tokens, film grain, fitted type, the reveal system, masked title reveals | +| `references/02-hero-knockout.md` | Words cut out of a veil over a scene; the zoom into a letter's stem | +| `references/03-section-patterns.md` | Section shell, ghost numerals, image-filled type, index rows, marquee, numbers, experience rows, contact, footer, far jumps | +| `references/04-scroll-timeline.md` | The pinned chapter timeline: line, comet, nodes, numeral, 3D decks, and keeping it synced to the scroll | +| `references/05-seo-prerender.md` | Build-time prerender, head tags, JSON-LD, share image, semantics | +| `references/06-verify-and-perf.md` | Playwright checks, frame-time A/B, headless caveats | +| `references/07-cursor-and-chrome.md` | Custom cursor, cursor labels and damage, start gate, scroll progress bar, mute toggle | +| `references/08-interactive-particles.md` | A WebGL point cloud that feels the visitor: light pulses, swarm startle, raids on the cursor | +| `references/09-sound-design.md` | The Web Audio soundtrack: graph, calm drone, intro hit, scroll-scrubbed warp, reveal sounds, attack music, testing | + +## Scenario map: what to read for what you are building + +| Scenario | Read | Skip | +|---|---|---| +| Full cinematic portfolio like the original | All, in the build order below | Nothing | +| Product or launch page (no 3D) | 01, 03, 04 (as a feature timeline), 05, 06, 07 (progress bar, cursor) | 02 needs a background: use a video or image in place of the WebGL scene; skip 08 | +| Agency or studio site | 01, 03 (index rows are the case studies), 04, 05, 06, 07 | 08, 09 unless asked | +| Adding "make it feel alive" to an existing site | 07 (cursor, progress bar), 03 (image-filled type), 09 (UI sounds only) | Rebuilding layout | +| Only the scroll timeline | 01 (reveal + fitted type), 04, 06 | The rest | +| Only sound | 09, 07 (start gate and mute toggle) | The rest | +| Only interactive particles | 08, 06; 09 if the particles should be heard | The rest | +| Fixing a laggy, bouncy or staggering scroll | 04 ("Performance contract", "Comet and lit line", "Common failures"), 06 | New features | +| Fixing SEO on a client-rendered cinematic site | 05, 06 (progressive enhancement check) | Visuals | +| Low-end phones must be smooth | 04 performance contract, 06 performance rules; drop blur and grain on phones; no raids on touch | Heavy effects on mobile | + +## Build order (full build) + +Each step is usable on its own, so verify after each. + +1. Tokens, fonts, ground, grain (01). +2. Reveal system and fitted type (01). Everything else depends on these. +3. Section shell with fitted title and ghost numeral (03). +4. Section contents: index rows, marquee, numbers, rows, contact, footer (03). +5. Scroll timeline, desktop first, then the mobile override, then the scroll sync (04). +6. Hero knockout (02). It needs the rest of the page to scroll into. +7. Prerender, head tags, JSON-LD, share image (05). +8. Cursor, scroll progress bar (07). +9. Interactive particles, if there is a WebGL cloud (08). +10. Sound: start gate, then the engine, then the triggers (07, 09). +11. Verification (06). Run it, fix what fails, run again. + +## The rules that matter most + +Layout and type +1. **Fit display type, do not clamp it.** Measure at a reference size and scale, then + step down by the overshoot and climb back while it fits (01). *Symptom: long words + overflow, short words leave gutters, an email address overhangs on phones.* +2. **Hide content only under a class the script adds** (`html.reveal-armed`). + *Symptom: blank sections with JS off or in the prerendered page.* +3. **Put the IntersectionObserver on the static clipping parent**, never on the + element that slides out of its clip. *Symptom: a title that never appears.* +4. **Image-filled text is softened, toned, tinted and outlined** (03). *Symptom: + letters full of screenshot text read as noise; dark images vanish, light ones go flat grey.* + +Scroll and motion +5. **Per-frame writes are `transform` and `opacity` only.** No width, top, clip-path, + filter or box-shadow per frame. Blur only on desktop. +6. **One rAF-throttled handler per effect, writing straight to the DOM.** Framework + state changes only at discrete moments (a chapter handover). +7. **Cache layout; re-measure only on resize** and on a body `ResizeObserver`. +8. **Anything locked to the scroll is not moved by JS.** Use `position: sticky` and CSS + scroll timelines, with JS only as a fallback (04). *Symptom: the comet or the tip of + a line staggers while scrolling, much worse on phones.* +9. **Scroll motion never reverses while readable, and reading copy gets no parallax.** + Animate in on approach with an ease-out, and only fade on the way out. *Symptom: + text "bounces" as it passes the centre.* +10. **A zoom through a word must end inside a solid stem**, measured from pixels, panned + to centre, with text swapped for a rectangle past ~6000px (02). *Symptom: pieces of + letters left on screen, then a cut, or the frame dimming near the end.* + +Interaction +11. **Movement answers intent; light answers presence.** Hover gets light, clicks get + movement. Pushing particles on hover looked cheap (08). +12. **Custom cursors are fine-pointer only** and never hide the text caret (07). +13. **Test automation skips gates** via `navigator.webdriver` (07). + +Sound +14. **No autoplay. Start from a gate click** (07, 09). +15. **Every gain starts at 0 at time 0.** `GainNode` defaults to 1. *Symptom: a loud + burst right after the start click.* +16. **Big hits fire on the visual state, not on a guessed timeline** (09). *Symptom: the + boom lands late or early.* +17. **The background is calm:** sines, slow swells, dark reverb, no whoosh on every + change. *Symptom: "the background sound is annoying".* +18. **Skip one-shots while suspended or muted**, or they all fire at once on resume. ## Visual system - Ground `#05050a` (sections) and `#020204` (hero). Type `#ece9f2` ("cream"). Muted copy `rgba(235,235,255,.55-.7)`. Hairlines `rgba(255,255,255,.08)`. -- One display face at one heavy weight (Inter Tight 800 works; a condensed face like - Anton works too). Only load weights you use; a missing weight is faked by the browser. +- One display face at one heavy weight (Inter Tight 800, or a condensed face like + Anton). Load only the weights you use. - Supporting labels: monospace, 9.5 to 12px, uppercase, `letter-spacing: .2em-.32em`. - The contrast between 10px labels and 300px words is the design. Do not enlarge labels. + The contrast between 10px labels and 300px words is the design. - Giants: `line-height: .84-.9`, `letter-spacing: -.03em to -.05em`, uppercase. -- One accent per section (or per project) used for the index number, a trailing dot - on the title, hairlines, glows. Never more than one accent on screen at a time. -- Atmosphere: a static radial glow of the accent at the top of each section at 6-11% - mix, plus film grain animated with `steps()`. -- Outlined giants: `color: transparent; -webkit-text-stroke: .005em-.012em `. - Size strokes in `em` so they track the font. +- One accent per section or per chapter, used for index numbers, a trailing dot on the + title, hairlines and glows, and crossfaded through a registered `@property`. +- Atmosphere: a static radial glow of the accent at 6 to 11% on each section (and on + the timeline's sticky stage, so every chapter gets it), plus film grain in `steps()`. +- Outlined giants: `color: transparent; -webkit-text-stroke: .005em-.012em `, + sized in `em`. +- Attack or damage states use a hot red `#ff4d6d` with red and cyan chromatic + offsets, never the page accent. -## Content rules +## Content and copy rules - Headings are statements or single words, never "Our Services". -- Every heading, lede, row and chapter is real text in the DOM. Decorative giants - (ghost numbers, marquee words, the timeline numeral) get `aria-hidden`. -- Say the same thing once. Duplicated copy (an `sr-only` copy next to split words, a - marquee repeating names) goes `aria-hidden` or is removed. +- Every heading, lede, row and chapter is real text in the served HTML. Decorative + giants, marquees and cursor text are `aria-hidden`. +- Say each thing once: no `sr-only` duplicate beside split words. +- Interactive copy is short and in the site's voice: a dare ("Don't touch. The swarm + is alive."), an answer ("It felt that."), a shout ("Ouch!", "Run."). Straight + apostrophes, no em dashes. +- Buttons say what happens in context ("Wake the swarm", "Enter without sound"), not + "Start" or "OK". + +## Working with the person + +- Visual and audio taste is theirs. Show screenshots, describe sounds with numbers you + can change, and ask what is off. Do not report "looks great" or "sounds right" from + your own read, and never claim to have heard audio. +- When they say something "looks stupid" or is "annoying", remove or replace it; do + not tune it and argue for it. +- When they say "it's too loud" or "not timed right" and a first fix does not land, + look for a bug (a default value, a wrong trigger) before adjusting levels again. +- If they stop a dev server, do not restart it; test the production preview instead. ## Stop conditions -Do not report the page as done until the checks in `06-verify-and-perf.md` pass: -no horizontal overflow at 390px and 1440px, comet within 2px of the viewport center, -veil coverage at 0% before the veil starts fading at 390px, 1440px and 2560px, -prerendered HTML containing every heading, and no console errors. +Do not report the page as done until these pass (06): +- no horizontal overflow at 390px and 1440px +- every fitted title fills its column without overhanging +- sections are readable with JS off, and every heading is in the prerendered HTML +- the timeline comet is within 2px of the viewport centre and the lit edge within 1px + of it, with scroll timelines and with the JS fallback +- the hero veil covers 0% of the frame before it starts fading at 390, 1440 and 2560px +- if there is sound: nothing plays before the gate, the context runs right after it, + the intro hit fires on the visual cue, mute survives a reload +- no console errors diff --git a/.claude/skills/cinematic-scroll-portfolio/references/01-foundations.md b/.claude/skills/cinematic-scroll-portfolio/references/01-foundations.md index 1017b03..b235e8a 100644 --- a/.claude/skills/cinematic-scroll-portfolio/references/01-foundations.md +++ b/.claude/skills/cinematic-scroll-portfolio/references/01-foundations.md @@ -101,7 +101,9 @@ because rendered width snaps to the pixel grid and a correction loop oscillates. ```ts const REF_PX = 200 const STEP = 0.004 -const MAX_STEPS = 4 +// Twelve, not four: at phone sizes the browser rounds every glyph advance, and a +// long line (an email address, 30 characters) can land 5px over at the solved size +const MAX_STEPS = 12 export function fitToWidth(el: HTMLElement, targetPx: number, trackingEm: number) { if (targetPx <= 0) return @@ -114,11 +116,22 @@ export function fitToWidth(el: HTMLElement, targetPx: number, trackingEm: number if (measured < 1) return let size = (REF_PX * targetPx) / measured setSize(size) - // Overflow is the failure that shows; undershooting 0.4% is invisible + // Overflow is the failure that shows; undershooting a fraction is invisible. + // Back off by the actual overshoot, never by less than one step. + let shrank = false for (let step = 0; step < MAX_STEPS; step++) { - if (el.getBoundingClientRect().width <= targetPx) break - size *= 1 - STEP + const width = el.getBoundingClientRect().width + if (width <= targetPx) break + size *= Math.min(1 - STEP, targetPx / width) setSize(size) + shrank = true + } + // The back-off can land a whole rounding step low; climb back while it still fits + for (let step = 0; shrank && step < MAX_STEPS; step++) { + const up = size / (1 - STEP) + setSize(up) + if (el.getBoundingClientRect().width > targetPx) { setSize(size); break } + size = up } } @@ -164,6 +177,9 @@ Markup and CSS for a fitted line: ``` Rules: +- Some sizes cannot be hit exactly: at 20.4px vs 20.5px a long line's width can jump + 5% because of glyph rounding. The loop above never overflows and gets as close as + the renderer allows. Accept a line that sits a few percent short on one phone width. - The fitted element must be one unbroken text run with `white-space: nowrap`. An inline child span (a coloured trailing dot) is fine. - Run the fit after the reveal system arms (see below). If a hidden state adds a diff --git a/.claude/skills/cinematic-scroll-portfolio/references/04-scroll-timeline.md b/.claude/skills/cinematic-scroll-portfolio/references/04-scroll-timeline.md index 43616fb..20d09fc 100644 --- a/.claude/skills/cinematic-scroll-portfolio/references/04-scroll-timeline.md +++ b/.claude/skills/cinematic-scroll-portfolio/references/04-scroll-timeline.md @@ -18,9 +18,16 @@ lane, puts the line in the left gutter, and pins the deck in a 44dvh band at the - One rAF-throttled scroll listener. It returns immediately while an IntersectionObserver says the timeline is off screen. -- Per frame it writes only: `transform` on the comet and on two line wrappers, - `opacity` and `transform` on rows and words of beats within 1.4 beats of the - center, and `transform`/`opacity` on the current project's cards. +- Per frame it writes only `opacity` and `transform` on rows and words of beats + within 1.4 beats of the center, and `transform`/`opacity` on the current + project's cards. +- **The comet and the lit line are never written per frame** where the browser has + scroll timelines. The comet's height is a sticky pin, and the lit line and the + comet's swing are CSS animations bound to the scroll (see "Comet and lit line" + below). A scroll handler always runs a frame behind a natively scrolling page, so + anything that must stay locked to the scroll (a comet on the viewport centre, the + tip of a line) visibly staggers if JS moves it. This is worst on phones, where + scrolling runs on another thread and scroll events arrive less often. - Layout is read in `measure()` only, on resize and on body `ResizeObserver`. - The line scrolls with the page natively (it spans the full timeline height), so moving it costs nothing. Only its lit half is written per frame. @@ -57,7 +64,11 @@ split into spans for the per-word resolve, **without** a duplicate `sr-only` cop Aug 2026 - + +
+
+
@@ -123,9 +134,11 @@ Card image `src` is set by the engine only for the active story and its neighbou .tl-line path { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } .tl-line__base path { stroke: rgba(255,255,255,.12); stroke-width: 1; stroke-dasharray: 2 7; } .tl-line path.tl-line__glow { stroke-width: 12; opacity: .16; } /* glow without a filter */ -.tl-line__clip, .tl-line__clip-inner { position: absolute; inset: 0; will-change: transform; } +.tl-line__clip, .tl-line__clip-inner { position: absolute; inset: 0; will-change: transform, translate; } .tl-line__clip { overflow: hidden; } -.tl-line__comet { position: absolute; top: 0; left: 0; width: 14px; height: 14px; margin: -7px 0 0 -7px; +.tl-line__track { position: absolute; left: 0; right: 0; top: var(--tl-y0, 0px); height: var(--tl-span, 0px); } +.tl-line__pin { position: sticky; top: 50vh; top: 50dvh; height: 0; } +.tl-line__comet { position: absolute; top: 0; left: 50%; width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%; background: #fff; will-change: transform; box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 28%, transparent), 0 0 28px 8px color-mix(in srgb, currentColor 55%, transparent); } @@ -249,6 +262,8 @@ export function createTimeline(root: HTMLElement, stories: { accent: string; tit const caption = root.querySelector('.tl-caption__name')! const cards = [...root.querySelectorAll('[data-shot]')] const total = stories.length * BEATS + // Where scroll timelines exist, CSS drives the lit line and the swing (below) + const cssDriven = typeof CSS !== 'undefined' && CSS.supports('animation-timeline: view()') const lit = nodes.map(() => false) let vh = innerHeight, first = 0, pitch = vh, top = 0, lw = 0, lh = 0, swing = 0 @@ -286,6 +301,10 @@ export function createTimeline(root: HTMLElement, stories: { accent: string; tit for (const s of svgs) s.setAttribute('viewBox', `0 0 ${lw} ${lh}`) for (const p of paths) p.setAttribute('d', d) nodes.forEach((n, i) => { n.style.transform = `translate3d(0, ${(y0 + i * BEATS * pitch).toFixed(1)}px, 0)` }) + // The comet's track and swing, for the sticky pin and the CSS swing keyframes + root.style.setProperty('--tl-y0', `${y0.toFixed(1)}px`) + root.style.setProperty('--tl-span', `${(y1 - y0).toFixed(1)}px`) + root.style.setProperty('--tl-swing', `${swing.toFixed(1)}px`) } function handover(si: number) { @@ -306,12 +325,16 @@ export function createTimeline(root: HTMLElement, stories: { accent: string; tit const si = Math.min(stories.length - 1, Math.floor(Math.round(pos) / BEATS)) if (si !== active) handover(si) - // Lit line: wrapper up, inner back down, so the lit copy ends exactly at the tip. - // A dash offset drifts off the comet on a curve; a clip-path repaints every frame. + // Fallback only (no scroll timelines, e.g. Firefox). Lit line: wrapper up, inner + // back down, so the lit copy ends exactly at the tip. A dash offset drifts off the + // comet on a curve; a clip-path repaints every frame. The comet's height is the + // sticky pin in every browser; here JS only adds its sideways swing. const tip = first - top + (reduced ? total - 1 : pos) * pitch - clip.style.transform = `translate3d(0, ${(tip - lh).toFixed(1)}px, 0)` - clipInner.style.transform = `translate3d(0, ${(lh - tip).toFixed(1)}px, 0)` - comet.style.transform = `translate3d(${xAt(tip).toFixed(1)}px, ${tip.toFixed(1)}px, 0)` + if (!cssDriven) { + clip.style.transform = `translate3d(0, ${(tip - lh).toFixed(1)}px, 0)` + clipInner.style.transform = `translate3d(0, ${(lh - tip).toFixed(1)}px, 0)` + if (swing) comet.style.transform = `translate3d(${(xAt(tip) - lw / 2).toFixed(1)}px, 0, 0)` + } nodes.forEach((n, i) => { const on = reduced || pos >= i * BEATS - 0.02 if (on !== lit[i]) { lit[i] = on; n.classList.toggle('is-lit', on) } @@ -406,6 +429,60 @@ export function createTimeline(root: HTMLElement, stories: { accent: string; tit } ``` +## Comet and lit line on the scroll itself + +Add this CSS. It binds the lit line to the timeline's own scroll range and the +comet's swing to the track's, so both run on the compositor in lockstep with the +scroll. Derivation for the lit line: across the root's `cover` range, the viewport +centre in the line's own coordinates runs from `-50vh` to `H + 50vh`, where H is +the line's height (`100%` in `translate` terms), and the wrapper sits at +`centre - H`. + +```css +@supports (animation-timeline: view()) { + .tl { view-timeline-name: --tl; } + .tl-line__clip { animation: tl-lit linear both; animation-timeline: --tl; } + .tl-line__clip-inner { animation: tl-lit-inner linear both; animation-timeline: --tl; } + .tl-line__track { view-timeline-name: --tl-track; } + /* 0 when the track's top reaches the centre, 1 when its bottom does */ + .tl-line__comet { + animation: tl-swing linear both; + animation-timeline: --tl-track; + animation-range: cover 50vh cover calc(100% - 50vh); + animation-range: cover 50dvh cover calc(100% - 50dvh); + } +} +@keyframes tl-lit { from { translate: 0 calc(-100% - 50dvh); } to { translate: 0 50dvh; } } +@keyframes tl-lit-inner { from { translate: 0 calc(100% + 50dvh); } to { translate: 0 -50dvh; } } +@media (prefers-reduced-motion: reduce) { + .tl-line__clip, .tl-line__clip-inner, .tl-line__comet { animation: none; translate: none; } +} +``` + +The swing keyframes follow the path's own sine, one extreme per beat, sampled every +quarter period. Easing out into each extreme and in to each crossing is close +enough to a sine that the comet stays on the line. Generate them once from the beat +count and render them in a `
@@ -619,7 +665,14 @@ export function ProjectJourney() { {s.period.split(' - ')[0]} ))} - + {/* Track from the first beat to the last; the pin inside it is + sticky at the viewport centre, so the browser holds the comet on + the scroll itself rather than a handler chasing it */} +
+
+ +
+
diff --git a/src/ui/RequestTrace.tsx b/src/ui/RequestTrace.tsx index 86ef11f..28ed1a5 100644 --- a/src/ui/RequestTrace.tsx +++ b/src/ui/RequestTrace.tsx @@ -5,6 +5,7 @@ import { traceStages, traceOpening, TRACE_COUNT } from '../content/requestTrace' import { SECTION_IDS, sections } from '../data/sections' import { triggerSectorWave } from '../scene/nodeAnimator' import { scrollToBeat } from '../scroll/traceNav' +import { announceReveal } from '../lib/uiEvents' const prefersReduced = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches @@ -61,8 +62,10 @@ export function RequestTrace() { // An invisible link is a tab trap el.toggleAttribute('inert', alpha < 0.5) el.setAttribute('aria-hidden', alpha < 0.5 ? 'true' : 'false') - if (alpha > 0.5) el.classList.add('is-in') - else if (alpha < 0.05) el.classList.remove('is-in') + if (alpha > 0.5 && !el.classList.contains('is-in')) { + el.classList.add('is-in') + announceReveal('stage') + } else if (alpha < 0.05) el.classList.remove('is-in') }) // The ghost arrives from the edge it is placed against, so each stage's diff --git a/src/ui/textReveal.ts b/src/ui/textReveal.ts index 84e95d1..181ae91 100644 --- a/src/ui/textReveal.ts +++ b/src/ui/textReveal.ts @@ -15,6 +15,8 @@ */ /** Everything the one-shot reveal drives. `data-wipe` is the image variant. */ +import { announceReveal } from '../lib/uiEvents' + export const REVEAL_SELECTOR = '[data-reveal],[data-wipe]' /** @@ -103,6 +105,8 @@ export function observeReveal(root: ParentNode, selector = '[data-reveal]'): () for (const entry of entries) { if (!entry.isIntersecting) continue entry.target.classList.add('is-in') + // Section titles are a moment; everything else is a detail arriving + announceReveal(entry.target.matches('.spine-title__mask') ? 'title' : 'item') for (const wipe of entry.target.querySelectorAll(WIPE)) { wipe.classList.add('is-in') }