From b5b2467f69c1df5d7bef431615c1d807a01d9658 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 4 Sep 2026 13:33:10 -0700 Subject: [PATCH 1/5] =?UTF-8?q?fix(hero):=20retime=20the=20walkthrough=20?= =?UTF-8?q?=E2=80=94=20fast=20typing,=20a=20real=20pause=20on=20the=20appr?= =?UTF-8?q?oval?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The playback taught the wrong things. Typing at 40ms/char spent 12.4s of a 27.8s loop showing a prospective customer what a keyboard is, while the beat that carries the whole product claim — the agent stopping itself to ask a human before deleting database backups — got 660ms, just long enough for the cursor to glide over and click Accept. Approving that fast says the gate is a formality. Pacing is now one named model in hero-script.ts, each constant carrying the communication reason rather than the number: TYPE_DELAY_MS 40 → 9, HOLD_AFTER_TYPING_MS 1200 (new: finish reading the prompt before it is answered), INTERRUPT_DWELL_MS 4000 (new: the pause IS the message), HOLD_AFTER_ANSWER_MS 400 → 2000, CURSOR_MOVE_MS and HOLD_AFTER_DONE_MS unchanged. The component imports the two it enacts instead of keeping copies. READ_PAUSE_MS is gone. It was a reduced-motion-only stopgap for exactly the hold that is now HOLD_AFTER_TYPING_MS, and the runner no longer branches on reducedMotion at all: reduced motion removes animation inside the host, never a beat, so both audiences follow the same walkthrough at the same tempo. Measured on /hero over four consecutive loops, before → after: prompt-1 typing 7.64s → 1.82s, prompt-2 typing 4.72s → 1.12s, panel-attached to Accept-pressed 0.66s → 4.66s, full loop 27.6s → 26.4s. WAIT_TIMEOUT_MS stays at 30s: it budgets waiting for a CONDITION, not the length of a pass, and none of these holds run inside a waitFor(). Co-Authored-By: Claude Fable 5.1 --- .../src/app/hero/hero-mode.component.spec.ts | 38 +++- .../src/app/hero/hero-mode.component.ts | 31 +-- .../angular/src/app/hero/hero-script.spec.ts | 204 +++++++++++++++--- .../chat/angular/src/app/hero/hero-script.ts | 86 +++++++- 4 files changed, 308 insertions(+), 51 deletions(-) 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..5b7a90a55 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 @@ -136,7 +136,25 @@ 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 fast enough not to bore: a prompt-sized string beats the old 40ms/char crawl', 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); + + const started = performance.now(); + await fx.componentInstance.typeInto(text); + const elapsed = performance.now() - started; + + // Still character by character — this is typing, not a paste. + expect(seen).toHaveLength(50); + expect(textarea.value).toBe(text); + // 50 chars at the old TYPE_DELAY_MS of 40 took over 2s; at 9ms it is ~0.5s. + expect(elapsed).toBeLessThan(2000); + }); + + 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 +165,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..4d5340488 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,75 @@ export interface ScriptClock { export type HeroScriptState = 'idle' | 'waiting' | 'running' | 'paused' | 'done' | 'stopped' | 'error'; +/* ── 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. + */ +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 look. + */ export const HOLD_AFTER_DONE_MS = 8000; -/** How long a single waitFor() may poll before the run is declared failed. */ + +/** + * 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 +150,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; From 05a40198d3bdcf139f766022e902a7305e8ed55d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 4 Sep 2026 13:34:47 -0700 Subject: [PATCH 2/5] revert(website): restore the flat works-with ribbon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The homepage rebuild replaced the flat "Works with" recognition line with three labelled compatibility groups. Roll that back to the pre-rebuild strip: one flex line, a "Works with" label, eight named logos and a "+ 4 more" count. Deletes COMPAT_GROUPS, the three-group markup, and the group-only CSS (.logo-ribbon-heading / -lede / -groups / -group / -group-head / -note). Nothing else in the app consumed them — no e2e spec and no style contract referenced the group markup. Keeps the accuracy guard the group version introduced: the restored spec still asserts every logo is alt="" aria-hidden beside a visible name, and that no "trusted by" / "customers" wording appears. "Works with" is a compatibility claim, not a customer claim. Co-Authored-By: Claude Fable 5.1 --- .../components/landing/LogoRibbon.spec.tsx | 37 +++--- .../src/components/landing/LogoRibbon.tsx | 125 ++++++------------ apps/website/src/styles/landing.css | 42 +----- 3 files changed, 64 insertions(+), 140 deletions(-) diff --git a/apps/website/src/components/landing/LogoRibbon.spec.tsx b/apps/website/src/components/landing/LogoRibbon.spec.tsx index f74b62de0..05a230c6d 100644 --- a/apps/website/src/components/landing/LogoRibbon.spec.tsx +++ b/apps/website/src/components/landing/LogoRibbon.spec.tsx @@ -2,35 +2,34 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; -import { LogoRibbon, COMPAT_GROUPS } from './LogoRibbon'; +import { LogoRibbon, RIBBON_ITEMS, RIBBON_MORE_COUNT } from './LogoRibbon'; -describe('LogoRibbon (compatibility boundary)', () => { - it('renders three labelled groups in order', () => { +describe('LogoRibbon', () => { + it('renders eight named items and the more-count', () => { render(); - expect(COMPAT_GROUPS.map((g) => g.label)).toEqual([ - 'Direct Threadplane adapters', - 'Backends reachable through AG-UI', - 'Model providers, behind your backend', - ]); - for (const group of COMPAT_GROUPS) { - expect(screen.getByText(group.label)).toBeTruthy(); - for (const item of group.items) expect(screen.getByText(item.name)).toBeTruthy(); + expect(RIBBON_ITEMS).toHaveLength(8); + for (const item of RIBBON_ITEMS) { + expect(screen.getByText(item.name)).toBeTruthy(); } + expect(screen.getByText(`+ ${RIBBON_MORE_COUNT} more`)).toBeTruthy(); }); - it('direct adapters are exactly LangGraph and AG-UI', () => { - expect(COMPAT_GROUPS[0].items.map((i) => i.name)).toEqual(['LangGraph', 'AG-UI']); + it('is a labelled landmark with no links', () => { + const { container } = render(); + const section = container.querySelector('section'); + expect(section?.getAttribute('aria-label')).toBe('Works with your agent stack'); + expect(container.querySelectorAll('a')).toHaveLength(0); }); - it('is a labelled landmark, logos hidden from assistive tech, no customer wording', () => { + it('reads as compatibility, not customers: hidden logos, visible names, no endorsement wording', () => { const { container } = render(); - expect(container.querySelector('section')?.getAttribute('aria-label')).toBe( - 'Keep your agent stack. Standardize the Angular surface.', - ); - for (const img of Array.from(container.querySelectorAll('img'))) { + expect(screen.getByText('Works with')).toBeTruthy(); + const imgs = Array.from(container.querySelectorAll('img')); + expect(imgs).toHaveLength(RIBBON_ITEMS.length); + for (const img of imgs) { expect(img.getAttribute('aria-hidden')).toBe('true'); expect(img.getAttribute('alt')).toBe(''); } - expect(container.textContent).not.toMatch(/trusted by|customers/i); + expect(container.textContent).not.toMatch(/trusted by|customers|our clients|powered by/i); }); }); diff --git a/apps/website/src/components/landing/LogoRibbon.tsx b/apps/website/src/components/landing/LogoRibbon.tsx index 144642c65..ed9e86863 100644 --- a/apps/website/src/components/landing/LogoRibbon.tsx +++ b/apps/website/src/components/landing/LogoRibbon.tsx @@ -1,102 +1,55 @@ import { Container } from '../ui/Container'; -interface CompatItem { +interface RibbonItem { name: string; - logoSrc?: string; + logoSrc: string; } -interface CompatGroup { - label: string; - note: string; - items: readonly CompatItem[]; -} - -/** - * Compatibility boundary (spec §5). Three rows so a provider logo is never - * read as a direct adapter. The AG-UI row lists only runtimes the site already - * presents as reachable — the three with docs runtime sections in - * `docs-config.ts` (AWS Strands, Microsoft Agent Framework, Mastra) plus the - * two that ship on the /ag-ui product page's BackendsGrid. Re-verify against - * both before editing. - */ -export const COMPAT_GROUPS: readonly CompatGroup[] = [ - { - label: 'Direct Threadplane adapters', - note: '@threadplane/langgraph · @threadplane/ag-ui', - items: [ - { name: 'LangGraph', logoSrc: '/logos/langgraph.svg' }, - { name: 'AG-UI', logoSrc: '/logos/ag-ui.svg' }, - ], - }, - { - label: 'Backends reachable through AG-UI', - note: 'any AG-UI-compatible endpoint', - items: [ - { name: 'Mastra', logoSrc: '/logos/runtimes/mastra.svg' }, - { name: 'Microsoft Agent Framework', logoSrc: '/logos/runtimes/microsoft.svg' }, - { name: 'AWS Strands' }, - { name: 'Pydantic AI', logoSrc: '/logos/runtimes/pydantic.svg' }, - { name: 'CrewAI', logoSrc: '/logos/runtimes/crewai.svg' }, - ], - }, - { - label: 'Model providers, behind your backend', - note: 'model choice stays in the backend you operate', - items: [ - { name: 'OpenAI', logoSrc: '/logos/providers/openai.svg' }, - { name: 'Anthropic', logoSrc: '/logos/providers/anthropic.svg' }, - { name: 'Gemini', logoSrc: '/logos/providers/google.svg' }, - { name: 'Bedrock', logoSrc: '/logos/providers/bedrock.svg' }, - { name: 'Azure OpenAI', logoSrc: '/logos/providers/azure.svg' }, - ], - }, +export const RIBBON_ITEMS: readonly RibbonItem[] = [ + { name: 'OpenAI', logoSrc: '/logos/providers/openai.svg' }, + { name: 'Anthropic', logoSrc: '/logos/providers/anthropic.svg' }, + { name: 'Gemini', logoSrc: '/logos/providers/google.svg' }, + { name: 'Bedrock', logoSrc: '/logos/providers/bedrock.svg' }, + { name: 'LangGraph', logoSrc: '/logos/langgraph.svg' }, + { name: 'AG-UI', logoSrc: '/logos/ag-ui.svg' }, + { name: 'CrewAI', logoSrc: '/logos/runtimes/crewai.svg' }, + { name: 'Mastra', logoSrc: '/logos/runtimes/mastra.svg' }, ]; +/** Azure OpenAI, Pydantic AI, Microsoft Agent Framework, AWS Strands. */ +export const RIBBON_MORE_COUNT = 4; + /** - * The compatibility boundary. Deliberately not a Section: it reads as a - * boundary statement between the hero and the argument below it, and the three - * labelled rows carry their own hierarchy. No links, no hover states. + * The "works with" recognition line. Deliberately not a Section: no heading, + * no subhead — the portability argument lives in the proof band below; this + * line carries recognition only. No links, no hover states. + * + * "Works with" is a compatibility claim, never a customer claim: the logos are + * `alt="" aria-hidden` decoration beside the visible names, and no wording here + * may imply these companies are Threadplane users. LogoRibbon.spec.tsx guards + * that. The labelled three-group variant was tried and rolled back — see + * `git show aaf8dea1:apps/website/src/components/landing/LogoRibbon.tsx`. */ export function LogoRibbon() { return ( -
+
-

- 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/styles/landing.css b/apps/website/src/styles/landing.css index 0d915494b..9904c0552 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -1228,46 +1228,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 +1264,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 From e6cfb35a5d7891f5a32e72ce5b16b7b2c23ab2ad Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 4 Sep 2026 13:34:59 -0700 Subject: [PATCH 3/5] feat(website): highlight the boundary claim in the hero subhead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old hero marker-highlighted a key phrase; the rebuild dropped it. Bring it back on exactly one phrase — "Your backend stays where it is." — the boundary claim and the sentence a reader most needs to retain. A second highlight in a sentence this short reads as decoration and cancels the emphasis. HERO_SUBHEAD stays the single source of truth. HERO_SUBHEAD_SEGMENTS sits beside it and carries the split; positioning.spec.ts asserts the segments join back to HERO_SUBHEAD character for character and that exactly one is highlighted, so the two cannot drift. Hero.spec.tsx keeps its .hero-subhead textContent assertion and gains one on the span. CSS: the new hero is centered, and the phrase wrapped mid-claim — 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. Scoped inline-block makes the phrase wrap as one unit so it drops whole onto its own line at 1440/1024/768/390; max-width: 100% is the safety valve that keeps it wrapping inside the measure at 320px instead of overflowing. box-decoration-break: clone on the base rule is now under a style contract — without it a wrapped highlight paints one union box across the lines, which still renders, just wrongly. Co-Authored-By: Claude Fable 5.1 --- .../src/components/landing/Hero.spec.tsx | 4 ++++ apps/website/src/components/landing/Hero.tsx | 12 ++++++++++-- apps/website/src/lib/positioning.spec.ts | 12 ++++++++++++ apps/website/src/lib/positioning.ts | 17 +++++++++++++++++ apps/website/src/styles/landing.css | 10 ++++++++++ apps/website/src/styles/style-contracts.spec.ts | 9 +++++++++ 6 files changed, 62 insertions(+), 2 deletions(-) diff --git a/apps/website/src/components/landing/Hero.spec.tsx b/apps/website/src/components/landing/Hero.spec.tsx index da79d15a2..d14a5ab49 100644 --- a/apps/website/src/components/landing/Hero.spec.tsx +++ b/apps/website/src/components/landing/Hero.spec.tsx @@ -58,6 +58,10 @@ describe('Hero', () => { expect(screen.getByRole('heading', { level: 1 }).textContent).toBe(HERO_H1); expect(screen.getByText(HERO_EYEBROW)).toBeTruthy(); expect(document.querySelector('.hero-subhead')?.textContent).toBe(HERO_SUBHEAD); + expect(document.querySelector('.hero-subhead .marker-highlight')?.textContent).toBe( + 'Your backend stays where it is.', + ); + expect(document.querySelectorAll('.hero-subhead .marker-highlight')).toHaveLength(1); expect(document.querySelector('.hero-trust')?.textContent).toBe(HERO_TRUST_LINE); expect(document.querySelector('.hero-chip-row')).toBeNull(); expect(screen.queryByText(/six months/)).toBeNull(); diff --git a/apps/website/src/components/landing/Hero.tsx b/apps/website/src/components/landing/Hero.tsx index f1c1c3f5d..c87dfc03d 100644 --- a/apps/website/src/components/landing/Hero.tsx +++ b/apps/website/src/components/landing/Hero.tsx @@ -12,7 +12,7 @@ import { HERO_PRIMARY_LABEL, HERO_SECONDARY_HREF, HERO_SECONDARY_LABEL, - HERO_SUBHEAD, + HERO_SUBHEAD_SEGMENTS, HERO_TRUST_LINE, } from '../../lib/positioning'; import { HeroDemo } from './HeroDemo'; @@ -32,7 +32,15 @@ export function Hero() {
{HERO_EYEBROW}

{HERO_H1}

-

{HERO_SUBHEAD}

+

+ {HERO_SUBHEAD_SEGMENTS.map((segment) => + segment.highlight ? ( + {segment.text} + ) : ( + {segment.text} + ), + )} +

{ 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 9904c0552..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; } 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 { From e0c40d79da78385e4b70321ee19fc7d66c7e5ab4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 4 Sep 2026 13:39:25 -0700 Subject: [PATCH 4/5] fix(hero): shorten the post-form hold to 5s and record what the dwell is calibrated to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With typing retimed, HOLD_AFTER_DONE_MS was the longest beat in the loop — 8.9s of a form that has stopped changing, a third of the runtime, and the likeliest place for a viewer to leave before seeing a whole cycle. Seeing a whole cycle is the only thing the loop is for, so 8000 → 5000. Measured over four consecutive loops: form → restart 8.89s → 5.87s, full loop 26.4s → 23.4s. Every other beat is byte-identical to the previous commit — prompt-1 typing 1.82s, the 1.2s read holds, the 4.66s panel-to-Accept dwell, and the 2s post-answer hold are all untouched. Also records the coupling nobody would guess from the number alone: INTERRUPT_DWELL_MS is calibrated to the ~60-word request_approval proposal in public/hero-replay.json. Four seconds skims that copy; if the copy grows and the dwell does not, a reader can no longer get through the proposal and the beat silently stops doing its job. Co-Authored-By: Claude Fable 5.1 --- .../chat/angular/src/app/hero/hero-script.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/examples/chat/angular/src/app/hero/hero-script.ts b/examples/chat/angular/src/app/hero/hero-script.ts index 4d5340488..a3af18c44 100644 --- a/examples/chat/angular/src/app/hero/hero-script.ts +++ b/examples/chat/angular/src/app/hero/hero-script.ts @@ -82,6 +82,13 @@ export const CURSOR_MOVE_MS = 650; * 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; @@ -93,9 +100,14 @@ 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 look. + * 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 = 8000; +export const HOLD_AFTER_DONE_MS = 5000; /** * How long a single waitFor() may poll before the run is declared failed. From 2a91327b42421dbc5be9f304cf88379bc06a9c82 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 4 Sep 2026 13:49:35 -0700 Subject: [PATCH 5/5] test(hero): pin the typing cadence with fake timers, not a wall-clock budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new typing test asserted `performance.now()` elapsed under 2000ms on real timers. It flaked under load and added real seconds to the suite, which destabilised the neighbouring demo-shell router specs — those were reported as pre-existing flakes, but clean main runs 179/179 and this branch failed 2-3 tests per run. Advancing fake timers pins the same cadence deterministically. Three consecutive runs: 184/184. Co-Authored-By: Claude Opus 5 --- .../src/app/hero/hero-mode.component.spec.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) 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 5b7a90a55..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,22 +137,31 @@ describe('HeroMode', () => { expect(fx.componentInstance.mode()).toBe('live'); }); - it('types fast enough not to bore: a prompt-sized string beats the old 40ms/char crawl', 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); - const started = performance.now(); - await fx.componentInstance.typeInto(text); - const elapsed = performance.now() - started; + // 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(); + } - // Still character by character — this is typing, not a paste. expect(seen).toHaveLength(50); expect(textarea.value).toBe(text); - // 50 chars at the old TYPE_DELAY_MS of 40 took over 2s; at 9ms it is ~0.5s. - expect(elapsed).toBeLessThan(2000); }); it('reduced motion: typeInto sets the value in one tick, leaving the reading pause to the runner', async () => {