Skip to content

Commit 8bc4dcc

Browse files
feat: guide visitors through live review demo
1 parent 45ab977 commit 8bc4dcc

7 files changed

Lines changed: 491 additions & 18 deletions

File tree

.beads/export-state.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"last_dolt_commit":"b35qgg861lepbkfkm2ml7skgkfktai9s","timestamp":"2026-07-19T08:39:27.669296-06:00","issues":404,"memories":15}
1+
{"last_dolt_commit":"cdie7jraaesaet7r9isciv0ksidv6b24","timestamp":"2026-07-19T09:03:11.681879-06:00","issues":405,"memories":15}

.beads/interactions.jsonl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,3 +454,5 @@
454454
{"id":"int-4f191a29","kind":"field_change","created_at":"2026-07-19T13:54:20.650916Z","actor":"James Lal","issue_id":"attn-8wo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Alternate /homepage-alt positioning study implemented with interactive review demo, responsive layouts, theme support, and passing accessibility/build checks."}}
455455
{"id":"int-16dba6a6","kind":"field_change","created_at":"2026-07-19T14:25:49.293362Z","actor":"James Lal","issue_id":"attn-xnt","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
456456
{"id":"int-0b329ca4","kind":"field_change","created_at":"2026-07-19T14:39:26.590239Z","actor":"James Lal","issue_id":"attn-xnt","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Alternate homepage now embeds a production-component review demo with seeded Claude/Codex feedback, real visitor commenting, responsive presentation, same-origin-only framing, and passing a11y/build/e2e gates."}}
457+
{"id":"int-fab64950","kind":"field_change","created_at":"2026-07-19T14:46:22.845527Z","actor":"James Lal","issue_id":"attn-3yt","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
458+
{"id":"int-5a023288","kind":"field_change","created_at":"2026-07-19T15:03:10.731435Z","actor":"James Lal","issue_id":"attn-3yt","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented guided real-reply autoplay, takeover cancellation, replay, mobile tap mode, reduced-motion completion, and production E2E coverage."}}

.beads/issues.jsonl

Lines changed: 9 additions & 9 deletions
Large diffs are not rendered by default.

web/e2e/landing-review-demo.spec.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import AxeBuilder from '@axe-core/playwright';
22
import { expect, test, type Page } from '@playwright/test';
33

