Skip to content

Commit f7997ea

Browse files
bloveclaude
andcommitted
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>
1 parent 896c762 commit f7997ea

11 files changed

Lines changed: 144 additions & 37 deletions

File tree

.github/workflows/ci.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -676,22 +676,22 @@ jobs:
676676
# act and fails on dead scroll outside the declared hold and on cues that
677677
# never peak. The Nx build lands in dist/apps/website with a rewritten
678678
# next.config, which only `nx serve --configuration=production` can
679-
# start; the content symlink mirrors playwright.config.ts's production
680-
# mode. The earlier e2e builds run with --skip-nx-cache and write no
681-
# cache entry, so this build is a full one; the serve skips the cache
682-
# too, otherwise its own build target restores dist/apps/website from
683-
# the entry this step just wrote and drops the symlink.
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.
684685
- name: Stage scroll verification (scroll-craft harness)
685686
env:
686687
GROWTH_FORM_POLICY: growth_v1
687688
SCROLLCRAFT_CHROME: /usr/bin/google-chrome
688689
run: |
689690
command -v ffmpeg > /dev/null || (sudo apt-get update && sudo apt-get install -y ffmpeg)
690-
npx nx build website
691-
ln -sfn ../../../apps/website/content dist/apps/website/content
692691
(npx nx serve website --configuration=production --port=4308 --skip-nx-cache > /tmp/next-start.log 2>&1 &)
693692
for i in $(seq 1 60); do curl -sf http://127.0.0.1:4308/ > /dev/null && break; sleep 2; done
694693
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
695695
node apps/website/e2e/scroll-craft/verify-home.mjs --url http://127.0.0.1:4308 --out dist/stage-shots
696696
- name: Upload stage contact sheets
697697
if: always()

