Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9679409
docs: plan 3 — the stage on the homepage (live-stage plan 3 of 3)
blove Sep 6, 2026
1ced36e
feat(examples/chat): stage ready message carries the hold and the rel…
blove Sep 6, 2026
a56a81b
chore(website): vendor the scroll-craft engine, pinned at 0b81622
blove Sep 6, 2026
d207fe1
test(examples/chat): anchor the ready-message reload assertion to the…
blove Sep 6, 2026
d2ce0a0
feat(website): stage beat map — shares, the hold, the threshold, cue …
blove Sep 6, 2026
f79d452
docs(website): vendored engine README names the real selectors the ac…
blove Sep 6, 2026
280838f
feat(website): stage rail copy single-sourced in positioning.ts
blove Sep 6, 2026
7d66adb
refactor(website): stage beat map hoists its windows table and degrad…
blove Sep 6, 2026
a9f5a9c
feat(website): stage stills — the section's server-rendered form
blove Sep 6, 2026
6dd5d78
refactor(website): stage rail copy keys off the beat map's StageBeat …
blove Sep 6, 2026
0ef7286
feat(website): stage publisher — scroll to recorded time, verify attr…
blove Sep 6, 2026
3f698f4
fix(website): even stage stills keep the wide track when they mirror
blove Sep 6, 2026
0463cc4
feat(website): the pinned stage act — engine mount, frame, rail cues,…
blove Sep 6, 2026
fabadb0
fix(website): stage publisher ignores a malformed ready message and s…
blove Sep 6, 2026
a685236
feat(website): the stage replaces the four capability blocks on the h…
blove Sep 6, 2026
f93f873
fix(website): stage act mounts the engine on a container, mounts once…
blove Sep 6, 2026
4a248bd
test(website): homepage stage e2e — upgrade, scroll-driven cues, the …
blove Sep 6, 2026
eb378f6
test(website): scroll-craft verification of the homepage stage in CI
blove Sep 6, 2026
ccbcae7
test(website): stage e2e settles on a frame, not a sleep, and samples…
blove Sep 6, 2026
95b5adc
ci(website): stage verification serve skips the Nx cache so the conte…
blove Sep 6, 2026
896c762
docs: plan 3 records the harness's desktop-only CI pass and the nx se…
blove Sep 6, 2026
f7997ea
fix(stage): the ready handshake survives an empty referrer, and the C…
blove Sep 6, 2026
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
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,34 @@ jobs:
GROWTH_FORM_POLICY: growth_v1
WEBSITE_E2E_MODE: production
run: npx nx e2e website --skip-nx-cache --grep "public copy boundary|canonical policy surface"
# Spec §8: scroll-craft's own harness walks the homepage's pinned stage
# act and fails on dead scroll outside the declared hold and on cues that
# never peak. The Nx build lands in dist/apps/website with a rewritten
# next.config, which only `nx serve --configuration=production` can
# start, and the serve's own build dependency is the one build here:
# it runs with --skip-nx-cache (the earlier e2e builds wrote no cache
# entry either) so it emits dist rather than restoring it. The content
# symlink, which mirrors playwright.config.ts's production mode, is
# added only after the server answers, so it lands on the dist the
# build has finished writing rather than on one it is about to replace.
- name: Stage scroll verification (scroll-craft harness)
env:
GROWTH_FORM_POLICY: growth_v1
SCROLLCRAFT_CHROME: /usr/bin/google-chrome
run: |
command -v ffmpeg > /dev/null || (sudo apt-get update && sudo apt-get install -y ffmpeg)
(npx nx serve website --configuration=production --port=4308 --skip-nx-cache > /tmp/next-start.log 2>&1 &)
for i in $(seq 1 60); do curl -sf http://127.0.0.1:4308/ > /dev/null && break; sleep 2; done
curl -sf http://127.0.0.1:4308/ > /dev/null || { cat /tmp/next-start.log; exit 1; }
ln -sfn ../../../apps/website/content dist/apps/website/content
node apps/website/e2e/scroll-craft/verify-home.mjs --url http://127.0.0.1:4308 --out dist/stage-shots
- name: Upload stage contact sheets
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: stage-shots
path: dist/stage-shots/**/sheet.png
if-no-files-found: ignore
# The production-smoke spec is testIgnore'd outside PRODUCTION_SMOKE mode,
# so a module-load error in it is invisible until the post-merge Production
# smoke job runs against main — too late to gate a PR. Collect it here:
Expand Down
149 changes: 149 additions & 0 deletions apps/website/e2e/home-stage.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { test, expect, type Page } from '@playwright/test';

