Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 22 additions & 0 deletions examples/chat/angular/src/app/hero/hero-mode.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLTextAreaElement>('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');
Expand Down
23 changes: 18 additions & 5 deletions examples/chat/angular/src/app/hero/hero-mode.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -389,9 +396,13 @@ export class HeroMode implements HeroScriptHost {

async typeInto(text: string): Promise<void> {
// 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<void> {
Expand Down Expand Up @@ -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 {
Expand Down
Loading