diff --git a/examples/chat/angular/src/app/hero/hero-mode.component.spec.ts b/examples/chat/angular/src/app/hero/hero-mode.component.spec.ts index 12f3bd1dc..b81585bd6 100644 --- a/examples/chat/angular/src/app/hero/hero-mode.component.spec.ts +++ b/examples/chat/angular/src/app/hero/hero-mode.component.spec.ts @@ -137,6 +137,28 @@ describe('HeroMode', () => { expect(fx.componentInstance.mode()).toBe('live'); }); + it('reduced motion: typeInto sets the value instantly but keeps a reading pause before resolving', async () => { + fx.componentInstance.reducedMotion = true; + const el = fx.nativeElement as HTMLElement; + const textarea = el.querySelector('textarea[aria-label="Type a message"]')!; + + const started = performance.now(); + const typing = fx.componentInstance.typeInto('abc'); + // Instant: the value is set synchronously, before any timer fires. + expect(textarea.value).toBe('abc'); + + await typing; + expect(performance.now() - started).toBeGreaterThanOrEqual(1200); + }); + + it('reduced motion: moveCursor holds for a reading pause instead of resolving instantly', async () => { + fx.componentInstance.reducedMotion = true; + + const started = performance.now(); + await fx.componentInstance.moveCursor('composer'); + expect(performance.now() - started).toBeGreaterThanOrEqual(600); + }); + it('clears the half-typed composer on takeover', async () => { const el = fx.nativeElement as HTMLElement; await fx.componentInstance.typeInto('hello'); diff --git a/examples/chat/angular/src/app/hero/hero-mode.component.ts b/examples/chat/angular/src/app/hero/hero-mode.component.ts index f4c9f7f66..ad499c695 100644 --- a/examples/chat/angular/src/app/hero/hero-mode.component.ts +++ b/examples/chat/angular/src/app/hero/hero-mode.component.ts @@ -47,6 +47,12 @@ import { HeroScriptRunner, type CursorTarget, type HeroScriptHost } from './hero export type HeroModeKind = 'replay' | 'live'; const TYPE_DELAY_MS = 40; +/** + * Reduced motion removes animation, not reading time: a visitor who never + * sees the cursor glide or the prompt type out still needs a moment to read + * what just appeared before the walkthrough moves on. + */ +const READ_PAUSE_MS = 1200; /** * The live agent's thread id, held per component instance (NOT at module @@ -216,7 +222,8 @@ export class HeroMode implements HeroScriptHost { typeof window === 'undefined' ? { postState: () => undefined, onVisibility: () => () => undefined } : browserHeroBridge(); - readonly reducedMotion = + /** TEST SEAM. Overridable the same way `bridge` is, instead of mocking matchMedia globally. */ + reducedMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; private runner: HeroScriptRunner | null = null; @@ -389,9 +396,13 @@ export class HeroMode implements HeroScriptHost { async typeInto(text: string): Promise { // Deliberately does NOT focus the textarea: that would trip the takeover. - await this.driving(() => - typeIntoTextarea(composerOf(this.surface()), text, TYPE_DELAY_MS, this.reducedMotion), - ); + await this.driving(async () => { + await typeIntoTextarea(composerOf(this.surface()), text, TYPE_DELAY_MS, this.reducedMotion); + // Reduced motion writes the whole prompt in one tick; without a pause + // here it would be typed and sent in the same tick and a reduced-motion + // visitor would never get to read it. + if (this.reducedMotion) await sleep(READ_PAUSE_MS); + }); } async send(): Promise { @@ -420,7 +431,9 @@ export class HeroMode implements HeroScriptHost { this.cursorX.set(point.x); this.cursorY.set(point.y); this.cursorVisible.set(true); - await sleep(this.reducedMotion ? 0 : 650); + // Reduced motion skips the glide (the cursor jumps) but still holds + // briefly so the target is readable before the next scripted action. + await sleep(this.reducedMotion ? READ_PAUSE_MS / 2 : 650); } hasInterrupt(): boolean {