4+
const GUIDED_REPLY = 'Exactly — the owner still makes the final call.';
5+
46
async function selectDemoText(page: Page, text: string): Promise<void> {
57
await page.evaluate((targetText) => {
68
const target = window as unknown as {
@@ -32,7 +34,7 @@ async function selectDemoText(page: Page, text: string): Promise<void> {
3234
}
3335

3436
test('landing demo shows agent feedback and lets the visitor join the review', async ({ page }) => {
35-
await page.goto('/app?surface=landing-review-demo');
37+
await page.goto('/app?surface=landing-review-demo&autoplay=0');
3638
await expect(page.locator('body')).toHaveAttribute('data-surface', 'landing-review-demo');
3739
await expect(page.locator('.ProseMirror')).toContainText('Launch direction');
3840

@@ -71,6 +73,50 @@ test('alternate homepage embeds the production review surface', async ({ page })
7173
const frame = page.frameLocator('iframe[title^="Interactive attn Markdown review"]');
7274
await expect(frame.locator('.ProseMirror')).toContainText('Launch direction');
7375
await expect(frame.getByTestId('review-margin-card')).toHaveCount(3);
76+
await expect(frame.getByText(GUIDED_REPLY, { exact: true })).toBeVisible();
77+
await expect(frame.locator('.landing-review-demo')).toHaveAttribute('data-demo-state', 'complete');
78+
const replay = frame.getByRole('button', { name: 'Replay demo' });
79+
await expect(replay).toBeVisible();
80+
await replay.click();
81+
await expect(frame.getByText(GUIDED_REPLY, { exact: true })).toHaveCount(0);
82+
await expect(frame.getByText(GUIDED_REPLY, { exact: true })).toBeVisible();
83+
await expect(frame.getByText(GUIDED_REPLY, { exact: true })).toHaveCount(1);
84+
});
85+
86+
test('visitor input cancels the guided reply and leaves the real composer ready', async ({ page }) => {
87+
await page.goto('/app?surface=landing-review-demo');
88+
89+
const cursor = page.locator('[data-slot="guided-demo-cursor"]');
90+
await expect(cursor).toHaveAttribute('data-phase', 'typing');
91+
const textarea = page.locator(
92+
'[data-thread-id="landing-demo-codex-thread"] [data-slot="review-reply-composer"] textarea',
93+
);
94+
await textarea.click();
95+
96+
await expect(cursor).toHaveAttribute('data-phase', 'cancelled');
97+
await expect(textarea).toHaveValue('');
98+
await page.waitForTimeout(1_000);
99+
await expect(page.getByText(GUIDED_REPLY, { exact: true })).toHaveCount(0);
100+
await expect(textarea).toBeFocused();
101+
});
102+
103+
test('reduced motion completes the real reply without showing a moving pointer', async ({ page }) => {
104+
await page.emulateMedia({ reducedMotion: 'reduce' });
105+
await page.goto('/app?surface=landing-review-demo');
106+
107+
await expect(page.getByText(GUIDED_REPLY, { exact: true })).toBeVisible();
108+
await expect(page.locator('.landing-review-demo')).toHaveAttribute('data-demo-state', 'complete');
109+
await expect(page.locator('[data-slot="guided-demo-cursor"]')).not.toHaveClass(/is-visible/u);
110+
});
111+
112+
test('phone demo uses a visible tap target instead of a desktop cursor', async ({ page }) => {
113+
await page.setViewportSize({ width: 390, height: 844 });
114+
await page.goto('/app?surface=landing-review-demo');
115+
116+
const cursor = page.locator('[data-slot="guided-demo-cursor"]');
117+
await expect(cursor).toHaveAttribute('data-phase', 'reply');
118+
await expect(cursor).toHaveAttribute('data-pointer', 'tap');
119+
await expect(cursor).toHaveClass(/is-visible/u);
74120
});
75121

76122
test('only the landing demo surface permits same-origin framing', async ({ request }) => {
@@ -85,7 +131,7 @@ test('only the landing demo surface permits same-origin framing', async ({ reque
85131

86132
test('landing demo remains contained at phone width', async ({ page }) => {
87133
await page.setViewportSize({ width: 320, height: 900 });
88-
await page.goto('/app?surface=landing-review-demo');
134+
await page.goto('/app?surface=landing-review-demo&autoplay=0');
89135
await expect(page.locator('.ProseMirror')).toBeVisible();
90136
await expect(page.getByTestId('review-margin-card')).toHaveCount(3);
91137
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
<script lang="ts">
2+
import { onDestroy, onMount, tick } from 'svelte';
3+
4+
interface Props {
5+
enabled?: boolean;
6+
replyText: string;
7+
onstart?: () => void;
8+
oncomplete?: () => void;
9+
oncancel?: () => void;
10+
}
11+
12+
let {
13+
enabled = true,
14+
replyText,
15+
onstart = () => undefined,
16+
oncomplete = () => undefined,
17+
oncancel = () => undefined,
18+
}: Props = $props();
19+
20+
type Phase = 'idle' | 'reply' | 'typing' | 'send' | 'complete' | 'cancelled';
21+
22+
const CODEX_CARD = '[data-thread-id="landing-demo-codex-thread"]';
23+
const REPLY_BUTTON = `${CODEX_CARD} [data-action="reply"]`;
24+
const REPLY_COMPOSER = `${CODEX_CARD} [data-slot="review-reply-composer"]`;
25+
26+
let phase = $state<Phase>('idle');
27+
let x = $state(24);
28+
let y = $state(24);
29+
let cursorVisible = $state(false);
30+
let compactPointer = $state(false);
31+
let reducedMotion = false;
32+
let outerVisible = false;
33+
let autoplayStarted = false;
34+
let userTookOver = false;
35+
let scriptOwnsInput = false;
36+
let runToken = 0;
37+
let autoTimer: ReturnType<typeof setTimeout> | undefined;
38+
let mediaQuery: MediaQueryList | undefined;
39+
40+
function current(token: number): boolean {
41+
return token === runToken && phase !== 'cancelled';
42+
}
43+
44+
async function pause(milliseconds: number, token: number): Promise<boolean> {
45+
let remaining = milliseconds;
46+
while (remaining > 0) {
47+
if (!current(token)) return false;
48+
if (!outerVisible || document.hidden) {
49+
await new Promise<void>((resolve) => setTimeout(resolve, 60));
50+
continue;
51+
}
52+
const slice = Math.min(remaining, 45);
53+
await new Promise<void>((resolve) => setTimeout(resolve, slice));
54+
remaining -= slice;
55+
}
56+
return current(token);
57+
}
58+
59+
async function findElement<T extends Element>(selector: string, token: number): Promise<T | null> {
60+
const deadline = Date.now() + 5_000;
61+
while (Date.now() < deadline && current(token)) {
62+
const element = document.querySelector<T>(selector);
63+
if (element) return element;
64+
if (!(await pause(50, token))) return null;
65+
}
66+
return null;
67+
}
68+
69+
function targetPoint(element: Element): { x: number; y: number } {
70+
const rect = element.getBoundingClientRect();
71+
return {
72+
x: Math.min(window.innerWidth - 26, Math.max(16, rect.left + Math.min(rect.width * 0.66, rect.width - 12))),
73+
y: Math.min(window.innerHeight - 26, Math.max(16, rect.top + Math.min(rect.height * 0.62, rect.height - 10))),
74+
};
75+
}
76+
77+
async function pointAt(element: Element, nextPhase: Phase, token: number): Promise<boolean> {
78+
const rect = element.getBoundingClientRect();
79+
const outside = rect.top < 74 || rect.bottom > window.innerHeight - 38;
80+
if (outside) {
81+
element.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'center' });
82+
if (!(await pause(reducedMotion ? 0 : 420, token))) return false;
83+
}
84+
const point = targetPoint(element);
85+
phase = nextPhase;
86+
x = point.x;
87+
y = point.y;
88+
cursorVisible = !reducedMotion;
89+
return pause(reducedMotion ? 0 : 720, token);
90+
}
91+
92+
function inputValue(textarea: HTMLTextAreaElement, value: string, data: string | null): void {
93+
textarea.value = value;
94+
textarea.dispatchEvent(new InputEvent('input', {
95+
bubbles: true,
96+
data,
97+
inputType: data === null ? 'deleteContentBackward' : 'insertText',
98+
}));
99+
}
100+
101+
function clearScriptedDraft(): void {
102+
if (!scriptOwnsInput) return;
103+
const textarea = document.querySelector<HTMLTextAreaElement>(`${REPLY_COMPOSER} textarea`);
104+
if (textarea) inputValue(textarea, '', null);
105+
scriptOwnsInput = false;
106+
}
107+
108+
function cancelForVisitor(event?: Event): void {
109+
if (event && !event.isTrusted) return;
110+
userTookOver = true;
111+
runToken += 1;
112+
phase = 'cancelled';
113+
cursorVisible = false;
114+
clearScriptedDraft();
115+
oncancel();
116+
}
117+
118+
async function runGuidedReply(manual: boolean): Promise<void> {
119+
if (!enabled || (!manual && (autoplayStarted || userTookOver))) return;
120+
autoplayStarted = true;
121+
userTookOver = false;
122+
runToken += 1;
123+
const token = runToken;
124+
phase = 'idle';
125+
cursorVisible = false;
126+
clearScriptedDraft();
127+
onstart();
128+
await tick();
129+
130+
const replyButton = await findElement<HTMLButtonElement>(REPLY_BUTTON, token);
131+
if (!replyButton || !(await pointAt(replyButton, 'reply', token))) return;
132+
replyButton.click();
133+
134+
const textarea = await findElement<HTMLTextAreaElement>(`${REPLY_COMPOSER} textarea`, token);
135+
if (!textarea || !(await pointAt(textarea, 'typing', token))) return;
136+
scriptOwnsInput = true;
137+
138+
if (reducedMotion) {
139+
inputValue(textarea, replyText, replyText);
140+
} else {
141+
let draft = '';
142+
for (const character of replyText) {
143+
if (!current(token)) return;
144+
draft += character;
145+
inputValue(textarea, draft, character);
146+
if (!(await pause(character === ' ' ? 28 : 43, token))) return;
147+
}
148+
if (!(await pause(260, token))) return;
149+
}
150+
151+
const sendButton = await findElement<HTMLButtonElement>(`${REPLY_COMPOSER} .rmc-btn-primary`, token);
152+
if (!sendButton || !(await pointAt(sendButton, 'send', token))) return;
153+
sendButton.click();
154+
scriptOwnsInput = false;
155+
if (!(await pause(reducedMotion ? 0 : 580, token))) return;
156+
157+
phase = 'complete';
158+
cursorVisible = false;
159+
oncomplete();
160+
}
161+
162+
function scheduleAutoplay(): void {
163+
if (!enabled || autoplayStarted || userTookOver || !outerVisible) return;
164+
if (autoTimer) return;
165+
autoTimer = setTimeout(() => void runGuidedReply(false), reducedMotion ? 80 : 520);
166+
}
167+
168+
function handleVisibilityMessage(event: MessageEvent): void {
169+
if (event.origin !== window.location.origin) return;
170+
if (event.data?.type !== 'attn-landing-demo-visibility') return;
171+
outerVisible = event.data.visible === true;
172+
if (outerVisible) scheduleAutoplay();
173+
}
174+
175+
function updatePointerMode(): void {
176+
compactPointer = mediaQuery?.matches ?? false;
177+
}
178+
179+
export function replay(): void {
180+
outerVisible = true;
181+
void runGuidedReply(true);
182+
}
183+
184+
onMount(() => {
185+
reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
186+
mediaQuery = window.matchMedia('(max-width: 680px)');
187+
updatePointerMode();
188+
mediaQuery.addEventListener('change', updatePointerMode);
189+
window.addEventListener('message', handleVisibilityMessage);
190+
window.addEventListener('pointerdown', cancelForVisitor, true);
191+
window.addEventListener('keydown', cancelForVisitor, true);
192+
window.addEventListener('wheel', cancelForVisitor, { capture: true, passive: true });
193+
194+
if (window.parent === window) {
195+
outerVisible = true;
196+
scheduleAutoplay();
197+
} else {
198+
window.parent.postMessage({ type: 'attn-landing-demo-ready' }, window.location.origin);
199+
}
200+
});
201+
202+
onDestroy(() => {
203+
if (autoTimer) clearTimeout(autoTimer);
204+
runToken += 1;
205+
mediaQuery?.removeEventListener('change', updatePointerMode);
206+
window.removeEventListener('message', handleVisibilityMessage);
207+
window.removeEventListener('pointerdown', cancelForVisitor, true);
208+
window.removeEventListener('keydown', cancelForVisitor, true);
209+
window.removeEventListener('wheel', cancelForVisitor, true);
210+
});
211+
</script>
212+
213+
<div
214+
class="guided-demo-cursor"
215+
class:is-visible={cursorVisible}
216+
data-slot="guided-demo-cursor"
217+
data-phase={phase}
218+
data-pointer={compactPointer ? 'tap' : 'cursor'}
219+
style={`--guided-x: ${x}px; --guided-y: ${y}px`}
220+
aria-hidden="true"
221+
>
222+
{#if compactPointer}
223+
<span class="tap-point"></span>
224+
{:else}
225+
<span class="cursor-point"></span>
226+
{/if}
227+
<span class="cursor-label">Demo</span>
228+
</div>
229+
230+
<style>
231+
.guided-demo-cursor {
232+
position: fixed;
233+
z-index: 80;
234+
top: 0;
235+
left: 0;
236+
display: flex;
237+
align-items: center;
238+
gap: 0.32rem;
239+
opacity: 0;
240+
pointer-events: none;
241+
transform: translate3d(var(--guided-x), var(--guided-y), 0);
242+
transition:
243+
transform 680ms cubic-bezier(0.16, 1, 0.3, 1),
244+
opacity 160ms ease;
245+
will-change: transform, opacity;
246+
}
247+
248+
.guided-demo-cursor.is-visible { opacity: 1; }
249+
250+
.cursor-point {
251+
width: 17px;
252+
height: 22px;
253+
flex: none;
254+
background: var(--foreground);
255+
clip-path: polygon(0 0, 0 100%, 5px 15px, 10px 22px, 13px 20px, 8px 13px, 17px 13px);
256+
filter: drop-shadow(0 1px 0 var(--background));
257+
transform: translate(-2px, -2px);
258+
}
259+
260+
.tap-point {
261+
width: 24px;
262+
height: 24px;
263+
flex: none;
264+
border: 2px solid var(--foreground);
265+
border-radius: 50%;
266+
background: color-mix(in oklch, var(--background) 68%, transparent);
267+
}
268+
269+
.guided-demo-cursor[data-phase='reply'] .cursor-point,
270+
.guided-demo-cursor[data-phase='send'] .cursor-point,
271+
.guided-demo-cursor[data-phase='reply'] .tap-point,
272+
.guided-demo-cursor[data-phase='send'] .tap-point {
273+
animation: guided-click 520ms ease-out 520ms both;
274+
}
275+
276+
.cursor-label {
277+
padding: 0.22rem 0.38rem;
278+
border-radius: 2px;
279+
background: var(--foreground);
280+
color: var(--background);
281+
font: 750 0.65rem/1 var(--font-sans, sans-serif);
282+
letter-spacing: 0.025em;
283+
}
284+
285+
@keyframes guided-click {
286+
0%, 100% { transform: scale(1); }
287+
42% { transform: scale(0.78); }
288+
}
289+
290+
@media (prefers-reduced-motion: reduce) {
291+
.guided-demo-cursor { display: none; }
292+
}
293+
</style>

0 commit comments

Comments
 (0)