apps/website/e2e/home-stage.spec.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ test.describe('homepage stage', () => {
6666
await expect(page.locator('html')).toHaveClass(/sc-ready/);
6767
await scrollAct(page, 0.05);
6868
expect(await progress(page)).toBeGreaterThan(0);
69-
const stream = page.locator('.stage-rail-beat[data-beat="stream"]');
69+
const stream = page
70+
.getByTestId('stage-rail-beat')
71+
.and(page.locator('[data-beat="stream"]'));
7072
await expect(stream).toHaveCSS('opacity', '1');
7173
// Inside the approve hold: approve spans 0.4167..0.8167 of the act and the
7274
// hold is 35–70% of it (0.5567..0.6967). 0.68 also sits on the last hold
@@ -76,7 +78,7 @@ test.describe('homepage stage', () => {
7678
'data-sc-verify-hold',
7779
'true'
7880
);
79-
await expect(page.locator('.stage-rail-hold').last()).toHaveCSS(
81+
await expect(page.getByTestId('stage-rail-hold').last()).toHaveCSS(
8082
'opacity',
8183
/^(0\.[5-9]\d*|1)$/
8284
);
@@ -87,7 +89,9 @@ test.describe('homepage stage', () => {
8789
);
8890
await scrollAct(page, 1);
8991
await expect(
90-
page.locator('.stage-rail-beat[data-beat="render"]')
92+
page
93+
.getByTestId('stage-rail-beat')
94+
.and(page.locator('[data-beat="render"]'))
9195
).toHaveCSS('opacity', '1');
9296
});
9397

apps/website/e2e/scroll-craft/README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,19 @@ those facts; run them with `--modes desktop,phone,reduced`.
4747
Build and serve the production site on a free port, then verify:
4848

4949
```bash
50-
npx nx build website
51-
ln -sfn ../../../apps/website/content dist/apps/website/content
5250
(npx nx serve website --configuration=production --port=4308 --skip-nx-cache &)
5351
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
5453
node apps/website/e2e/scroll-craft/verify-home.mjs --url http://127.0.0.1:4308 --out dist/stage-shots
5554
```
5655

57-
The serve skips the Nx cache on purpose: its own build target would otherwise
58-
restore `dist/apps/website` from the entry the build just wrote and drop the
59-
`content` symlink. Kill the backgrounded serve when you are done (`lsof
60-
-iTCP:4308 -sTCP:LISTEN -n` names the process).
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).
6163

6264
The Nx build lands in `dist/apps/website` with a rewritten `next.config`, so a
6365
bare `next start` cannot serve it; `nx serve --configuration=production` can.

apps/website/src/components/landing/StageAct.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,7 @@ export function StageAct({ onFallback }: Props) {
127127
<div className="stage-pin" data-sc-stage>
128128
<Container className="stage-pin-inner">
129129
<div className="stage-frame">
130-
<BrowserFrame
131-
url="demo.threadplane.ai/stage"
132-
elevation="lg"
133-
className="stage-frame-chrome"
134-
>
130+
<BrowserFrame url="demo.threadplane.ai/stage" elevation="lg">
135131
<div className="stage-frame-stage">
136132
<img
137133
src={POSTER}
@@ -170,6 +166,7 @@ export function StageAct({ onFallback }: Props) {
170166
{STAGE_RAIL.map((b) => (
171167
<div
172168
className="stage-rail-beat"
169+
data-testid="stage-rail-beat"
173170
data-beat={b.beat}
174171
data-sc-cue={cueFor(b.beat)}
175172
key={b.beat}
@@ -183,9 +180,7 @@ export function StageAct({ onFallback }: Props) {
183180
aria-hidden="true"
184181
/>
185182
</div>
186-
<h3 className="feature-block-heading stage-rail-heading">
187-
{b.headline}
188-
</h3>
183+
<h3 className="feature-block-heading">{b.headline}</h3>
189184
<p className="feature-block-body">{b.body}</p>
190185
<div className="feature-block-rows">
191186
{b.rows.map((row) => (
@@ -209,6 +204,7 @@ export function StageAct({ onFallback }: Props) {
209204
{STAGE_HOLD_LINES.map((line, i) => (
210205
<p
211206
className="stage-rail-hold"
207+
data-testid="stage-rail-hold"
212208
data-sc-cue={holdCues[i]}
213209
key={line}
214210
>

apps/website/src/components/landing/use-stage-publisher.spec.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useRef } from 'react';
55
import {
66
createStagePublisher,
77
STAGE_DEMO_ORIGIN,
8+
STAGE_HELLO_INTERVAL_MS,
89
STAGE_MESSAGE_TYPE,
910
useStagePublisher,
1011
type StagePublisher,
@@ -85,10 +86,44 @@ describe('stage publisher', () => {
8586
});
8687
});
8788

88-
it('posts nothing before ready', () => {
89+
it('posts no seek before ready, only a throttled hello so the frame learns the origin', () => {
90+
let now = 1000;
91+
vi.spyOn(performance, 'now').mockImplementation(() => now);
8992
const { section, posted, pub } = setup({ ready: false });
9093
section.style.setProperty('--sc-p', '0.3');
9194
pub.tick();
95+
now += 100;
96+
pub.tick();
97+
// Two ticks inside the interval: exactly one hello.
98+
expect(posted).toEqual([
99+
{ origin: STAGE_DEMO_ORIGIN, m: { type: STAGE_MESSAGE_TYPE, t: 0 } },
100+
]);
101+
now += STAGE_HELLO_INTERVAL_MS;
102+
pub.tick();
103+
expect(posted).toHaveLength(2);
104+
expect(posted[1]).toEqual({
105+
origin: STAGE_DEMO_ORIGIN,
106+
m: { type: STAGE_MESSAGE_TYPE, t: 0 },
107+
});
108+
// Once ready arrives the hello stops and the real seek is posted.
109+
fromDemo(READY);
110+
now += STAGE_HELLO_INTERVAL_MS * 4;
111+
pub.tick();
112+
pub.tick();
113+
expect(posted).toHaveLength(3);
114+
expect(posted[2]).toEqual({
115+
origin: STAGE_DEMO_ORIGIN,
116+
m: { type: STAGE_MESSAGE_TYPE, t: timeAt(0.3, READY) },
117+
});
118+
});
119+
120+
it('says no hello while the frame window is not there yet', () => {
121+
const { section, posted, pub } = setup({
122+
ready: false,
123+
frameWindow: () => null,
124+
});
125+
section.style.setProperty('--sc-p', '0.3');
126+
pub.tick();
92127
expect(posted).toHaveLength(0);
93128
});
94129

@@ -200,12 +235,15 @@ describe('stage publisher', () => {
200235

201236
it('ignores a malformed ready: no hold, or a beat with a non-numeric time', () => {
202237
const { section, posted, onReady, pub } = setup({ ready: false });
238+
// A malformed ready leaves the publisher un-ready, so the only thing it
239+
// may post is the hello (t: 0), never a seek.
240+
const seeks = () => posted.filter((p) => (p.m as { t: number }).t !== 0);
203241
const { hold: _hold, ...noHold } = READY;
204242
void _hold;
205243
fromDemo(noHold);
206244
section.style.setProperty('--sc-p', '0.1');
207245
expect(() => pub.tick()).not.toThrow();
208-
expect(posted).toHaveLength(0);
246+
expect(seeks()).toHaveLength(0);
209247
expect(onReady).not.toHaveBeenCalled();
210248

211249
fromDemo({
@@ -218,13 +256,18 @@ describe('stage publisher', () => {
218256
section.style.setProperty('--sc-p', '0.11');
219257
expect(() => pub.tick()).not.toThrow();
220258
pub.tick();
221-
expect(posted).toHaveLength(0);
259+
expect(seeks()).toHaveLength(0);
222260
expect(onReady).not.toHaveBeenCalled();
223261

224262
// A well-formed ready afterwards still works.
225263
fromDemo(READY);
226264
pub.tick();
227-
expect(posted).toHaveLength(1);
265+
expect(seeks()).toEqual([
266+
{
267+
origin: STAGE_DEMO_ORIGIN,
268+
m: { type: STAGE_MESSAGE_TYPE, t: timeAt(0.11, READY) },
269+
},
270+
]);
228271
expect(onReady).toHaveBeenCalledTimes(1);
229272
});
230273

apps/website/src/components/landing/use-stage-publisher.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@ import {
55
inHold,
66
timeAt,
77
type StageBeat,
8+
type StageMilestone,
89
type StageReadyMessage,
910
} from '../../lib/stage-beats';
1011

12+
export type { StageMilestone } from '../../lib/stage-beats';
13+
1114
export const STAGE_DEMO_ORIGIN = 'https://demo.threadplane.ai';
1215
export const STAGE_DEMO_URL = `${STAGE_DEMO_ORIGIN}/stage?t=0`;
1316
export const STAGE_MESSAGE_TYPE = 'tplane-stage';
1417

15-
export type StageMilestone = 'enter' | 'beat' | 'threshold' | 'complete';
18+
/** How often the publisher says hello to a frame that has not answered yet. */
19+
export const STAGE_HELLO_INTERVAL_MS = 500;
1620

1721
export interface StagePublisherDeps {
1822
section: HTMLElement;
@@ -78,6 +82,7 @@ export function createStagePublisher(deps: StagePublisherDeps): StagePublisher {
7882
let thresholdSeen = false;
7983
const beatsSeen = new Set<StageBeat>();
8084
let disposed = false;
85+
let lastHello = -Infinity;
8186

8287
const onMessage = (e: MessageEvent) => {
8388
if (e.origin !== STAGE_DEMO_ORIGIN) return;
@@ -130,8 +135,21 @@ export function createStagePublisher(deps: StagePublisherDeps): StagePublisher {
130135
deps.track('complete');
131136
}
132137
lastP = p;
138+
// Hello. Under a strict Referrer-Policy the frame arrives with no
139+
// referrer and posts nothing (it has nowhere to answer) until it hears
140+
// from an allowlisted parent; the frame learns the origin from this
141+
// message and answers with `ready`. Throttled so a scroll that idles
142+
// before the frame boots does not flood it.
143+
if (!ready) {
144+
const now = performance.now();
145+
if (now - lastHello < STAGE_HELLO_INTERVAL_MS) return;
146+
const w = deps.frameWindow();
147+
if (!w) return;
148+
w.postMessage({ type: STAGE_MESSAGE_TYPE, t: 0 }, STAGE_DEMO_ORIGIN);
149+
lastHello = now;
150+
return;
151+
}
133152
// Seek.
134-
if (!ready) return;
135153
const t = timeAt(p, ready);
136154
if (t === lastT) return;
137155
const w = deps.frameWindow();

apps/website/src/lib/analytics/events.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { StageBeat, StageMilestone } from '../stage-beats';
2+
13
export const analyticsEvents = {
24
marketingCtaClick: 'marketing:cta_click',
35
marketingExternalLinkClick: 'marketing:external_link_click',
@@ -142,7 +144,7 @@ export type AnalyticsProperties = {
142144
ai_source?: string;
143145
user_agent?: string;
144146
/** Homepage stage milestones (`marketing:stage_progress`). */
145-
stage_event?: 'enter' | 'beat' | 'threshold' | 'complete';
146-
beat?: 'stream' | 'persist' | 'approve' | 'render';
147+
stage_event?: StageMilestone;
148+
beat?: StageBeat;
147149
[key: string]: string | number | boolean | undefined;
148150
};

apps/website/src/lib/stage-beats.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
export const STAGE_BEATS = ['stream', 'persist', 'approve', 'render'] as const;
99
export type StageBeat = (typeof STAGE_BEATS)[number];
1010

11+
/** The scroll milestones the publisher reports (`marketing:stage_progress`). */
12+
export type StageMilestone = 'enter' | 'beat' | 'threshold' | 'complete';
13+
1114
/** Viewport-heights of scroll each beat owns. Approve is the peak by a visible margin. */
1215
export const STAGE_SHARES: Readonly<Record<StageBeat, number>> = {
1316
stream: 1.3,

examples/chat/angular/src/app/stage/stage-bridge.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,33 @@ describe('createStageBridge', () => {
7272
});
7373
});
7474

75+
it('re-posts ready once an empty-referrer frame learns the parent origin from a seek', () => {
76+
const e = env('');
77+
const bridge = createStageBridge({ referrer: e.referrer, parent: e.parent, self: e.self });
78+
const seen: number[] = [];
79+
bridge.onSeek((t) => seen.push(t));
80+
const ready = {
81+
totalMs: 9000,
82+
beats: [{ beat: 'stream' as const, startMs: 0, endMs: 1000 }],
83+
hold: { startMs: 5000, endMs: 8000 },
84+
reloadEndMs: 1600,
85+
};
86+
bridge.postReady(ready);
87+
// Nothing yet: the frame has nowhere to answer.
88+
expect(e.parent.postMessage).not.toHaveBeenCalled();
89+
e.fire({ type: STAGE_MESSAGE_TYPE, t: 5 }, 'https://evil.example');
90+
expect(e.parent.postMessage).not.toHaveBeenCalled();
91+
e.fire({ type: STAGE_MESSAGE_TYPE, t: 0 });
92+
expect(seen).toEqual([0]);
93+
const calls = (e.parent.postMessage as ReturnType<typeof vi.fn>).mock.calls;
94+
expect(calls).toHaveLength(1);
95+
expect(calls[0][0]).toEqual({ type: STAGE_MESSAGE_TYPE, ready: true, ...ready });
96+
expect(calls[0][1]).toBe('https://threadplane.ai');
97+
// A second seek does not repeat the handshake.
98+
e.fire({ type: STAGE_MESSAGE_TYPE, t: 100 });
99+
expect(calls).toHaveLength(1);
100+
});
101+
75102
it('posts nothing when not embedded', () => {
76103
const e = env();
77104
const bridge = createStageBridge({ referrer: '', parent: e.self, self: e.self });

examples/chat/angular/src/app/stage/stage-bridge.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ export function createStageBridge(env: BridgeEnv): StageBridge {
4848
let parentOrigin: string | null =
4949
fromReferrer !== null && isAllowedParentOrigin(fromReferrer) ? fromReferrer : null;
5050
const embedded = env.parent !== env.self;
51+
// The last `ready` posted (or held back while the parent origin was still
52+
// unknown): re-posted the moment the origin is learned, so a parent that
53+
// arrived with an empty referrer still receives the handshake.
54+
let lastReady: Record<string, unknown> | null = null;
5155
const post = (msg: Record<string, unknown>) => {
5256
if (!embedded || parentOrigin === null) return;
5357
env.parent.postMessage({ type: STAGE_MESSAGE_TYPE, ...msg }, parentOrigin);
@@ -61,20 +65,24 @@ export function createStageBridge(env: BridgeEnv): StageBridge {
6165
if (!d || d.type !== STAGE_MESSAGE_TYPE || typeof d.t !== 'number' || !Number.isFinite(d.t)) return;
6266
// Under a strict Referrer-Policy the referrer is empty, so the first
6367
// allowlisted message is how the frame learns where to answer.
64-
if (parentOrigin === null) parentOrigin = e.origin;
68+
if (parentOrigin === null) {
69+
parentOrigin = e.origin;
70+
if (lastReady !== null) post(lastReady);
71+
}
6572
cb(d.t);
6673
};
6774
env.self.addEventListener('message', handler);
6875
return () => env.self.removeEventListener('message', handler);
6976
},
7077
postReady(ready) {
71-
post({
78+
lastReady = {
7279
ready: true,
7380
totalMs: ready.totalMs,
7481
beats: ready.beats,
7582
hold: ready.hold,
7683
reloadEndMs: ready.reloadEndMs,
77-
});
84+
};
85+
post(lastReady);
7886
},
7987
postState(state) {
8088
post({ applied: state.applied, phase: state.phase, t: state.t });

0 commit comments

Comments
 (0)