/** Drives the pinned act: the section is 6 viewports tall; scroll to a fraction of its travel. */
async function scrollAct(page: Page, p: number) {
await page.evaluate((frac) => {
const el = document.querySelector('[data-stage-act]') as HTMLElement;
const top = el.getBoundingClientRect().top + window.scrollY;
window.scrollTo({
top: top + (el.offsetHeight - window.innerHeight) * frac,
behavior: 'instant',
});
}, p);
// The engine writes `--sc-p` and the cue opacities from `scrollY` on ONE
// requestAnimationFrame after the scroll event (the 0.18/frame lerp applies
// to <video> playheads only; this page has none). The scroll event lands in
// the frame after scrollTo, so two nested frames are past the engine's write.
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
);
}
const progress = (page: Page) =>
page.evaluate(() =>
parseFloat(
(
document.querySelector('[data-stage-act]') as HTMLElement
).style.getPropertyValue('--sc-p')
)
);

test.describe('homepage stage', () => {
test('stills render first and the act upgrades on desktop', async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/');
const act = page.locator('[data-stage-act]');
await expect(act).toHaveAttribute('data-sc-span', '6');
await expect(act.locator('iframe')).toHaveAttribute(
'src',
'https://demo.threadplane.ai/stage?t=0'
);
await expect(act.locator('.stage-pin')).toHaveCSS('position', 'sticky');
await expect(page.getByTestId('stage-still-beat')).toHaveCount(0);
// This runs well inside StageAct's 8 s READY_TIMEOUT_MS (after which the
// act is swapped for the stills and [data-stage-act] disappears).
// The engine's layout() sets the act's inline height to span × 100vh.
await expect(page.locator('html')).toHaveClass(/sc-ready/);
const heights = await page.evaluate(() => ({
act: (document.querySelector('[data-stage-act]') as HTMLElement)
.offsetHeight,
viewport: window.innerHeight,
}));
expect(Math.abs(heights.act - 6 * heights.viewport)).toBeLessThanOrEqual(4);
});

test('scroll drives the act: progress, cues, and the declared hold', async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/');
// With the rAF settle this takes ~1-2 s, well inside StageAct's 8 s
// READY_TIMEOUT_MS after which the act is swapped for the stills.
await expect(page.locator('html')).toHaveClass(/sc-ready/);
await scrollAct(page, 0.05);
expect(await progress(page)).toBeGreaterThan(0);
const stream = page
.getByTestId('stage-rail-beat')
.and(page.locator('[data-beat="stream"]'));
await expect(stream).toHaveCSS('opacity', '1');
// Inside the approve hold: approve spans 0.4167..0.8167 of the act and the
// hold is 35–70% of it (0.5567..0.6967). 0.68 also sits on the last hold
// line's plateau (its cue opens at 0.65, full from ~0.678).
await scrollAct(page, 0.68);
await expect(page.locator('[data-stage-act]')).toHaveAttribute(
'data-sc-verify-hold',
'true'
);
await expect(page.getByTestId('stage-rail-hold').last()).toHaveCSS(
'opacity',
/^(0\.[5-9]\d*|1)$/
);
await scrollAct(page, 0.8);
await expect(page.locator('[data-stage-act]')).not.toHaveAttribute(
'data-sc-verify-hold',
'true'
);
await scrollAct(page, 1);
await expect(
page
.getByTestId('stage-rail-beat')
.and(page.locator('[data-beat="render"]'))
).toHaveCSS('opacity', '1');
});

test('the frame answers and the verify state changes between positions', async ({
page,
}) => {
test.skip(
process.env['STAGE_LIVE_FRAME'] !== 'true',
'needs the deployed demo /stage (set STAGE_LIVE_FRAME=true after #1030 promotes)'
);
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/');
const act = page.locator('[data-stage-act]');
// After READY_TIMEOUT_MS (8 s) StageAct swaps the act for the stills and
// [data-stage-act] is gone, so a `ready` that never arrives fails here,
// on the state attribute, rather than later on a vanished locator.
await expect(act).toHaveAttribute('data-state', 'ready', {
timeout: 8_000,
});
// `data-state="ready"` only proves the iframe answered; the engine's
// layout (html.sc-ready) is what makes the act 6 viewports tall.
await expect(page.locator('html')).toHaveClass(/sc-ready/);
// Once ready arrived the fallback timer is cleared, so the waits below
// only cover the live frame's own latency.
await scrollAct(page, 0.1);
await expect(act).toHaveAttribute('data-sc-verify-state', /^stream:\d+$/, {
timeout: 10_000,
});
const a = await act.getAttribute('data-sc-verify-state');
// Inside the approve hold: approve spans 0.4167..0.8167 of the act and the
// hold is 35–70% of it (0.5567..0.6967). 0.68 also sits on the last hold
// line's plateau (its cue opens at 0.65, full from ~0.678).
await scrollAct(page, 0.68);
await expect(act).toHaveAttribute('data-sc-verify-state', /^pause:\d+$/, {
timeout: 10_000,
});
expect(await act.getAttribute('data-sc-verify-state')).not.toBe(a);
await scrollAct(page, 1);
await expect(act).toHaveAttribute('data-sc-verify-state', /^render:\d+$/, {
timeout: 10_000,
});
});

