- THE ADAPTIVE MIND v1.0 {profile.tagline}
+ THE ADAPTIVE MIND v3.1 {profile.tagline}
{/*
diff --git a/src/ui/ProjectJourney.tsx b/src/ui/ProjectJourney.tsx
index f5de222..be626d5 100644
--- a/src/ui/ProjectJourney.tsx
+++ b/src/ui/ProjectJourney.tsx
@@ -1,6 +1,7 @@
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import { projectStories } from '../content/projects'
import { toWords } from './textReveal'
+import { announceReveal } from '../lib/uiEvents'
const prefersReduced = () =>
typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -239,6 +240,7 @@ export function ProjectJourney() {
if (storyIndex !== activeRef.current) {
activeRef.current = storyIndex
setActive(storyIndex)
+ announceReveal('stage')
}
/*
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')
}
From c19ff43e674c1507749558dc1834ce06d4d150a4 Mon Sep 17 00:00:00 2001
From: DavidK4leido5
Date: Thu, 24 Sep 2026 08:20:12 +0800
Subject: [PATCH 4/4] Update cinematic skill: cursor, interactive particles,
sound design, scroll sync, scenario map
---
.../cinematic-scroll-portfolio/SKILL.md | 235 ++++++++++-------
.../references/01-foundations.md | 24 +-
.../references/04-scroll-timeline.md | 101 +++++++-
.../references/05-seo-prerender.md | 25 ++
.../references/06-verify-and-perf.md | 42 +++
.../references/07-cursor-and-chrome.md | 242 ++++++++++++++++++
.../references/08-interactive-particles.md | 216 ++++++++++++++++
.../references/09-sound-design.md | 220 ++++++++++++++++
8 files changed, 994 insertions(+), 111 deletions(-)
create mode 100644 .claude/skills/cinematic-scroll-portfolio/references/07-cursor-and-chrome.md
create mode 100644 .claude/skills/cinematic-scroll-portfolio/references/08-interactive-particles.md
create mode 100644 .claude/skills/cinematic-scroll-portfolio/references/09-sound-design.md
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 `