Skip to content

Commit 8f4fc22

Browse files
committed
fix(stage): make chat scroll exploration explicit
1 parent 603df64 commit 8f4fc22

10 files changed

Lines changed: 212 additions & 11 deletions

File tree

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,25 @@ test.describe('homepage stage', () => {
8181
const y = await page.evaluate(() => scrollY);
8282
await frame.getByRole('tab', { name: 'State', exact: true }).hover();
8383
await page.mouse.wheel(0, 150);
84-
await expect.poll(() => page.evaluate(() => scrollY)).toBe(y);
84+
await expect.poll(() => page.evaluate(() => scrollY)).toBeGreaterThan(y);
85+
await expect(act).toHaveAttribute('data-interactive', '', { timeout: 30_000 });
86+
await page.getByRole('button', { name: 'Explore chat', exact: true }).click();
87+
await expect(act).toHaveAttribute('data-exploring', '');
88+
const exploringY = await page.evaluate(() => scrollY);
89+
await frame.getByRole('tab', { name: 'State', exact: true }).hover();
90+
await page.mouse.wheel(0, 150);
91+
await page.waitForTimeout(400);
92+
expect(await page.evaluate(() => scrollY)).toBe(exploringY);
93+
await frame.getByRole('tab', { name: 'State', exact: true }).click();
94+
await page.keyboard.press('Escape');
95+
await expect(act).not.toHaveAttribute('data-exploring');
96+
await expect(page.getByRole('button', { name: 'Explore chat', exact: true })).toBeFocused();
97+
await page.getByRole('button', { name: 'Explore chat', exact: true }).click();
98+
await page.getByRole('button', { name: 'Resume walkthrough', exact: true }).click();
99+
await expect(act).not.toHaveAttribute('data-exploring');
100+
await page.getByRole('button', { name: 'Explore chat', exact: true }).click();
85101
await scrollAct(page, 1);
102+
await expect(act).not.toHaveAttribute('data-exploring');
86103
await expect(act).toHaveAttribute('data-interactive', '', { timeout: 30_000 });
87104
await expect(frame.getByRole('tab', { name: 'State', exact: true })).toHaveAttribute('aria-selected', 'true');
88105
const notes = frame.getByRole('textbox', { name: 'Follow-up notes' });

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

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -177,14 +177,19 @@ export function StageAct({ onFallback }: Props) {
177177
/>
178178
</div>
179179
</BrowserFrame>
180-
<a
181-
className="stage-frame-open"
182-
href={STAGE_DEMO_ORIGIN}
183-
target="_blank"
184-
rel="noopener noreferrer"
185-
>
186-
Open the live demo →
187-
</a>
180+
<div className="stage-frame-actions">
181+
<button type="button" className="stage-explore" data-stage-explore aria-pressed="false" disabled>
182+
Explore chat
183+
</button>
184+
<a
185+
className="stage-frame-open"
186+
href={STAGE_DEMO_ORIGIN}
187+
target="_blank"
188+
rel="noopener noreferrer"
189+
>
190+
Open the live demo →
191+
</a>
192+
</div>
188193
</div>
189194
<div className="stage-rail">
190195
<nav className="stage-checklist" aria-label="Workflow capabilities">

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,38 @@ function fromDemo(data: unknown) {
9191
);
9292
}
9393

94+
describe('explicit exploration', () => {
95+
it('routes wheel to the page by default and restores it after Escape', () => {
96+
let now = 0;
97+
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now);
98+
const scroll = vi.spyOn(window, 'scrollBy').mockImplementation(() => undefined);
99+
const { section, pub, posted } = setup({ rail: s => {
100+
s.innerHTML = '<button data-stage-explore aria-pressed="false">Explore chat</button>';
101+
} });
102+
pub.tick();
103+
fromDemo({ type: STAGE_MESSAGE_TYPE, applied: 0, phase: 'stream', t: 0, settled: true });
104+
now = 300;
105+
pub.tick();
106+
fromDemo({ type: STAGE_MESSAGE_TYPE, wheel: { deltaX: 0, deltaY: 80 } });
107+
expect(scroll).toHaveBeenCalledWith({ left: 0, top: 80, behavior: 'instant' });
108+
now = 600;
109+
pub.tick();
110+
const button = section.querySelector('button')!;
111+
button.click();
112+
expect(button.textContent).toBe('Resume walkthrough');
113+
expect(posted.at(-1)?.m).toEqual({ type: STAGE_MESSAGE_TYPE, explore: true });
114+
fromDemo({ type: STAGE_MESSAGE_TYPE, wheel: { deltaX: 0, deltaY: 80 } });
115+
expect(scroll).toHaveBeenCalledTimes(1);
116+
fromDemo({ type: STAGE_MESSAGE_TYPE, explore: false });
117+
expect(button.textContent).toBe('Explore chat');
118+
button.click();
119+
window.dispatchEvent(new Event('scroll'));
120+
expect(section.hasAttribute('data-exploring')).toBe(false);
121+
clock.mockRestore();
122+
scroll.mockRestore();
123+
});
124+
});
125+
94126
function setup(
95127
opts: {
96128
frameWindow?: () => Window | null;

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,39 @@ export function createStagePublisher(deps: StagePublisherDeps): StagePublisher {
114114
let lastMovement = performance.now();
115115
let appliedT = -1;
116116
const iframe = deps.section.querySelector<HTMLIFrameElement>('.stage-frame-iframe');
117+
const exploreButton = deps.section.querySelector<HTMLButtonElement>('[data-stage-explore]');
118+
let exploring = false;
119+
const setExploring = (value: boolean) => {
120+
if (exploring === value) return;
121+
exploring = value;
122+
deps.section.toggleAttribute('data-exploring', value);
123+
if (exploreButton) {
124+
exploreButton.textContent = value ? 'Resume walkthrough' : 'Explore chat';
125+
exploreButton.setAttribute('aria-pressed', String(value));
126+
}
127+
deps.frameWindow()?.postMessage({ type: STAGE_MESSAGE_TYPE, explore: value }, STAGE_DEMO_ORIGIN);
128+
};
129+
const toggleExploring = () => {
130+
if (deps.section.hasAttribute('data-interactive')) setExploring(!exploring);
131+
};
132+
const onKey = (event: KeyboardEvent) => {
133+
if (event.key === 'Escape' && exploring) {
134+
setExploring(false);
135+
exploreButton?.focus({ preventScroll: true });
136+
}
137+
};
138+
exploreButton?.addEventListener('click', toggleExploring);
139+
window.addEventListener('keydown', onKey);
117140
const setInteractive = (enabled: boolean) => {
118141
deps.section.toggleAttribute('data-interactive', enabled);
142+
if (exploreButton) exploreButton.disabled = !enabled;
119143
if (iframe) {
120144
iframe.toggleAttribute('inert', !enabled);
121145
iframe.tabIndex = enabled ? 0 : -1;
122146
}
123147
};
124148
const onScroll = () => {
149+
setExploring(false);
125150
lastMovement = performance.now();
126151
setInteractive(false);
127152
};
@@ -148,6 +173,22 @@ export function createStagePublisher(deps: StagePublisherDeps): StagePublisher {
148173
return;
149174
const d = e.data as Record<string, unknown> | null;
150175
if (!d || typeof d !== 'object' || d['type'] !== STAGE_MESSAGE_TYPE) return;
176+
if (d['explore'] === false) {
177+
setExploring(false);
178+
exploreButton?.focus({ preventScroll: true });
179+
return;
180+
}
181+
const wheel = d['wheel'] as { deltaX?: unknown; deltaY?: unknown } | undefined;
182+
if (ready && !exploring && wheel && isFiniteNumber(wheel.deltaX) && isFiniteNumber(wheel.deltaY)) {
183+
onScroll();
184+
const limit = window.innerHeight * 2;
185+
window.scrollBy({
186+
left: Math.max(-limit, Math.min(limit, wheel.deltaX)),
187+
top: Math.max(-limit, Math.min(limit, wheel.deltaY)),
188+
behavior: 'instant',
189+
});
190+
return;
191+
}
151192
if (d['ready'] === true) {
152193
if (!isReady(d)) return;
153194
const first = ready === null;
@@ -249,6 +290,9 @@ export function createStagePublisher(deps: StagePublisherDeps): StagePublisher {
249290
},
250291
dispose() {
251292
disposed = true;
293+
setExploring(false);
294+
exploreButton?.removeEventListener('click', toggleExploring);
295+
window.removeEventListener('keydown', onKey);
252296
window.removeEventListener('message', onMessage);
253297
window.removeEventListener('scroll', onScroll);
254298
setInteractive(false);

apps/website/src/styles/landing.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,11 @@
11061106
font-size: 13px;
11071107
justify-self: end;
11081108
}
1109+
.stage-frame-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
1110+
.stage-explore { font: inherit; font-size: 13px; color: inherit; background: transparent; border: 1px solid #b8b8b8; border-radius: 6px; padding: 3px 10px; cursor: pointer; }
1111+
.stage-explore:disabled { opacity: .45; cursor: default; }
1112+
.stage-explore[aria-pressed='true'] { background: #eef3ff; border-color: #7289b9; }
1113+
.stage-explore:focus-visible { outline: 2px solid #3963a8; outline-offset: 3px; }
11091114
/* Persistent capability checklist; only the hold cue and final CTA fade. */
11101115
.stage-rail {
11111116
display: grid;

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export interface StageState {
2121

2222
export interface StageBridge {
2323
onSeek(cb: (t: number) => void): () => void;
24+
onExplore(cb: (explore: boolean) => void): () => void;
25+
postExplore(explore: boolean): void;
26+
postWheel(deltaX: number, deltaY: number): boolean;
2427
postReady(ready: StageReady): void;
2528
postState(state: StageState): void;
2629
}
@@ -54,10 +57,22 @@ export function createStageBridge(env: BridgeEnv): StageBridge {
5457
// arrived with an empty referrer still receives the handshake.
5558
let lastReady: Record<string, unknown> | null = null;
5659
const post = (msg: Record<string, unknown>) => {
57-
if (!embedded || parentOrigin === null) return;
60+
if (!embedded || parentOrigin === null) return false;
5861
env.parent.postMessage({ type: STAGE_MESSAGE_TYPE, ...msg }, parentOrigin);
62+
return true;
5963
};
6064
return {
65+
onExplore(cb) {
66+
const handler = (e: MessageEvent) => {
67+
if (e.source !== env.parent || !isAllowedParentOrigin(e.origin)) return;
68+
const d = e.data;
69+
if (d?.type === STAGE_MESSAGE_TYPE && typeof d.explore === 'boolean') cb(d.explore);
70+
};
71+
env.self.addEventListener('message', handler);
72+
return () => env.self.removeEventListener('message', handler);
73+
},
74+
postExplore(explore) { post({ explore }); },
75+
postWheel(deltaX, deltaY) { return post({ wheel: { deltaX, deltaY } }); },
6176
onSeek(cb) {
6277
const handler = (e: MessageEvent) => {
6378
if (e.source !== env.parent) return;

examples/chat/angular/src/app/stage/stage-mode.component.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,9 @@ describe('StageMode', () => {
158158
onSeek: () => () => undefined,
159159
postReady: (r) => posted.push(r),
160160
postState: (s) => posted.push(s),
161+
onExplore: () => () => undefined,
162+
postExplore: () => undefined,
163+
postWheel: () => false,
161164
};
162165
await fx.componentInstance.boot(new URLSearchParams('t=0'));
163166
fx.detectChanges();

examples/chat/angular/src/app/stage/stage-mode.component.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from '@threadplane/chat';
2323
import { ChatDebugComponent } from '@threadplane/chat/debug';
2424
import { stagePresentationAgent } from './stage-interaction';
25+
import { stageWheelOwnership } from './stage-wheel';
2526
import {
2627
FetchStreamTransport,
2728
injectAgent,
@@ -273,6 +274,7 @@ export class StageMode {
273274
? this.agent
274275
: stagePresentationAgent(this.agent, () => this.openLiveDemo());
275276
protected readonly debugOpen = signal(true);
277+
private exploring = false;
276278
protected readonly inspectionDock = signal<'left' | 'right' | 'bottom' | null>(null);
277279

278280
protected openLiveDemo(): void {
@@ -307,7 +309,14 @@ export class StageMode {
307309
/** TEST SEAM. Replaced by the spec; browser bridge by default. */
308310
bridge: StageBridge =
309311
typeof window === 'undefined'
310-
? { onSeek: () => () => undefined, postReady: () => undefined, postState: () => undefined }
312+
? {
313+
onSeek: () => () => undefined,
314+
onExplore: () => () => undefined,
315+
postReady: () => undefined,
316+
postState: () => undefined,
317+
postExplore: () => undefined,
318+
postWheel: () => false,
319+
}
311320
: browserStageBridge();
312321

313322
private lastPosted = '';
@@ -320,6 +329,12 @@ export class StageMode {
320329

321330
constructor() {
322331
this.watchDock();
332+
if (!this.recording && typeof window !== 'undefined') {
333+
this.destroyRef.onDestroy(this.bridge.onExplore((value) => { this.exploring = value; }));
334+
this.destroyRef.onDestroy(stageWheelOwnership(window, () => this.exploring,
335+
(x, y) => this.bridge.postWheel(x, y),
336+
() => { this.exploring = false; this.bridge.postExplore(false); }));
337+
}
323338
this.destroyRef.onDestroy(() => {
324339
if (typeof cancelAnimationFrame === 'function') {
325340
if (this.seekFrame !== null) cancelAnimationFrame(this.seekFrame);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { stageWheelOwnership } from './stage-wheel';
3+
4+
describe('stage wheel ownership', () => {
5+
it('forwards wheel units to the page until exploration is explicitly enabled', () => {
6+
let exploring = false;
7+
const forward = vi.fn(() => true);
8+
const stop = stageWheelOwnership(window, () => exploring, forward, vi.fn());
9+
const wheel = new WheelEvent('wheel', { deltaY: 3, deltaMode: 1, cancelable: true });
10+
window.dispatchEvent(wheel);
11+
expect(forward).toHaveBeenCalledWith(0, 48);
12+
expect(wheel.defaultPrevented).toBe(true);
13+
exploring = true;
14+
const local = new WheelEvent('wheel', { deltaY: 80, cancelable: true });
15+
window.dispatchEvent(local);
16+
expect(local.defaultPrevented).toBe(false);
17+
expect(forward).toHaveBeenCalledTimes(1);
18+
stop();
19+
});
20+
21+
it('preserves pinch zoom and standalone scrolling; Escape exits exploration', () => {
22+
const forward = vi.fn(() => false);
23+
const resume = vi.fn();
24+
let exploring = false;
25+
const stop = stageWheelOwnership(window, () => exploring, forward, resume);
26+
const zoom = new WheelEvent('wheel', { deltaY: 50, ctrlKey: true, cancelable: true });
27+
window.dispatchEvent(zoom);
28+
expect(forward).not.toHaveBeenCalled();
29+
const standalone = new WheelEvent('wheel', { deltaY: 50, cancelable: true });
30+
window.dispatchEvent(standalone);
31+
expect(standalone.defaultPrevented).toBe(false);
32+
exploring = true;
33+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
34+
expect(resume).toHaveBeenCalledOnce();
35+
stop();
36+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
37+
expect(resume).toHaveBeenCalledOnce();
38+
});
39+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/** Wheel ownership is explicit; a pause only unlocks clicks, not local scrolling. */
2+
export function stageWheelOwnership(
3+
host: Window,
4+
exploring: () => boolean,
5+
forward: (deltaX: number, deltaY: number) => boolean,
6+
resume: () => void,
7+
): () => void {
8+
const wheel = (event: WheelEvent) => {
9+
if (exploring() || event.ctrlKey || !event.cancelable) return;
10+
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? host.innerHeight : 1;
11+
if (forward(event.deltaX * unit, event.deltaY * unit)) event.preventDefault();
12+
};
13+
const key = (event: KeyboardEvent) => {
14+
if (event.key === 'Escape' && exploring()) {
15+
resume();
16+
event.preventDefault();
17+
event.stopImmediatePropagation();
18+
}
19+
};
20+
host.addEventListener('wheel', wheel, { capture: true, passive: false });
21+
host.addEventListener('keydown', key, true);
22+
return () => {
23+
host.removeEventListener('wheel', wheel, true);
24+
host.removeEventListener('keydown', key, true);
25+
};
26+
}

0 commit comments

Comments
 (0)