Skip to content
Merged

Test #21

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 139 additions & 96 deletions .claude/skills/cinematic-scroll-portfolio/SKILL.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}

Expand Down Expand 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -57,7 +64,11 @@ split into spans for the per-word resolve, **without** a duplicate `sr-only` cop
</div></div>
<!-- one per story -->
<span class="tl-line__node" data-node="0" style="color:#e8b86b"><span class="tl-line__label">Aug 2026</span></span>
<span class="tl-line__comet"></span>
<!-- Track from the first beat to the last; the pin is sticky at the viewport
centre, so the browser holds the comet there, not a handler -->
<div class="tl-line__track">
<div class="tl-line__pin"><span class="tl-line__comet"></span></div>
</div>
</div>

<div class="tl-grid">
Expand Down Expand Up @@ -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); }
Expand Down Expand Up @@ -249,6 +262,8 @@ export function createTimeline(root: HTMLElement, stories: { accent: string; tit
const caption = root.querySelector<HTMLElement>('.tl-caption__name')!
const cards = [...root.querySelectorAll<HTMLElement>('[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
Expand Down Expand Up @@ -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) {
Expand All @@ -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) }
Expand Down Expand Up @@ -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 `<style>` tag next to the timeline:

```ts
function swingKeyframes(beats: number): string {
const quarters = Math.max(1, (beats - 1) * 2)
const out: string[] = []
for (let i = 0; i <= quarters; i++) {
const phase = i % 4
const x = phase === 1 ? 1 : phase === 3 ? -1 : 0
const ease = x === 0 ? 'cubic-bezier(0.61, 1, 0.88, 1)' : 'cubic-bezier(0.12, 0, 0.39, 0)'
out.push(`${((i / quarters) * 100).toFixed(4)}% { translate: calc(var(--tl-swing, 0px) * ${x}) 0; animation-timing-function: ${ease}; }`)
}
return `@keyframes tl-swing { ${out.join(' ')} }`
}
```

Check it: at eight scroll positions the comet's centre must sit within 2px of the
viewport centre, the lit edge within 1px of the comet, and the swing must match the
JS `xAt` values. Test the fallback by stubbing `CSS.supports` to return false for
`animation-timeline` and adding `animation: none` to the three elements.

If you use React: render the markup from data, memoise the copy column (hundreds of
word spans), call `createTimeline` in one `useEffect` with no dependency on the active
index, and let the engine own every class and style it writes. Never re-render the
Expand All @@ -422,3 +499,5 @@ copy on a handover.
| Words never resolve after load | Offsets cached before titles fitted / images loaded | Body `ResizeObserver` re-measure |
| Sticky stage does not stick | An ancestor has `overflow: hidden` | Use `overflow: clip` |
| Janky on phones | `filter: blur()` per frame on big bitmaps | Disable blur below desktop |
| Comet staggers or jitters while scrolling, worst on phones | JS moves it on the next frame while the page scrolls natively | Sticky pin for its height, scroll timelines for the lit line and swing, JS only as a fallback |
| Far nav jumps take seconds and replay every beat | Smooth-scrolling the whole 20-viewport journey | `glideTo`: jump to one viewport short, then smooth-scroll (03-section-patterns.md) |
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,28 @@ const ld = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/
check(ld.some((d) => d['@type'] === 'ItemList'), 'ItemList JSON-LD present')
if (fails.length) process.exit(1)
```

## Lessons from shipping it

- **Prerender the hero's own copy, not just a name.** If the hero is a WebGL scene
with scroll-driven copy (story stages), render those stages into the static hero as
real `h2`s with their ledes. Put the keyword first ("Interface layer: what the
person touches") and show it visibly as a small label above the line in the live
page, so the static and live pages say the same thing.
- **One text run per heading.** React puts `<!-- -->` between adjacent JSX text
nodes: `<h2>{title} layer: {headline}</h2>` renders as three runs. Harmless to
crawlers but it breaks exact-match checks. Use one template string:
`<h2>{`${title} layer: ${headline}`}</h2>`.
- **A missing optional image must resolve to nothing.** An image helper that falls
back to the raw source path when the file is not in the bundle ships a request that
404s in production and a broken image in the prerendered page. Return `''` and let
the component show its placeholder (initials, a solid block).
- **Every `<img>` gets `width`, `height` and real `alt`**, including avatars and
marquee copies. The audit check is "zero images without dimensions in the
prerendered HTML".
- **The h1 names the person or product.** If the brand mark holds the h1, move the
h1 to the name line and keep the brand text as a plain span. Only one h1 exists at a
time: the prerendered hero's h1 is replaced when the app mounts.
- **Gate buttons and loaders are client-only.** The prerendered page must read
correctly with JS off: the hero heading, every section heading, every chapter's
copy. Hidden-until-revealed states hang off `html.reveal-armed`, which only JS adds.
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,45 @@ must be equal or better.
unmount it after ~2s covered. Never run a canvas behind opaque sections.
- Marquees are CSS keyframes on one track element.
- Scroll handlers are passive, rAF-throttled, and idle when their element is off screen.

## Headless browser caveats (read before trusting a screenshot)

- **Screenshots lag the page.** Headless Chromium rendering a WebGL scene can manage
only a few frames a second, so a screenshot often shows the frame before the state
you just caused. Verify state by reading the DOM (`dataset`, `textContent`,
computed styles) and use screenshots only for the look.
- **To capture a transient look, pin it.** Hold the state from inside the page (for
example rewrite a uniform every animation frame, or keep toggling an attribute),
wait a few seconds, then screenshot.
- **Timing is stretched.** An intro that takes 5.4s can take 10s headless. Anything
keyed to wall-clock guesses looks wrong there and may be wrong on slow phones too.
Key big moments to the actual visual state instead.
- **Canvas pixel analysis: keep the aspect ratio.** When decoding a screenshot into a
canvas to count pixels, draw it at its native size. Squashing it into a fixed
rectangle, or reading a cropped preview, hides strips at the edges.
- **Start gates stop tests.** Skip them under `navigator.webdriver`. To test the gate
itself, override it in an init script:
`Object.defineProperty(Navigator.prototype, 'webdriver', { get: () => false })`.
- **A/B against the previous commit** for performance: `git worktree add --detach
<dir> HEAD`, install and build there, serve both builds with `vite preview` on two
ports, and run the same frame-time probe on each. On Windows, deleting that worktree
can fail with "Filename too long" inside `node_modules`; remove it with PowerShell
`Remove-Item -LiteralPath '\?\C:\...' -Recurse -Force`, then `git worktree prune`.
- **Respect the person's dev server.** If they stop it, test against the production
preview instead of restarting theirs.

## Timeline sync check

```js
for (const b of [0, 0.5, 1.3, 4, 8.7, 17]) {
await scrollToBeat(b)
const r = await page.evaluate(() => {
const c = document.querySelector('.tl-line__comet').getBoundingClientRect()
const lit = document.querySelector('.tl-line__clip').getBoundingClientRect()
const cy = c.top + c.height / 2
return { centre: cy - innerHeight / 2, gap: lit.bottom - cy }
})
// centre within 2px, gap within 1px, in both the scroll-timeline path and the
// forced JS fallback (stub CSS.supports and add animation:none)
}
```
Loading
Loading