test('phones and reduced motion get the stills', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
await expect(page.getByTestId('stage-still-beat')).toHaveCount(4);
await expect(page.locator('[data-stage-act]')).toHaveCount(0);
await page.setViewportSize({ width: 1440, height: 900 });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/');
await expect(page.getByTestId('stage-still-beat')).toHaveCount(4);
await expect(page.locator('[data-stage-act]')).toHaveCount(0);
});
});
87 changes: 87 additions & 0 deletions apps/website/e2e/scroll-craft/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# scroll-craft verification of the homepage stage

The homepage carries one pinned scroll act (`[data-stage-act]`, spec §8 of
`docs/superpowers/specs/2026-09-05-homepage-live-stage-design.md`). This
directory runs scroll-craft's own harness against a production build of the
site and fails CI on what the harness reports as defects.

## What it verifies

`shoot.mjs` walks the page's scroll in eight positions per act, screenshots
each, and reports:

- **DEAD SCROLL** between two sample positions whose visible state signature
did not change. The stage publishes its state in `data-sc-verify-state` and
declares its authored hold with `data-sc-verify-hold="true"`, so the pinned
interrupt inside the Approve beat is exempt and everything else is not.
- **CUES THAT NEVER PEAK**: a `[data-sc-cue]` line that never reaches full
opacity anywhere on the page.
- **Contrast** of every cue line at its worst frame (fail below 3:1, thin
between 3:1 and 4.5:1). Contrast is reported, not gated.

`verify-home.mjs` runs the harness per mode and exits 1 on a non-zero harness
exit, on a `DEAD SCROLL between:` line, or on a `CUES THAT NEVER PEAK:` line.
The modes are desktop 1440×900, phone 390×844, and reduced motion; the default
(`--modes desktop`) runs the desktop pass only, see below.

The harness sees the declared hold only while the frame answers: it reads
`data-sc-verify-hold` from the elements that carry `data-sc-verify-state`, and
the publisher writes that state from the frame's `{ applied, phase }` reply.
Before the frame is ready the act shows the poster and the check runs on the
rail cues alone, which still move through the hold (the hold lines fade in),
so a poster-only run is a weaker check, not a false failure.

### The phone and reduced-motion passes

Below 1024px and under reduced motion the page renders the four stacked
stills — no pinned act, no engine. The vendored harness waits for the engine's
`html.sc-ready` signal before it samples, so on the stills page those passes
time out (`waiting for locator('html.sc-ready')`) rather than report. And a
page with no `[data-sc-act]` gives the harness one sample at 100%, so even a
page that raised the signal would produce a one-frame sheet and no cue or
contrast lines. The two modes stay defined for a page that changes either of
those facts; run them with `--modes desktop,phone,reduced`.

## Running it locally

Build and serve the production site on a free port, then verify:

```bash
(npx nx serve website --configuration=production --port=4308 --skip-nx-cache &)
until curl -sf http://127.0.0.1:4308/ > /dev/null; do sleep 2; done
ln -sfn ../../../apps/website/content dist/apps/website/content
node apps/website/e2e/scroll-craft/verify-home.mjs --url http://127.0.0.1:4308 --out dist/stage-shots
```

The serve's own build dependency emits `dist/apps/website`; there is no
separate build step. It skips the Nx cache on purpose so that build is a real
one rather than a cache restore, and the `content` symlink is added only once
the server answers, so it lands on the directory the build has finished
writing rather than on one the build is still about to replace. Kill the
backgrounded serve when you are done (`lsof -iTCP:4308 -sTCP:LISTEN -n` names
the process).

The Nx build lands in `dist/apps/website` with a rewritten `next.config`, so a
bare `next start` cannot serve it; `nx serve --configuration=production` can.
The `content` symlink mirrors `playwright.config.ts`'s production mode.

Each mode writes numbered frames, `report.json`, and a tiled `sheet.png` to
`<out>/<mode>/` (`dist/stage-shots/desktop/sheet.png` and so on). The contact
sheet needs `ffmpeg` on the PATH; without it the frames and the report are
still written and the harness prints `contact sheet skipped`.

The harness uses an installed Chrome (`SCROLLCRAFT_CHROME` overrides the
auto-detected path) and resolves `playwright-core` from the repository root's
`package.json`, so run it from the repository root.

In CI the `website-e2e` job runs the desktop pass against the production
build and uploads every `sheet.png` as the `stage-shots` artifact.

## Provenance

`shoot.mjs` is vendored unmodified from scroll-craft at commit `0b81622`
(`plugins/nateherk-design/skills/scroll-craft/scripts/shoot.mjs`), the same
pinned commit as the engine in `apps/website/src/vendor/scrollcraft/`. It is
MIT licensed; the licence is `apps/website/src/vendor/scrollcraft/LICENSE`. It
is excluded from lint (root `eslint.config.mjs`, next to the engine) and must
not be reformatted, so that a byte comparison against upstream stays possible.
Loading
Loading