- Keep your agent stack. Standardize the Angular surface.
-
-
- Threadplane adapts LangGraph and AG-UI into one signal-shaped Agent contract. Your model
- provider stays behind the backend you already operate.
-
-
- {COMPAT_GROUPS.map((group) => (
-
-
- {group.label}
- {group.note}
-
-
- {group.items.map((item) => (
-
- {item.logoSrc ? (
-
- ) : null}
- {item.name}
-
- ))}
-
-
+
+
Works with
+ {RIBBON_ITEMS.map((item) => (
+
+
+ {item.name}
+
))}
+
+ {RIBBON_MORE_COUNT} more
diff --git a/apps/website/src/lib/positioning.spec.ts b/apps/website/src/lib/positioning.spec.ts
index b57a9772d..e2b90c047 100644
--- a/apps/website/src/lib/positioning.spec.ts
+++ b/apps/website/src/lib/positioning.spec.ts
@@ -14,6 +14,7 @@ import {
HERO_SECONDARY_HREF,
HERO_SECONDARY_LABEL,
HERO_SUBHEAD,
+ HERO_SUBHEAD_SEGMENTS,
HERO_TRUST_LINE,
HOME_DESCRIPTION,
HOME_TITLE,
@@ -53,6 +54,17 @@ describe('positioning: hero copy', () => {
expect(HOME_DESCRIPTION.length).toBeLessThanOrEqual(160);
});
+ it('subhead segments join back to HERO_SUBHEAD, with exactly one highlight', () => {
+ // The segments exist only so Hero.tsx can marker-highlight one phrase.
+ // If they ever stop reassembling the source-of-truth string, the rendered
+ // subhead silently diverges from the copy every other surface quotes.
+ expect(HERO_SUBHEAD_SEGMENTS.map((s) => s.text).join('')).toBe(HERO_SUBHEAD);
+ expect(HERO_SUBHEAD_SEGMENTS.filter((s) => s.highlight)).toHaveLength(1);
+ expect(HERO_SUBHEAD_SEGMENTS.find((s) => s.highlight)?.text).toBe(
+ 'Your backend stays where it is.',
+ );
+ });
+
it('pins the hero action labels and the secondary destination', () => {
expect(HERO_PRIMARY_LABEL).toBe('Install Threadplane');
expect(HERO_SECONDARY_LABEL).toBe('See it running in the docs →');
diff --git a/apps/website/src/lib/positioning.ts b/apps/website/src/lib/positioning.ts
index 4b63aa9ad..89ac2fdb0 100644
--- a/apps/website/src/lib/positioning.ts
+++ b/apps/website/src/lib/positioning.ts
@@ -5,6 +5,23 @@ export const HERO_EYEBROW = 'Open-source · Angular · LangGraph & AG-UI';
export const HERO_H1 = 'The AI agent UI framework for Angular.';
export const HERO_SUBHEAD =
'Chat, threads, approvals, and generative UI on Signals and DI. Your backend stays where it is.';
+
+/**
+ * The subhead, split so Hero.tsx can marker-highlight the boundary claim.
+ * HERO_SUBHEAD stays the single source of truth: positioning.spec.ts asserts
+ * these segments join back to it character for character, so the two cannot
+ * drift. Exactly one segment is highlighted — a second one in a sentence this
+ * short reads as decoration and cancels the emphasis.
+ */
+export interface HeroSubheadSegment {
+ text: string;
+ highlight?: boolean;
+}
+export const HERO_SUBHEAD_SEGMENTS: readonly HeroSubheadSegment[] = [
+ { text: 'Chat, threads, approvals, and generative UI on Signals and DI. ' },
+ { text: 'Your backend stays where it is.', highlight: true },
+];
+
export const HERO_PRIMARY_LABEL = 'Install Threadplane';
export const HERO_SECONDARY_LABEL = 'See it running in the docs →';
export const HERO_SECONDARY_HREF = '/docs/chat/guides/generative-ui?mode=run';
diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css
index 0d915494b..54610d8f0 100644
--- a/apps/website/src/styles/landing.css
+++ b/apps/website/src/styles/landing.css
@@ -67,6 +67,16 @@
.hero-stack .hero-subhead {
max-width: 40em;
}
+/* The centered hero wraps the highlighted boundary claim mid-phrase — measured
+ * at 768px it orphaned a lone "Your" in its own pill at the end of line 1, and
+ * at 1440px it split into two staggered boxes. inline-block makes the phrase
+ * wrap as one unit, so it drops whole onto its own line instead. max-width is
+ * the safety valve: below ~360px the phrase is wider than the measure, and
+ * without it a shrink-to-fit box would overflow the page rather than wrap. */
+.hero-stack .hero-subhead .marker-highlight {
+ display: inline-block;
+ max-width: 100%;
+}
.hero-stack .hero-cta-row {
justify-content: center;
}
@@ -1228,46 +1238,16 @@
/* ---------- LogoRibbon — components/landing/LogoRibbon.tsx ---------- */
.logo-ribbon {
background: var(--color-canvas);
- padding: 32px 0;
border-top: 1px solid var(--color-border);
/* No border-bottom: the dark proof band directly below draws its own accent
* seam at the boundary; two hairlines would stack. */
}
-.logo-ribbon-heading {
- margin: 0 0 4px;
- font-size: 18px;
- font-weight: 600;
-}
-.logo-ribbon-lede {
- margin: 0 0 20px;
- max-width: 60em;
- color: var(--color-text-muted);
- font-size: 14px;
-}
-.logo-ribbon-groups {
- display: grid;
- gap: 14px;
-}
-.logo-ribbon-group {
- display: grid;
- grid-template-columns: minmax(0, 260px) minmax(0, 1fr);
- gap: 12px 24px;
- align-items: center;
-}
-.logo-ribbon-group-head {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-.logo-ribbon-note {
- font-size: 12px;
- color: var(--color-text-muted);
-}
.logo-ribbon-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 26px;
+ padding: 16px 0;
}
.logo-ribbon-label {
font-family: var(--font-mono);
@@ -1294,10 +1274,12 @@
font-weight: 500;
color: var(--color-text-secondary);
}
-@media (max-width: 767px) {
- .logo-ribbon-group {
- grid-template-columns: 1fr;
- }
+.logo-ribbon-more {
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--color-text-muted);
}
/* Shared rail for the three CodeShowcase intros — the last centered kicker
diff --git a/apps/website/src/styles/style-contracts.spec.ts b/apps/website/src/styles/style-contracts.spec.ts
index a3d2a901d..375ac969e 100644
--- a/apps/website/src/styles/style-contracts.spec.ts
+++ b/apps/website/src/styles/style-contracts.spec.ts
@@ -252,6 +252,15 @@ const CONTRACTS: StyleContract[] = [
'min-height': /min-height:\s*44px/,
},
},
+ {
+ file: 'landing.css',
+ selector: '.marker-highlight',
+ why: 'The hero subhead is centered and the highlighted boundary claim wraps at every width below ~1200px (it wraps mid-phrase at 390px). Without box-decoration-break: clone the browser paints ONE background box spanning the union of the wrapped lines, so the marker bleeds across the full paragraph width and past the text it is meant to emphasise — it still renders, just wrongly. The negative margins cancel the padding so the highlight does not shift the line box.',
+ requires: {
+ 'box-decoration-break': /[^-]box-decoration-break:\s*clone/,
+ '-webkit-box-decoration-break': /-webkit-box-decoration-break:\s*clone/,
+ },
+ },
];
function baseDeclarationsFor(css: string, selector: string): string {
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 c5440b036..5ce14c6f2 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
@@ -1,6 +1,7 @@
-import { describe, expect, it, beforeEach, afterEach } from 'vitest';
+import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import { HeroMode } from './hero-mode.component';
+import { TYPE_DELAY_MS } from './hero-script';
import { HeroReplayTransport } from './hero-replay.transport';
import type { HeroBridge } from './hero-bridge';
import type { HeroRecording } from './hero-recording.types';
@@ -136,7 +137,34 @@ describe('HeroMode', () => {
expect(fx.componentInstance.mode()).toBe('live');
});
- it('reduced motion: typeInto sets the value instantly but keeps a reading pause before resolving', async () => {
+ it('types one character at a time at the shared TYPE_DELAY_MS cadence', async () => {
+ const el = fx.nativeElement as HTMLElement;
+ const textarea = el.querySelector
('textarea[aria-label="Type a message"]')!;
+ const seen: string[] = [];
+ textarea.addEventListener('input', () => seen.push(textarea.value));
+ const text = 'x'.repeat(50);
+
+ // Fake timers, not a wall-clock budget: asserting `elapsed < 2000ms` on real
+ // timers both flaked under load and added real seconds to the suite, which
+ // destabilised neighbouring specs. This pins the cadence itself.
+ vi.useFakeTimers();
+ try {
+ const done = fx.componentInstance.typeInto(text);
+ await vi.advanceTimersByTimeAsync(TYPE_DELAY_MS * 20);
+ // Partway through: this is typing, not a paste.
+ expect(seen.length).toBeGreaterThan(0);
+ expect(seen.length).toBeLessThan(50);
+ await vi.advanceTimersByTimeAsync(TYPE_DELAY_MS * 40);
+ await done;
+ } finally {
+ vi.useRealTimers();
+ }
+
+ expect(seen).toHaveLength(50);
+ expect(textarea.value).toBe(text);
+ });
+
+ it('reduced motion: typeInto sets the value in one tick, leaving the reading pause to the runner', async () => {
fx.componentInstance.reducedMotion = true;
const el = fx.nativeElement as HTMLElement;
const textarea = el.querySelector('textarea[aria-label="Type a message"]')!;
@@ -147,15 +175,19 @@ describe('HeroMode', () => {
expect(textarea.value).toBe('abc');
await typing;
- expect(performance.now() - started).toBeGreaterThanOrEqual(1200);
+ // The old READ_PAUSE_MS lived here and made this leg take 1.2s. That hold
+ // is now HOLD_AFTER_TYPING_MS in the runner, where BOTH motion settings
+ // get it, so this host call must no longer carry a pacing pause of its own.
+ expect(performance.now() - started).toBeLessThan(400);
});
- 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('moveCursor holds for the same beat with or without reduced motion', async () => {
+ for (const reducedMotion of [false, true]) {
+ fx.componentInstance.reducedMotion = reducedMotion;
+ const started = performance.now();
+ await fx.componentInstance.moveCursor('composer');
+ expect(performance.now() - started).toBeGreaterThanOrEqual(600);
+ }
});
it('clears the half-typed composer on takeover', async () => {
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 84e102df9..66ae8aa12 100644
--- a/examples/chat/angular/src/app/hero/hero-mode.component.ts
+++ b/examples/chat/angular/src/app/hero/hero-mode.component.ts
@@ -42,16 +42,15 @@ import {
} from './hero-dom-host';
import { HeroRecordingTransport } from './hero-recording.transport';
import { HeroReplayTransport } from './hero-replay.transport';
-import { HeroScriptRunner, type CursorTarget, type HeroScriptHost } from './hero-script';
+import {
+ CURSOR_MOVE_MS,
+ HeroScriptRunner,
+ TYPE_DELAY_MS,
+ type CursorTarget,
+ type HeroScriptHost,
+} from './hero-script';
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 embed handshake is a race the frame cannot win on its own. The parent
@@ -451,11 +450,11 @@ export class HeroMode implements HeroScriptHost {
async typeInto(text: string): Promise {
// Deliberately does NOT focus the textarea: that would trip the takeover.
await this.driving(async () => {
+ // Reduced motion writes the whole prompt in one tick. The reading pause
+ // that used to be bolted on here for that case is now the runner's
+ // HOLD_AFTER_TYPING_MS, which EVERY visitor gets — one beat, one number,
+ // and no second pacing system hiding inside the host.
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);
});
}
@@ -485,9 +484,11 @@ export class HeroMode implements HeroScriptHost {
this.cursorX.set(point.x);
this.cursorY.set(point.y);
this.cursorVisible.set(true);
- // 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);
+ // The same hold either way. Reduced motion loses the glide — the CSS
+ // transition is off, so the cursor jumps — but the beat is "the pointer
+ // deliberately went there", and the hold is what makes that legible with
+ // or without the animation.
+ await sleep(CURSOR_MOVE_MS);
}
hasInterrupt(): boolean {
diff --git a/examples/chat/angular/src/app/hero/hero-script.spec.ts b/examples/chat/angular/src/app/hero/hero-script.spec.ts
index faadae541..9d8a2bf50 100644
--- a/examples/chat/angular/src/app/hero/hero-script.spec.ts
+++ b/examples/chat/angular/src/app/hero/hero-script.spec.ts
@@ -1,5 +1,15 @@
import { describe, expect, it, vi } from 'vitest';
-import { HERO_PROMPTS, HOLD_AFTER_DONE_MS, HeroScriptRunner, type HeroScriptHost } from './hero-script';
+import {
+ HERO_PROMPTS,
+ HOLD_AFTER_ANSWER_MS,
+ HOLD_AFTER_DONE_MS,
+ HOLD_AFTER_TYPING_MS,
+ INTERRUPT_DWELL_MS,
+ POLL_MS,
+ HeroScriptRunner,
+ type HeroScriptHost,
+ type ScriptClock,
+} from './hero-script';
interface FakeHost extends HeroScriptHost {
log: string[];
@@ -69,6 +79,36 @@ function autoHost(): FakeHost {
/** Yields a macrotask per sleep so the runner still makes progress under test. */
const clock = { sleep: () => new Promise((r) => setTimeout(r, 0)) };
+/**
+ * A clock that writes each hold into the SAME log the host writes its actions
+ * into, so a spec can assert the pacing beats and the actions as one ordered
+ * sequence instead of two lists it has to correlate by hand.
+ */
+function beatClock(log: string[]): ScriptClock {
+ return {
+ sleep: (ms) => {
+ log.push(`sleep:${ms}`);
+ return new Promise((r) => setTimeout(r, 0));
+ },
+ };
+}
+
+/** The interleaved log with waitFor()'s polling noise dropped. */
+function beatsOf(log: string[]): string[] {
+ return log.filter((entry) => entry !== `sleep:${POLL_MS}`);
+}
+
+/** Feeds a fakeHost() the state changes a real replay would produce. */
+async function driveOnePass(host: FakeHost): Promise {
+ await until(() => host.log.includes('send'));
+ host.running = false;
+ host.interruptPresent = true;
+ await until(() => host.log.includes('accept'));
+ host.running = false;
+ await until(() => host.log.filter((l) => l === 'send').length === 2);
+ host.running = false;
+}
+
async function drain(n = 25): Promise {
for (let i = 0; i < n; i++) await new Promise((r) => setTimeout(r, 0));
}
@@ -191,29 +231,6 @@ describe('HeroScriptRunner', () => {
spy.mockRestore();
});
- it('skips the settle delay under reduced motion', async () => {
- const host = fakeHost();
- (host as { reducedMotion: boolean }).reducedMotion = true;
- const slept: number[] = [];
- const r = new HeroScriptRunner(host, {
- sleep: (ms) => {
- slept.push(ms);
- return new Promise((res) => setTimeout(res, 0));
- },
- });
- r.setVisible(true);
- const done = r.start();
- await until(() => host.log.includes('send'));
- host.running = false;
- host.interruptPresent = true;
- await until(() => host.log.includes('accept'));
- host.running = false;
- await until(() => host.log.filter((l) => l === 'send').length === 2);
- host.running = false;
- await done;
- expect(slept).not.toContain(400);
- });
-
it('loop() replays between passes and stops during the hold', async () => {
const host = autoHost();
let holds = 0;
@@ -247,6 +264,145 @@ describe('HeroScriptRunner', () => {
});
});
+/**
+ * The pacing of the walkthrough is a product claim, not a cosmetic detail:
+ * typing runs fast because nobody needs to be taught what typing is, and the
+ * approval gate holds because "the agent stopped and waited for a human" is
+ * the thing the demo exists to show. These specs pin the beats in order.
+ */
+describe('HeroScriptRunner pacing', () => {
+ it('holds each beat in order, dwelling on the interrupt before reaching for Accept', async () => {
+ const host = fakeHost();
+ const r = new HeroScriptRunner(host, beatClock(host.log));
+ r.setVisible(true);
+ const done = r.start();
+ await driveOnePass(host);
+ await done;
+
+ expect(beatsOf(host.log)).toEqual([
+ 'cursor:composer',
+ `type:${HERO_PROMPTS[0]}`,
+ `sleep:${HOLD_AFTER_TYPING_MS}`,
+ 'cursor:send',
+ 'send',
+ // The beat the whole demo turns on: the panel is up and NOTHING moves.
+ `sleep:${INTERRUPT_DWELL_MS}`,
+ 'cursor:accept',
+ 'accept',
+ `sleep:${HOLD_AFTER_ANSWER_MS}`,
+ 'cursor:composer',
+ `type:${HERO_PROMPTS[1]}`,
+ `sleep:${HOLD_AFTER_TYPING_MS}`,
+ 'cursor:send',
+ 'send',
+ ]);
+ });
+
+ it('starts the dwell only once the interrupt exists, and blocks Accept until it ends', async () => {
+ const host = fakeHost();
+ const slept: number[] = [];
+ let releaseDwell: (() => void) | null = null;
+ const r = new HeroScriptRunner(host, {
+ sleep: (ms) => {
+ slept.push(ms);
+ // Hold the dwell open so "Accept waits for it" is observable rather
+ // than inferred from an ordering that a zero-length sleep also gives.
+ if (ms === INTERRUPT_DWELL_MS) return new Promise((res) => (releaseDwell = res));
+ return new Promise((res) => setTimeout(res, 0));
+ },
+ });
+ r.setVisible(true);
+ void r.start();
+
+ await until(() => host.log.includes('send'));
+ await drain(10);
+ // No interrupt yet: there is nothing to dwell on and nothing to approve.
+ expect(slept).not.toContain(INTERRUPT_DWELL_MS);
+ expect(host.log).not.toContain('cursor:accept');
+
+ host.running = false;
+ host.interruptPresent = true;
+ await until(() => slept.includes(INTERRUPT_DWELL_MS));
+ await drain(10);
+ expect(host.log).not.toContain('cursor:accept');
+
+ releaseDwell!();
+ await until(() => host.log.includes('cursor:accept'));
+ r.stop();
+ });
+
+ it('a dwell that ends while the frame is hidden does not go on to approve', async () => {
+ const host = fakeHost();
+ let releaseDwell: (() => void) | null = null;
+ const r = new HeroScriptRunner(host, {
+ sleep: (ms) =>
+ ms === INTERRUPT_DWELL_MS
+ ? new Promise((res) => (releaseDwell = res))
+ : new Promise((res) => setTimeout(res, 0)),
+ });
+ r.setVisible(true);
+ void r.start();
+ await until(() => host.log.includes('send'));
+ host.running = false;
+ host.interruptPresent = true;
+ await until(() => releaseDwell !== null);
+
+ r.setVisible(false);
+ releaseDwell!();
+ await drain(20);
+ expect(host.log).not.toContain('cursor:accept');
+ expect(r.state()).toBe('paused');
+
+ r.setVisible(true);
+ await until(() => host.log.includes('cursor:accept'));
+ r.stop();
+ });
+
+ it('a run superseded during the dwell never presses Accept', async () => {
+ const host = fakeHost();
+ let releaseDwell: (() => void) | null = null;
+ const r = new HeroScriptRunner(host, {
+ sleep: (ms) =>
+ ms === INTERRUPT_DWELL_MS
+ ? new Promise((res) => (releaseDwell = res))
+ : new Promise((res) => setTimeout(res, 0)),
+ });
+ r.setVisible(true);
+ const done = r.start();
+ await until(() => host.log.includes('send'));
+ host.running = false;
+ host.interruptPresent = true;
+ await until(() => releaseDwell !== null);
+
+ r.stop();
+ releaseDwell!();
+ await done;
+ await drain(20);
+ expect(host.log).not.toContain('cursor:accept');
+ expect(host.log).not.toContain('accept');
+ });
+
+ it('reduced motion keeps every beat: the sequence is identical either way', async () => {
+ const beatsFor = async (reducedMotion: boolean): Promise => {
+ const host = fakeHost();
+ (host as { reducedMotion: boolean }).reducedMotion = reducedMotion;
+ const r = new HeroScriptRunner(host, beatClock(host.log));
+ r.setVisible(true);
+ const done = r.start();
+ await driveOnePass(host);
+ await done;
+ return beatsOf(host.log);
+ };
+
+ const reduced = await beatsFor(true);
+ expect(reduced).toEqual(await beatsFor(false));
+ // Guards against the pair matching because both are empty or beat-free.
+ expect(reduced).toContain(`sleep:${INTERRUPT_DWELL_MS}`);
+ expect(reduced).toContain(`sleep:${HOLD_AFTER_ANSWER_MS}`);
+ expect(reduced.filter((b) => b === `sleep:${HOLD_AFTER_TYPING_MS}`)).toHaveLength(2);
+ });
+});
+
async function until(pred: () => boolean, max = 2000): Promise {
for (let i = 0; i < max; i++) {
if (pred()) return;
diff --git a/examples/chat/angular/src/app/hero/hero-script.ts b/examples/chat/angular/src/app/hero/hero-script.ts
index 327fb191a..a3af18c44 100644
--- a/examples/chat/angular/src/app/hero/hero-script.ts
+++ b/examples/chat/angular/src/app/hero/hero-script.ts
@@ -15,6 +15,13 @@ export const HERO_PROMPTS = [
export type CursorTarget = 'composer' | 'send' | 'accept';
export interface HeroScriptHost {
+ /**
+ * Whether the visitor asked for reduced motion. The runner deliberately does
+ * NOT branch on it: reduced motion removes ANIMATION inside the host (the
+ * prompt appears at once instead of a character at a time, the cursor jumps
+ * instead of gliding) but never removes a beat, so both audiences follow the
+ * same walkthrough at the same tempo. `hero-script.spec.ts` pins that.
+ */
readonly reducedMotion: boolean;
typeInto(text: string): Promise;
send(): Promise;
@@ -31,11 +38,87 @@ export interface ScriptClock {
export type HeroScriptState = 'idle' | 'waiting' | 'running' | 'paused' | 'done' | 'stopped' | 'error';
-export const HOLD_AFTER_DONE_MS = 8000;
-/** How long a single waitFor() may poll before the run is declared failed. */
+/* ── Pacing model ────────────────────────────────────────────────────────────
+ *
+ * Every hold below exists because a BEAT of the walkthrough needs it, and the
+ * length of each one is an argument about what that beat has to communicate —
+ * not a uniform rhythm. The parts a developer already understands (a person
+ * typing into a composer) run about as fast as still reads as typing; the part
+ * that carries the whole claim of the product — the agent stopping itself and
+ * waiting for a human — is given room to be noticed and read.
+ *
+ * This is the ONE place these numbers live. `hero-mode.component.ts` imports
+ * the two it needs rather than keeping its own copies, so the model cannot
+ * drift apart from the host that enacts it.
+ */
+
+/**
+ * Per keystroke while the walkthrough types a prompt. Nobody watching needs
+ * to be taught what typing is, so this is only slow enough to read as a person
+ * at a keyboard rather than a paste. Enacted by `HeroMode.typeInto()`.
+ */
+export const TYPE_DELAY_MS = 9;
+
+/**
+ * After the prompt is fully typed, before the cursor sets off for Send. The
+ * reader has to finish the question before they are shown the answer to it;
+ * without this the last character and the submission land together.
+ */
+export const HOLD_AFTER_TYPING_MS = 1200;
+
+/**
+ * One glide of the scripted pointer. Deliberate pointer motion is what makes
+ * the walkthrough read as somebody using the product; hurrying it turns the
+ * clicks into events that simply happen. Enacted by `HeroMode.moveCursor()`.
+ */
+export const CURSOR_MOVE_MS = 650;
+
+/**
+ * After the interrupt panel appears, before anything moves toward Accept.
+ *
+ * The longest hold in the walkthrough, on purpose. The agent has stopped
+ * itself mid-task and is waiting on a human, and the PAUSE IS THE MESSAGE:
+ * the reader needs long enough to notice the panel, read a proposal to delete
+ * database backups, and register that nothing at all happens until someone
+ * approves it. Approving instantly says the opposite — that the gate is a
+ * formality — which is the one thing this demo must not say.
+ *
+ * CALIBRATED TO THE PROPOSAL COPY, which is currently ~60 words: four
+ * seconds is enough to skim that and take its weight, not to read it word for
+ * word. The two are coupled and nothing enforces the coupling — if the
+ * recorded `request_approval` text in `public/hero-replay.json` grows, this
+ * has to grow with it. A reader who cannot get through the proposal cannot
+ * feel what approving it means, and the beat silently stops working.
+ */
+export const INTERRUPT_DWELL_MS = 4000;
+
+/**
+ * After the resumed answer has finished streaming, before the next prompt
+ * starts. Lets the answer land instead of being shoved aside by new typing.
+ */
+export const HOLD_AFTER_ANSWER_MS = 2000;
+
+/**
+ * After the generated form has rendered, before the loop starts over. The
+ * form is the payoff of the second prompt, so it gets the longest single
+ * look — but not an unbounded one. This was 8000 when typing ate half the
+ * loop and the hold was cheap; now that the walkthrough is tight, a third of
+ * the runtime spent on a form that has stopped changing is the likeliest
+ * place for a viewer to leave BEFORE seeing a whole cycle, which is the one
+ * thing the loop exists to deliver.
+ */
+export const HOLD_AFTER_DONE_MS = 5000;
+
+/**
+ * How long a single waitFor() may poll before the run is declared failed.
+ * It guards WAITING FOR A CONDITION — an interrupt arriving, a run going
+ * quiet — not the total length of a pass, so the holds above never eat into
+ * it and lengthening them does not bring this budget any closer.
+ */
export const WAIT_TIMEOUT_MS = 30_000;
-const POLL_MS = 50;
-const SETTLE_MS = 400;
+
+/** Polling granularity inside waitFor(). Not a pacing beat. */
+export const POLL_MS = 50;
const realClock: ScriptClock = { sleep: (ms) => new Promise((r) => setTimeout(r, ms)) };
@@ -79,15 +162,22 @@ export class HeroScriptRunner {
await this.step(g, () => this.host.moveCursor('composer'));
await this.step(g, () => this.host.typeInto(HERO_PROMPTS[0]));
+ await this.step(g, () => this.clock.sleep(HOLD_AFTER_TYPING_MS));
await this.step(g, () => this.host.moveCursor('send'));
await this.step(g, () => this.host.send());
if (!(await this.waitFor(g, () => this.host.hasInterrupt()))) return;
+ // The dwell belongs HERE and nowhere else: after the panel provably
+ // exists, and before a single pixel moves toward Accept. Put it earlier
+ // and it is a pause on an empty screen; put it later and the reader
+ // watches a form being dismissed rather than a decision being made.
+ await this.step(g, () => this.clock.sleep(INTERRUPT_DWELL_MS));
await this.step(g, () => this.host.moveCursor('accept'));
await this.step(g, () => this.host.acceptInterrupt());
if (!(await this.waitFor(g, () => !this.host.isRunning() && !this.host.hasInterrupt()))) return;
- if (!this.host.reducedMotion) await this.step(g, () => this.clock.sleep(SETTLE_MS));
+ await this.step(g, () => this.clock.sleep(HOLD_AFTER_ANSWER_MS));
await this.step(g, () => this.host.moveCursor('composer'));
await this.step(g, () => this.host.typeInto(HERO_PROMPTS[1]));
+ await this.step(g, () => this.clock.sleep(HOLD_AFTER_TYPING_MS));
await this.step(g, () => this.host.moveCursor('send'));
await this.step(g, () => this.host.send());
if (!(await this.waitFor(g, () => !this.host.isRunning()))) return;