Skip to content

Commit fee3545

Browse files
bloveclaude
andauthored
feat(website): the stage on the homepage — one real run, scrubbed by scroll (live-stage plan 3 of 3) (#1032)
* docs: plan 3 — the stage on the homepage (live-stage plan 3 of 3) Eleven tasks: extend the frame's ready message with the hold and reload boundary; vendor the scroll-craft engine hash-pinned at 0b81622; the beat map (shares, the 35–70% hold, the threshold, the render tail, cue windows); rail copy in positioning.ts; the stills fallback; a DOM-only publisher; the pinned act with engine mount and analytics; the page swap; e2e; the scroll-craft harness in CI; verification and PR. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(examples/chat): stage ready message carries the hold and the reload boundary Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(website): vendor the scroll-craft engine, pinned at 0b81622 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(examples/chat): anchor the ready-message reload assertion to the fixture's reload run Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): stage beat map — shares, the hold, the threshold, cue windows Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(website): vendored engine README names the real selectors the act re-authors Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): stage rail copy single-sourced in positioning.ts Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(website): stage beat map hoists its windows table and degrades forward on a missing beat Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): stage stills — the section's server-rendered form Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(website): stage rail copy keys off the beat map's StageBeat and is deeply readonly The rail's beat union is now the beat map's own type, so renaming a beat in stage-beats.ts breaks the rail at compile time instead of drifting. Tests assert the eyebrow matches the beat, every string is present, and the three hold lines in full. The verbatim-vs-page.tsx cross-check was deliberately not written: Task 8 deletes the page's FeatureBlocks and positioning.ts becomes the only source. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): stage publisher — scroll to recorded time, verify attributes, milestones Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(website): even stage stills keep the wide track when they mirror Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): the pinned stage act — engine mount, frame, rail cues, analytics Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(website): stage publisher ignores a malformed ready message and survives a throwing tick Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): the stage replaces the four capability blocks on the homepage Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(website): stage act mounts the engine on a container, mounts once, and is keyboard-skippable Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(website): homepage stage e2e — upgrade, scroll-driven cues, the hold, the stills Four tests for the pinned act, the first time the vendored engine runs end to end in a real browser: the desktop upgrade (span 6, the /stage iframe, sticky .stage-pin, inline height = 6 viewports), scroll-driven --sc-p with the stream and render cues and the declared verify hold, the live-frame verify-state test (skipped unless STAGE_LIVE_FRAME=true until the demo /stage redeploys with hold/reloadEndMs), and the stills on phones and under reduced motion. The plan's hold position was 0.55 with a miscomputed window: with the authored shares (approve 2.4 of 6) the hold is 0.5567..0.6967, so 0.55 sits just outside it. The test scrolls to 0.68, inside the hold and on the last hold line's plateau. No component change was needed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(website): scroll-craft verification of the homepage stage in CI Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(website): stage e2e settles on a frame, not a sleep, and samples the hold where it is Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci(website): stage verification serve skips the Nx cache so the content symlink survives Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: plan 3 records the harness's desktop-only CI pass and the nx serve substitution Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(stage): the ready handshake survives an empty referrer, and the CI harness step builds once Ready handshake: with a strict Referrer-Policy the frame had nowhere to post `ready` and the publisher posted nothing before `ready`, so neither side ever spoke. The bridge now remembers the last `ready` and re-posts it the moment an allowlisted message teaches it the parent origin; the stage component subscribes to seeks before posting `ready` so that listener exists. The publisher says hello (`{ type, t: 0 }`) at most once per 500 ms until `ready` arrives. Specs cover both sides. CI harness step: the stage scroll verification built the website twice (a standalone build, then the serve's own build dependency) and the second run dropped the content symlink. The standalone build is gone; the symlink is added after the readiness curl succeeds. The scroll-craft README's local commands mirror the same ordering. Minor: StageAct drops the unused `stage-frame-chrome` and `stage-rail-heading` class names and gains `stage-rail-beat` / `stage-rail-hold` test ids, which home-stage.spec.ts now locates by. `StageMilestone` lives in stage-beats.ts (re-exported from the publisher) and analytics/events.ts types `stage_event` and `beat` from it instead of duplicating the unions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 98b202a commit fee3545

36 files changed

Lines changed: 5666 additions & 190 deletions

.github/workflows/ci.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,34 @@ jobs:
672672
GROWTH_FORM_POLICY: growth_v1
673673
WEBSITE_E2E_MODE: production
674674
run: npx nx e2e website --skip-nx-cache --grep "public copy boundary|canonical policy surface"
675+
# Spec §8: scroll-craft's own harness walks the homepage's pinned stage
676+
# act and fails on dead scroll outside the declared hold and on cues that
677+
# never peak. The Nx build lands in dist/apps/website with a rewritten
678+
# next.config, which only `nx serve --configuration=production` can
679+
# start, and the serve's own build dependency is the one build here:
680+
# it runs with --skip-nx-cache (the earlier e2e builds wrote no cache
681+
# entry either) so it emits dist rather than restoring it. The content
682+
# symlink, which mirrors playwright.config.ts's production mode, is
683+
# added only after the server answers, so it lands on the dist the
684+
# build has finished writing rather than on one it is about to replace.
685+
- name: Stage scroll verification (scroll-craft harness)
686+
env:
687+
GROWTH_FORM_POLICY: growth_v1
688+
SCROLLCRAFT_CHROME: /usr/bin/google-chrome
689+
run: |
690+
command -v ffmpeg > /dev/null || (sudo apt-get update && sudo apt-get install -y ffmpeg)
691+
(npx nx serve website --configuration=production --port=4308 --skip-nx-cache > /tmp/next-start.log 2>&1 &)
692+
for i in $(seq 1 60); do curl -sf http://127.0.0.1:4308/ > /dev/null && break; sleep 2; done
693+
curl -sf http://127.0.0.1:4308/ > /dev/null || { cat /tmp/next-start.log; exit 1; }
694+
ln -sfn ../../../apps/website/content dist/apps/website/content
695+
node apps/website/e2e/scroll-craft/verify-home.mjs --url http://127.0.0.1:4308 --out dist/stage-shots
696+
- name: Upload stage contact sheets
697+
if: always()
698+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
699+
with:
700+
name: stage-shots
701+
path: dist/stage-shots/**/sheet.png
702+
if-no-files-found: ignore
675703
# The production-smoke spec is testIgnore'd outside PRODUCTION_SMOKE mode,
676704
# so a module-load error in it is invisible until the post-merge Production
677705
# smoke job runs against main — too late to gate a PR. Collect it here:
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
3+
/** Drives the pinned act: the section is 6 viewports tall; scroll to a fraction of its travel. */
4+
async function scrollAct(page: Page, p: number) {
5+
await page.evaluate((frac) => {
6+
const el = document.querySelector('[data-stage-act]') as HTMLElement;
7+
const top = el.getBoundingClientRect().top + window.scrollY;
8+
window.scrollTo({
9+
top: top + (el.offsetHeight - window.innerHeight) * frac,
10+
behavior: 'instant',
11+
});
12+
}, p);
13+
// The engine writes `--sc-p` and the cue opacities from `scrollY` on ONE
14+
// requestAnimationFrame after the scroll event (the 0.18/frame lerp applies
15+
// to <video> playheads only; this page has none). The scroll event lands in
16+
// the frame after scrollTo, so two nested frames are past the engine's write.
17+
await page.evaluate(
18+
() =>
19+
new Promise<void>((resolve) =>
20+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
21+
)
22+
);
23+
}
24+
const progress = (page: Page) =>
25+
page.evaluate(() =>
26+
parseFloat(
27+
(
28+
document.querySelector('[data-stage-act]') as HTMLElement
29+
).style.getPropertyValue('--sc-p')
30+
)
31+
);
32+
33+
test.describe('homepage stage', () => {
34+
test('stills render first and the act upgrades on desktop', async ({
35+
page,
36+
}) => {
37+
await page.setViewportSize({ width: 1440, height: 900 });
38+
await page.goto('/');
39+
const act = page.locator('[data-stage-act]');
40+
await expect(act).toHaveAttribute('data-sc-span', '6');
41+
await expect(act.locator('iframe')).toHaveAttribute(
42+
'src',
43+
'https://demo.threadplane.ai/stage?t=0'
44+
);
45+
await expect(act.locator('.stage-pin')).toHaveCSS('position', 'sticky');
46+
await expect(page.getByTestId('stage-still-beat')).toHaveCount(0);
47+
// This runs well inside StageAct's 8 s READY_TIMEOUT_MS (after which the
48+
// act is swapped for the stills and [data-stage-act] disappears).
49+
// The engine's layout() sets the act's inline height to span × 100vh.
50+
await expect(page.locator('html')).toHaveClass(/sc-ready/);
51+
const heights = await page.evaluate(() => ({
52+
act: (document.querySelector('[data-stage-act]') as HTMLElement)
53+
.offsetHeight,
54+
viewport: window.innerHeight,
55+
}));
56+
expect(Math.abs(heights.act - 6 * heights.viewport)).toBeLessThanOrEqual(4);
57+
});
58+
59+
test('scroll drives the act: progress, cues, and the declared hold', async ({
60+
page,
61+
}) => {
62+
await page.setViewportSize({ width: 1440, height: 900 });
63+
await page.goto('/');
64+
// With the rAF settle this takes ~1-2 s, well inside StageAct's 8 s
65+
// READY_TIMEOUT_MS after which the act is swapped for the stills.
66+
await expect(page.locator('html')).toHaveClass(/sc-ready/);
67+
await scrollAct(page, 0.05);
68+
expect(await progress(page)).toBeGreaterThan(0);
69+
const stream = page
70+
.getByTestId('stage-rail-beat')
71+
.and(page.locator('[data-beat="stream"]'));
72+
await expect(stream).toHaveCSS('opacity', '1');
73+
// Inside the approve hold: approve spans 0.4167..0.8167 of the act and the
74+
// hold is 35–70% of it (0.5567..0.6967). 0.68 also sits on the last hold
75+
// line's plateau (its cue opens at 0.65, full from ~0.678).
76+
await scrollAct(page, 0.68);
77+
await expect(page.locator('[data-stage-act]')).toHaveAttribute(
78+
'data-sc-verify-hold',
79+
'true'
80+
);
81+
await expect(page.getByTestId('stage-rail-hold').last()).toHaveCSS(
82+
'opacity',
83+
/^(0\.[5-9]\d*|1)$/
84+
);
85+
await scrollAct(page, 0.8);
86+
await expect(page.locator('[data-stage-act]')).not.toHaveAttribute(
87+
'data-sc-verify-hold',
88+
'true'
89+
);
90+
await scrollAct(page, 1);
91+
await expect(
92+
page
93+
.getByTestId('stage-rail-beat')
94+
.and(page.locator('[data-beat="render"]'))
95+
).toHaveCSS('opacity', '1');
96+
});
97+
98+
test('the frame answers and the verify state changes between positions', async ({
99+
page,
100+
}) => {
101+
test.skip(
102+
process.env['STAGE_LIVE_FRAME'] !== 'true',
103+
'needs the deployed demo /stage (set STAGE_LIVE_FRAME=true after #1030 promotes)'
104+
);
105+
await page.setViewportSize({ width: 1440, height: 900 });
106+
await page.goto('/');
107+
const act = page.locator('[data-stage-act]');
108+
// After READY_TIMEOUT_MS (8 s) StageAct swaps the act for the stills and
109+
// [data-stage-act] is gone, so a `ready` that never arrives fails here,
110+
// on the state attribute, rather than later on a vanished locator.
111+
await expect(act).toHaveAttribute('data-state', 'ready', {
112+
timeout: 8_000,
113+
});
114+
// `data-state="ready"` only proves the iframe answered; the engine's
115+
// layout (html.sc-ready) is what makes the act 6 viewports tall.
116+
await expect(page.locator('html')).toHaveClass(/sc-ready/);
117+
// Once ready arrived the fallback timer is cleared, so the waits below
118+
// only cover the live frame's own latency.
119+
await scrollAct(page, 0.1);
120+
await expect(act).toHaveAttribute('data-sc-verify-state', /^stream:\d+$/, {
121+
timeout: 10_000,
122+
});
123+
const a = await act.getAttribute('data-sc-verify-state');
124+
// Inside the approve hold: approve spans 0.4167..0.8167 of the act and the
125+
// hold is 35–70% of it (0.5567..0.6967). 0.68 also sits on the last hold
126+
// line's plateau (its cue opens at 0.65, full from ~0.678).
127+
await scrollAct(page, 0.68);
128+
await expect(act).toHaveAttribute('data-sc-verify-state', /^pause:\d+$/, {
129+
timeout: 10_000,
130+
});
131+
expect(await act.getAttribute('data-sc-verify-state')).not.toBe(a);
132+
await scrollAct(page, 1);
133+
await expect(act).toHaveAttribute('data-sc-verify-state', /^render:\d+$/, {
134+
timeout: 10_000,
135+
});
136+
});
137+
138+
test('phones and reduced motion get the stills', async ({ page }) => {
139+
await page.setViewportSize({ width: 390, height: 844 });
140+
await page.goto('/');
141+
await expect(page.getByTestId('stage-still-beat')).toHaveCount(4);
142+
await expect(page.locator('[data-stage-act]')).toHaveCount(0);
143+
await page.setViewportSize({ width: 1440, height: 900 });
144+
await page.emulateMedia({ reducedMotion: 'reduce' });
145+
await page.goto('/');
146+
await expect(page.getByTestId('stage-still-beat')).toHaveCount(4);
147+
await expect(page.locator('[data-stage-act]')).toHaveCount(0);
148+
});
149+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# scroll-craft verification of the homepage stage
2+
3+
The homepage carries one pinned scroll act (`[data-stage-act]`, spec §8 of
4+
`docs/superpowers/specs/2026-09-05-homepage-live-stage-design.md`). This
5+
directory runs scroll-craft's own harness against a production build of the
6+
site and fails CI on what the harness reports as defects.
7+
8+
## What it verifies
9+
10+
`shoot.mjs` walks the page's scroll in eight positions per act, screenshots
11+
each, and reports:
12+
13+
- **DEAD SCROLL** between two sample positions whose visible state signature
14+
did not change. The stage publishes its state in `data-sc-verify-state` and
15+
declares its authored hold with `data-sc-verify-hold="true"`, so the pinned
16+
interrupt inside the Approve beat is exempt and everything else is not.
17+
- **CUES THAT NEVER PEAK**: a `[data-sc-cue]` line that never reaches full
18+
opacity anywhere on the page.
19+
- **Contrast** of every cue line at its worst frame (fail below 3:1, thin
20+
between 3:1 and 4.5:1). Contrast is reported, not gated.
21+
22+
`verify-home.mjs` runs the harness per mode and exits 1 on a non-zero harness
23+
exit, on a `DEAD SCROLL between:` line, or on a `CUES THAT NEVER PEAK:` line.
24+
The modes are desktop 1440×900, phone 390×844, and reduced motion; the default
25+
(`--modes desktop`) runs the desktop pass only, see below.
26+
27+
The harness sees the declared hold only while the frame answers: it reads
28+
`data-sc-verify-hold` from the elements that carry `data-sc-verify-state`, and
29+
the publisher writes that state from the frame's `{ applied, phase }` reply.
30+
Before the frame is ready the act shows the poster and the check runs on the
31+
rail cues alone, which still move through the hold (the hold lines fade in),
32+
so a poster-only run is a weaker check, not a false failure.
33+
34+
### The phone and reduced-motion passes
35+
36+
Below 1024px and under reduced motion the page renders the four stacked
37+
stills — no pinned act, no engine. The vendored harness waits for the engine's
38+
`html.sc-ready` signal before it samples, so on the stills page those passes
39+
time out (`waiting for locator('html.sc-ready')`) rather than report. And a
40+
page with no `[data-sc-act]` gives the harness one sample at 100%, so even a
41+
page that raised the signal would produce a one-frame sheet and no cue or
42+
contrast lines. The two modes stay defined for a page that changes either of
43+
those facts; run them with `--modes desktop,phone,reduced`.
44+
45+
## Running it locally
46+
47+
Build and serve the production site on a free port, then verify:
48+
49+
```bash
50+
(npx nx serve website --configuration=production --port=4308 --skip-nx-cache &)
51+
until curl -sf http://127.0.0.1:4308/ > /dev/null; do sleep 2; done
52+
ln -sfn ../../../apps/website/content dist/apps/website/content
53+
node apps/website/e2e/scroll-craft/verify-home.mjs --url http://127.0.0.1:4308 --out dist/stage-shots
54+
```
55+
56+
The serve's own build dependency emits `dist/apps/website`; there is no
57+
separate build step. It skips the Nx cache on purpose so that build is a real
58+
one rather than a cache restore, and the `content` symlink is added only once
59+
the server answers, so it lands on the directory the build has finished
60+
writing rather than on one the build is still about to replace. Kill the
61+
backgrounded serve when you are done (`lsof -iTCP:4308 -sTCP:LISTEN -n` names
62+
the process).
63+
64+
The Nx build lands in `dist/apps/website` with a rewritten `next.config`, so a
65+
bare `next start` cannot serve it; `nx serve --configuration=production` can.
66+
The `content` symlink mirrors `playwright.config.ts`'s production mode.
67+
68+
Each mode writes numbered frames, `report.json`, and a tiled `sheet.png` to
69+
`<out>/<mode>/` (`dist/stage-shots/desktop/sheet.png` and so on). The contact
70+
sheet needs `ffmpeg` on the PATH; without it the frames and the report are
71+
still written and the harness prints `contact sheet skipped`.
72+
73+
The harness uses an installed Chrome (`SCROLLCRAFT_CHROME` overrides the
74+
auto-detected path) and resolves `playwright-core` from the repository root's
75+
`package.json`, so run it from the repository root.
76+
77+
In CI the `website-e2e` job runs the desktop pass against the production
78+
build and uploads every `sheet.png` as the `stage-shots` artifact.
79+
80+
## Provenance
81+
82+
`shoot.mjs` is vendored unmodified from scroll-craft at commit `0b81622`
83+
(`plugins/nateherk-design/skills/scroll-craft/scripts/shoot.mjs`), the same
84+
pinned commit as the engine in `apps/website/src/vendor/scrollcraft/`. It is
85+
MIT licensed; the licence is `apps/website/src/vendor/scrollcraft/LICENSE`. It
86+
is excluded from lint (root `eslint.config.mjs`, next to the engine) and must
87+
not be reformatted, so that a byte comparison against upstream stays possible.

0 commit comments

Comments
 (0)