diff --git a/frontend/public/media/demo-2x.mp4 b/frontend/public/media/demo-2x.mp4
deleted file mode 100644
index 3c4e44e12d..0000000000
Binary files a/frontend/public/media/demo-2x.mp4 and /dev/null differ
diff --git a/frontend/public/media/demo-poster.jpg b/frontend/public/media/demo-poster.jpg
deleted file mode 100644
index 91b820bb23..0000000000
Binary files a/frontend/public/media/demo-poster.jpg and /dev/null differ
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 06f39319e7..023b9922cc 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -625,8 +625,8 @@
"lede": "Get real work done with a team of AI agents — each with its own memory and workstation, all in one room where they hand off instead of making you re-explain. Open source, any runtime, your infra, no per-agent fees.",
"selfHostInstall": "Self-host install",
"builtBy": "Built in the open by",
- "demoAria": "Demo: Sam and three agents spec, build, and review a signup flow together in a Commonly pod",
- "demoCaption": "Real work — Sam and three agents spec a signup flow, open the PR, and review it together.",
+ "demoAria": "Interactive demo: a sample workspace with four pods, agents working, and a decision card you can rule on",
+ "demoCaption": "This is the product, not a video. Pick an option on the card, or ask an agent something.",
"terms": {
"claudeCode": "Claude Code",
"cursor": "Cursor",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 31a30f4af9..8a2ed2fb7d 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -624,8 +624,8 @@
"lede": "和一支 AI 团队一起把正事办成——每个 agent 都有自己的记忆和工位,在同一个群里互相交接,不用你一遍遍重复解释。开源、支持任意运行时、部署在你自己的基础设施上,绝不按 agent 收费。",
"selfHostInstall": "自托管安装命令",
"builtBy": "公开构建者",
- "demoAria": "演示:Sam 与三个智能体在 Commonly 的 Pod 中共同设计、开发并评审注册流程",
- "demoCaption": "真实工作——Sam 与三个智能体共同设计注册流程、创建 PR 并完成评审。",
+ "demoAria": "交互演示:一个示例工作区,有四个 Pod、正在工作的 agent,还有一张你可以直接裁决的决策卡",
+ "demoCaption": "这就是产品本身,不是视频。在卡片上选一项,或者直接问 agent。",
"terms": {
"claudeCode": "Claude Code",
"cursor": "Cursor",
diff --git a/frontend/src/v2/__tests__/V2LandingPage.stats.test.tsx b/frontend/src/v2/__tests__/V2LandingPage.stats.test.tsx
index ec6339f820..c588e9b581 100644
--- a/frontend/src/v2/__tests__/V2LandingPage.stats.test.tsx
+++ b/frontend/src/v2/__tests__/V2LandingPage.stats.test.tsx
@@ -20,17 +20,6 @@ const renderLanding = () => render(
);
describe('V2LandingPage public proof', () => {
- let playSpy: jest.SpyInstance;
-
- beforeAll(() => {
- playSpy = jest.spyOn(window.HTMLMediaElement.prototype, 'play')
- .mockResolvedValue(undefined);
- });
-
- afterAll(() => {
- playSpy.mockRestore();
- });
-
beforeEach(() => {
jest.clearAllMocks();
mockUseAuth.mockReturnValue({ isAuthenticated: false });
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index 8357d080c5..34668c7e98 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -2120,3 +2120,138 @@ describe('v2 layout invariants (CSS rule presence)', () => {
});
});
+
+// The landing hero demo (TASK-147) is the product's own four-column workspace
+// shrunk into a landing page, so its two failure modes are the ones jsdom
+// cannot see: content clipped by the stage's fixed 720px height once the
+// columns stack, and a transcript that collapses to nothing when the stage
+// stops being a fixed-height grid. Both were reasoned about from the CSS and
+// neither can be reproduced in a render test.
+describe('the landing hero demo (TASK-147)', () => {
+ const demo = read('../landing/demo-workspace.css');
+ const landing = read('../landing/v2-landing.css');
+ const landingPage = read('../landing/V2LandingPage.tsx');
+ const component = read('../landing/DemoWorkspace.tsx');
+
+ // demo-workspace.css carries exactly one phone block (v2.css carries many,
+ // which is why it needs a different helper there).
+ // Match an at-rule by its FULL text, never by a prefix: `@media (max-width:
+ // 760px)` is a prefix of the 641-760 query beside it, so a prefix match reads
+ // whichever the file happens to put first and the guard quietly asserts on the
+ // wrong block.
+ const mediaAt = (css: string, atRule: string): string => {
+ const at = css.indexOf(atRule);
+ if (at < 0) return '';
+ const open = css.indexOf('{', at);
+ let depth = 0;
+ let end = open;
+ for (let i = open; i < css.length; i += 1) {
+ if (css[i] === '{') depth += 1;
+ if (css[i] === '}') { depth -= 1; if (depth === 0) { end = i; break; } }
+ }
+ return css.slice(at, end + 1);
+ };
+
+ const phoneBlock = (css: string): string => mediaAt(css, '@media (max-width: 760px) {');
+
+ test('the demo is the workspace at its own canvas, not a smaller invention', () => {
+ expect(ruleBody(demo, '.v2-demo')).toContain('max-width: 1312px');
+ expect(ruleBody(demo, '.v2-demo__stage')).toContain('grid-template-columns: 56px 212px minmax(0, 1fr) 272px');
+ expect(ruleBody(demo, '.v2-demo__stage')).toContain('height: 720px');
+ expect(ruleBody(demo, '.v2-demo__stage')).toContain('background: var(--v2-page-bg)');
+ // The demo is the product's page, so it sits on the app's canvas colour.
+ expect(cssVariable(read('../v2.css'), '--v2-page-bg')).toBe('#eef0f4');
+ });
+
+ test('a phone stacks the columns and lets the stage grow instead of clipping them', () => {
+ const phone = phoneBlock(demo);
+ expect(phone).toContain('.v2-demo__stage');
+ expect(phone).toMatch(/\.v2-demo__stage \{[^}]*grid-template-columns: minmax\(0, 1fr\);/);
+ // `height: auto` is the load-bearing half: .v2-demo has overflow:hidden, so
+ // a stage that kept 720px while the columns stacked would cut off whatever
+ // did not fit — silently, and only on a phone.
+ expect(phone).toMatch(/\.v2-demo__stage \{[^}]*height: auto;/);
+ });
+
+ test('the transcript keeps a floor on a phone so the thread is never empty', () => {
+ const phone = phoneBlock(demo);
+ // .v2-demo__log is flex:1 / min-height:0, which needs a sized parent; on a
+ // phone the stage is auto-height, so without this floor the hero's thread
+ // renders as a blank strip.
+ expect(phone).toMatch(/\.v2-demo__log \{[^}]*min-height: 300px;/);
+ });
+
+ test('the active pod row keeps its tint on a phone, where a later rule would clear it', () => {
+ const phone = phoneBlock(demo);
+ // ux-lead's 390 gate (#1855, at 62ceda24) measured the strip painting the
+ // open pod like its neighbours. The cause is order, not a missing rule: the
+ // phone row rule below has the same specificity as the desktop --active rule
+ // and comes later, so its surface colour wins and the rail loses its only
+ // position cue at exactly the width where the strip IS the navigation.
+ expect(phone).toMatch(/\.v2-root button\.v2-demo__pod \{[^}]*background: var\(--v2-surface\);/);
+ expect(phone).toMatch(/\.v2-root button\.v2-demo__pod--active \{[^}]*background: var\(--v2-accent-soft\);/);
+ // Order IS the defect, so order is what this guard has to see. The two rules
+ // are the same specificity (0,2,1), so the tint survives only because
+ // --active sits later in the block: move it above the row rule with every
+ // declaration unchanged and the active pod repaints white. sprint-review
+ // measured exactly that in a browser at 390 — 86 parsed rules either way, so
+ // it is the cascade, not a parse break — and this suite stayed green until
+ // this pair. (Textual presence alone cannot see it.)
+ const rowAt = phone.indexOf('.v2-root button.v2-demo__pod {');
+ const activeAt = phone.indexOf('.v2-root button.v2-demo__pod--active {');
+ expect(rowAt).toBeGreaterThanOrEqual(0);
+ expect(activeAt).toBeGreaterThan(rowAt);
+ // And hover cannot wipe it either — hover outranks the active rule, which is
+ // the opposite of the product, where .v2-pods__item:hover is weaker than
+ // .v2-root button.v2-pods__item--active.
+ expect(demo).toMatch(/\.v2-root button\.v2-demo__pod--active:hover \{[^}]*background: var\(--v2-accent-soft\);/);
+ });
+
+ test('the inspector is one column at 390 and two only from 641 up', () => {
+ // At 390 the two columns are ~156px against a board row that needs ~230, so
+ // every row title truncated to an ellipsis with nothing left to read
+ // (ux-lead, #1855 gate at 62ceda24).
+ const twoUp = mediaAt(demo, '@media (max-width: 760px) and (min-width: 641px)');
+ expect(twoUp).toMatch(/\.v2-demo__inspector \{[^}]*grid-template-columns: minmax\(0, 1fr\) minmax\(0, 1fr\);/);
+ expect(twoUp).toMatch(/\.v2-demo__needs \{[^}]*grid-column: 1 \/ -1;/);
+ // The phone block itself must not turn the inspector back into a grid: it
+ // covers widths the 640 rule below also covers, and it comes first, so the
+ // two-column layout would win at 390 as surely as it did before the fix.
+ expect(phoneBlock(demo)).not.toMatch(/\.v2-demo__inspector \{[^}]*grid-template-columns/);
+ // Below 641 it is the base flex column again, with needs hoisted to the top.
+ expect(ruleBody(demo, '.v2-demo__inspector')).toContain('flex-direction: column');
+ const oneUp = mediaAt(demo, '@media (max-width: 640px) {');
+ expect(oneUp).toMatch(/\.v2-demo__needs \{ order: -1; \}/);
+ });
+
+ test('the hero is the product, not the video it replaced', () => {
+ expect(landingPage).toContain('');
+ expect(landingPage).not.toContain('/media/demo-2x.mp4');
+ expect(landingPage).not.toContain('demoVideoRef');
+ // The demo explains its own honesty limit in its chrome, and the component
+ // renders that line unconditionally (the phone layout keeps it wrapping on
+ // its own row rather than letting it scroll out of the pod strip).
+ expect(component).toContain('sample workspace · replies are scripted');
+ expect(phoneBlock(read('../landing/demo-workspace.css'))).toMatch(
+ /\.v2-demo__sample \{[^}]*flex: 1 1 100%;/,
+ );
+ });
+
+ test('the active pod row follows the component, not the board that drew it filled', () => {
+ // ux-lead's carry note on #1841: the design board paints the active pod row
+ // cobalt-filled, while the live rail uses a tint plus a 3px accent mark.
+ // The build follows the component — a filled row here would be the landing
+ // showing a product that does not exist.
+ expect(ruleBody(demo, '.v2-root button.v2-demo__pod--active')).toContain('background: var(--v2-accent-soft)');
+ expect(ruleBody(demo, '.v2-root button.v2-demo__pod--active')).not.toContain('background: var(--v2-accent);');
+ expect(ruleBody(demo, '.v2-root button.v2-demo__pod--active::before')).toContain('width: 3px');
+ expect(ruleBody(demo, '.v2-root button.v2-demo__pod--active::before')).toContain('background: var(--v2-accent);');
+ });
+
+ test('the hero demo keeps the letterboxed screenshot chrome out of its card', () => {
+ // The feature rows still use the framed-screenshot chrome with window dots;
+ // the live demo is not a screenshot, so its card has no dots bar.
+ expect(ruleBody(landing, '.v2-landing__shot-bar')).toContain('height: 32px');
+ expect(landingPage.match(/v2-landing__shot-bar/g) ?? []).toHaveLength(1);
+ });
+});
diff --git a/frontend/src/v2/landing/DemoWorkspace.tsx b/frontend/src/v2/landing/DemoWorkspace.tsx
new file mode 100644
index 0000000000..461a7368a4
--- /dev/null
+++ b/frontend/src/v2/landing/DemoWorkspace.tsx
@@ -0,0 +1,367 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { characterAvatarFor } from '../utils/avatars';
+import {
+ FACE_NAMES,
+ FIXTURE_PODS,
+ GENERIC_REPLY,
+ isHumanFace,
+ type FaceKey,
+ type FixtureAsk,
+ type FixtureMessage,
+ type FixtureOption,
+ type FixturePod,
+ type PodKey,
+ type ScriptedStep,
+} from './demoFixture';
+import './demo-workspace.css';
+
+/**
+ * The landing hero demo (TASK-147, spec `docs/design/landing-demo/Demo.dc.html`
+ * via #1841). An interactive fake of the workspace: four pods, a decision card
+ * you can rule on, suggested prompts with timed replies, and free text. No
+ * backend — every reply is scripted and delayed locally, and the demo says so
+ * in its own footer.
+ *
+ * Two rules from the spec's README shape this file:
+ *
+ * 1. **Build from the real components, not the board's markup.** The decision
+ * card and the transcript rows below render the product's own class names
+ * (`.v2-decision-card*`, `.v2-msg*`), and faces come from the real kit via
+ * `characterAvatarFor`. What the demo cannot borrow is the behaviour: every
+ * real in-app component on this surface is wired to a pod id, a socket and
+ * an API, and a marketing page has none of those. So the *shell* is
+ * demo-scoped (`.v2-demo*`) and the *pieces that carry identity* are the
+ * product's.
+ * 2. **Sample values only.** All copy in the demo comes from the fixture and
+ * is deliberately English-only, exactly as a screenshot in the README would
+ * be; the page's own copy stays translated.
+ */
+
+const START_POD: PodKey = 'launch';
+
+/** Faces are deterministic kit output, so build them once at module load. */
+const FACES: Record = (Object.keys(FACE_NAMES) as FaceKey[])
+ .reduce((acc, key) => {
+ acc[key] = characterAvatarFor(`demo:${key}`, isHumanFace(key) ? 'human' : 'agent');
+ return acc;
+ }, {} as Record);
+
+const Face: React.FC<{ faceKey?: FaceKey; initial?: string; className?: string }> = ({
+ faceKey, initial, className = 'v2-demo__face',
+}) => {
+ const src = faceKey ? FACES[faceKey] : null;
+ if (src) return ;
+ return {initial || ''};
+};
+
+interface Typing { pod: PodKey; faceKey: FaceKey }
+
+const DemoWorkspace: React.FC = () => {
+ const [pod, setPod] = useState(START_POD);
+ const [decided, setDecided] = useState>>({});
+ const [extra, setExtra] = useState>(
+ () => FIXTURE_PODS.reduce((acc, p) => { acc[p.key] = []; return acc; }, {} as Record),
+ );
+ const [used, setUsed] = useState>({});
+ const [typing, setTyping] = useState(null);
+ const [draft, setDraft] = useState('');
+
+ // Timestamps march forward from 09:31 the way the spec's `stamp()` does, so
+ // a ruling is visibly later than the message it answers. A ref, not state:
+ // it is read inside timers and never rendered on its own.
+ const clock = useRef(0);
+ const timers = useRef[]>([]);
+ const stamp = () => {
+ clock.current += 1;
+ const minutes = 9 * 60 + 31 + clock.current;
+ return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
+ };
+
+ // A demo that keeps scheduling after the visitor has left the page is a
+ // leak and, on a landing, an unmounted-setState warning in every consumer
+ // that renders it in a test. Clear everything on unmount.
+ useEffect(() => () => { timers.current.forEach(clearTimeout); timers.current = []; }, []);
+
+ const later = useCallback((ms: number, fn: () => void) => {
+ timers.current.push(setTimeout(fn, ms));
+ }, []);
+
+ const say = useCallback((target: PodKey, message: FixtureMessage) => {
+ setTyping((current) => (current && current.pod === target ? null : current));
+ setExtra((current) => ({ ...current, [target]: [...current[target], message] }));
+ }, []);
+
+ const agentSays = useCallback((target: PodKey, step: ScriptedStep) => {
+ later(Math.max(0, step.delay - 900), () => {
+ setTyping({ pod: target, faceKey: step.faceKey });
+ });
+ later(step.delay, () => {
+ say(target, {
+ who: FACE_NAMES[step.faceKey],
+ faceKey: step.faceKey,
+ meta: stamp(),
+ text: step.text,
+ sub: step.sub,
+ });
+ });
+ }, [later, say]);
+
+ const send = useCallback((target: PodKey, raw: string, steps?: ScriptedStep[]) => {
+ const text = raw.trim();
+ if (!text) return;
+ say(target, { who: 'Sam', faceKey: 'sam', meta: stamp(), text });
+ const chain = steps || [{ faceKey: FIXTURE_PODS.find((p) => p.key === target)!.lead, text: GENERIC_REPLY, delay: 1500 }];
+ chain.forEach((step) => agentSays(target, step));
+ }, [agentSays, say]);
+
+ const rule = useCallback((target: PodKey, ask: FixtureAsk, option: FixtureOption) => {
+ setDecided((current) => ({ ...current, [target]: option.id }));
+ say(target, { who: 'Sam', faceKey: 'sam', meta: stamp(), text: option.rule });
+ agentSays(target, { faceKey: ask.faceKey, text: option.reply, delay: 1200 });
+ }, [agentSays, say]);
+
+ const activePod = useMemo(() => FIXTURE_PODS.find((p) => p.key === pod) as FixturePod, [pod]);
+ const ruled = Boolean(decided[pod]);
+ const pendingAsk = activePod.ask && !ruled ? activePod.ask : null;
+
+ const statusOf = (agent: FixturePod['agents'][number]) => ({
+ status: ruled && agent.ruledStatus ? agent.ruledStatus : agent.status,
+ working: ruled && agent.ruledWorking !== undefined ? agent.ruledWorking : Boolean(agent.working),
+ waiting: ruled && agent.ruledWaiting !== undefined ? agent.ruledWaiting : Boolean(agent.waiting),
+ });
+ const workingCount = activePod.agents.filter((a) => statusOf(a).working).length;
+ const workingLabel = workingCount
+ ? `● ${workingCount} ${workingCount === 1 ? 'agent working' : 'agents working'}`
+ : 'no one working';
+
+ const messages = [...activePod.base, ...extra[pod]];
+ const chips = activePod.chips.filter((c) => !used[c.id]);
+
+ const selectPod = (key: PodKey) => { setPod(key); setDraft(''); };
+
+ return (
+
+
+ {/* rail — decorative chrome, so it stays out of the a11y tree */}
+
+ C
+
+
+
+
+
+
+
+
+
+
+
+
+
{activePod.name}
+ {activePod.sub}
+
+
+ {workingLabel}
+ {activePod.channel}
+
+
+
+
+
+ {messages.map((m, i) => (
+
+
+
+
+ {m.who}
+ {m.meta}
+
+
{m.text}
+ {m.sub &&
{m.sub}
}
+
+
+ ))}
+
+ {pendingAsk && (
+ // The product's own card classes: this is the same card an
+ // agent's DecisionRequest renders in the app, with a scripted
+ // outcome. "Other…" is a canned ruling here rather than an
+ // input, because the demo has nothing to send it to.
+
+
+
+
+
+ {FACE_NAMES[typing.faceKey]} is working
+
+
+ )}
+
+
+
+
+ {chips.length > 0 && (
+
+ try
+ {chips.map((chip) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ );
+};
+
+export default DemoWorkspace;
diff --git a/frontend/src/v2/landing/V2LandingPage.tsx b/frontend/src/v2/landing/V2LandingPage.tsx
index e3e551b420..e9867519ab 100644
--- a/frontend/src/v2/landing/V2LandingPage.tsx
+++ b/frontend/src/v2/landing/V2LandingPage.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import axios from 'axios';
@@ -12,8 +12,10 @@ import DnsOutlinedIcon from '@mui/icons-material/DnsOutlined';
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
import PublicOutlinedIcon from '@mui/icons-material/PublicOutlined';
import V2LangSwitch from '../components/V2LangSwitch';
+import DemoWorkspace from './DemoWorkspace';
import '../v2.css';
import './v2-landing.css';
+import './demo-workspace.css';
import yourTeamImg from '../../assets/landing/your-team.png';
import realEngineeringImg from '../../assets/landing/real-engineering.png';
@@ -186,16 +188,6 @@ const V2LandingPage: React.FC = () => {
// hasn't asked for reduced motion — so no-JS, old browsers, and
// reduced-motion users always get fully visible content.
const [motion, setMotion] = useState(false);
- // Hero demo video. Autoplay is driven imperatively, NOT via the autoPlay
- // prop: React never renders the `muted` attribute into the DOM
- // (facebook/react#10389), and iOS Safari refuses autoplay for any video
- // it doesn't see as muted — so the prop-only version silently showed the
- // poster on iPhones (2026-07-03 field report). Setting muted via the ref
- // and calling play() explicitly satisfies the mobile autoplay policy;
- // the rejection catch keeps the poster for Low Power Mode / data-saver
- // visitors, which is the correct fallback anyway.
- const demoVideoRef = useRef(null);
-
// Primary CTA: signed-in → the shell; signed-out → /v2/register. Since
// registration opened (2026-07-03: invite codes gate cloud agents, not
// signup) the label is "Get started", not "Request access" — the old copy
@@ -248,15 +240,6 @@ const V2LandingPage: React.FC = () => {
return () => io.disconnect();
}, [motion, stats]);
- useEffect(() => {
- const v = demoVideoRef.current;
- if (!v || !motion) return;
- v.muted = true;
- v.defaultMuted = true;
- const p = v.play();
- if (p && typeof p.catch === 'function') p.catch(() => { /* poster stays */ });
- }, [motion]);
-
const hasStats = Boolean(stats && (
stats.activePods
|| stats.messageCount24h
@@ -323,28 +306,20 @@ const V2LandingPage: React.FC = () => {
- {/* Real product demo in the framed-screenshot chrome. 2x-speed
- muted loop (3.7MB H.264, /public so it stays out of the JS
- bundle). Autoplay rides the same `motion` gate as every other
- animation — reduced-motion / no-JS visitors get the poster. */}
+ {/* The product, not a video (Sam approved 2026-09-23). The demo is
+ an interactive fake of the workspace built from the real v2
+ components with fixture data and scripted replies — no backend,
+ and it says so inside itself and again in the caption. There is
+ no autoplay gate to reason about any more: nothing plays until
+ the visitor acts, so reduced-motion visitors get the same
+ surface as everyone else. */}
-
-
-
-
-
-
-
+
+
{t('landing.hero.demoCaption')}
diff --git a/frontend/src/v2/landing/__tests__/DemoWorkspace.test.tsx b/frontend/src/v2/landing/__tests__/DemoWorkspace.test.tsx
new file mode 100644
index 0000000000..5a1d03c7b3
--- /dev/null
+++ b/frontend/src/v2/landing/__tests__/DemoWorkspace.test.tsx
@@ -0,0 +1,220 @@
+import React from 'react';
+import { act, fireEvent, render, screen, within } from '@testing-library/react';
+import DemoWorkspace from '../DemoWorkspace';
+
+/**
+ * The landing hero demo (TASK-147). These pin behaviour, not markup: the
+ * scripted replies arrive on a timer, the decision card settles into the
+ * product's ruled state, and each pod keeps its own history.
+ */
+
+const advance = (ms: number) => act(() => { jest.advanceTimersByTime(ms); });
+
+const message = (text: string) => screen.getByText(text);
+
+// A pod can carry the needs-you count, so its accessible name is "Launch" or
+// "Launch1" depending on state. Matching either is deliberate: several tests
+// below use that difference.
+const podButton = (name: string) => screen.getByRole('button', { name: new RegExp(`^${name}\\s*\\d*$`) });
+
+describe('DemoWorkspace', () => {
+ beforeEach(() => { jest.useFakeTimers(); });
+ afterEach(() => { jest.useRealTimers(); });
+
+ it('opens on the Launch pod with a thread, a decision to rule, and the scripted disclosure', () => {
+ const { container } = render();
+
+ // The demo announces itself as a sample in its own chrome. This line is
+ // the one honesty requirement of the surface, so it is asserted here and
+ // guarded for every layout in v2-layout-invariants.
+ expect(message('sample workspace · replies are scripted')).toBeInTheDocument();
+ expect(podButton('Launch')).toHaveAttribute('aria-current', 'true');
+
+ // The thread, its header state and the two agents it names.
+ expect(screen.getByRole('log')).toBeInTheDocument();
+ expect(message('@wren the signup fix is green. Merge it, then tell me what broke in last night’s deploy.')).toBeInTheDocument();
+ expect(message('#212 merged · deployed 09:21')).toBeInTheDocument();
+ expect(message('● 1 agent working')).toBeInTheDocument();
+
+ // The decision card, drawn with the product's own card classes.
+ expect(container.querySelector('.v2-decision-card')).not.toBeNull();
+ expect(message('Wren needs you')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Email them now' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Wait for Friday' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Other…' })).toBeInTheDocument();
+ // The first option is the product's primary choice (filled), and the card
+ // says which agent is asking — both are the real card's shape, which is
+ // the whole reason the demo borrows its classes instead of drawing one.
+ expect(screen.getByRole('button', { name: 'Email them now' }))
+ .toHaveClass('v2-decision-card__choice--primary');
+ expect(screen.getByRole('button', { name: 'Wait for Friday' }))
+ .not.toHaveClass('v2-decision-card__choice--primary');
+
+ // Inspector: agents, needs-you, board, channel.
+ expect(message('agents in launch')).toBeInTheDocument();
+ expect(message('TASK-32 Note to users')).toBeInTheDocument();
+ expect(message('Telegram · Launch')).toBeInTheDocument();
+ });
+
+ it('rules the decision: Sam’s line, then the agent’s reply, and the pod settles', () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Email them now' }));
+
+ // Sam's ruling is immediate; the agent only starts working afterwards.
+ expect(message('Email them now.')).toBeInTheDocument();
+ expect(screen.queryByText('Wren is working')).not.toBeInTheDocument();
+
+ advance(300);
+ expect(screen.getByText('Wren is working')).toBeInTheDocument();
+
+ advance(900);
+ expect(screen.queryByText('Wren is working')).not.toBeInTheDocument();
+ expect(message('Sending from support@ now: one line and a link to the fix. I’ll post the open rate here tomorrow.')).toBeInTheDocument();
+
+ // The card is spent, and the pod's "needs you" is answered in all three
+ // places it was claimed: the inspector, the board row and the pod count.
+ expect(screen.queryByRole('button', { name: 'Email them now' })).not.toBeInTheDocument();
+ expect(message('Nothing. Your agents are working.')).toBeInTheDocument();
+ expect(message('wren · wip')).toBeInTheDocument();
+ expect(screen.queryByText('needs you', { selector: '.v2-demo__board-state' })).not.toBeInTheDocument();
+ expect(message('working · note to users')).toBeInTheDocument();
+ expect(podButton('Launch')).not.toHaveTextContent(/1/);
+ });
+
+ it('sends a suggested prompt as Sam and runs its whole scripted chain', () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: '@kai is the retry fix up?' }));
+
+ // The prompt is now a message from Sam, and the chip is used up.
+ expect(message('@kai is the retry fix up?')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: '@kai is the retry fix up?' })).not.toBeInTheDocument();
+
+ advance(500);
+ expect(screen.getByText('Kai is working')).toBeInTheDocument();
+ advance(900);
+ expect(message('PR #214 is up with a test. Vera is reviewing it now.')).toBeInTheDocument();
+ expect(message('#214 open · review requested')).toBeInTheDocument();
+
+ // Second link in the chain: a different agent, later.
+ advance(1000);
+ expect(screen.getByText('Vera is working')).toBeInTheDocument();
+ advance(900);
+ expect(message('Reviewed #214. One nit, fixed in place. Approved.')).toBeInTheDocument();
+ });
+
+ it('answers free text from the pod’s lead agent, and ignores an empty send', () => {
+ const { container } = render();
+
+ const input = screen.getByLabelText('Message Launch');
+ fireEvent.change(input, { target: { value: 'what should I look at first?' } });
+ fireEvent.submit(input.closest('form') as HTMLFormElement);
+
+ expect(message('what should I look at first?')).toBeInTheDocument();
+ advance(1500);
+ expect(message('Noted. I’ll pick it up after the task I’m on and post here when it’s done.')).toBeInTheDocument();
+
+ // An empty or whitespace-only send adds nothing: no blank row, no reply.
+ const rows = () => container.querySelectorAll('.v2-demo__msg').length;
+ const before = rows();
+ fireEvent.change(input, { target: { value: ' ' } });
+ fireEvent.submit(input.closest('form') as HTMLFormElement);
+ advance(2000);
+ expect(rows()).toBe(before);
+ });
+
+ it('keeps each pod’s own history and decision when you switch between them', () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Email them now' }));
+ advance(1200);
+
+ fireEvent.click(podButton('Support'));
+ // Support has its own ask, its own agents and its own Slack channel.
+ expect(message('Dana wants a CSV of the Launch pod. It has two private threads. Leave them out, or export everything?')).toBeInTheDocument();
+ expect(message('Slack · #help')).toBeInTheDocument();
+ expect(screen.queryByText('Sending from support@ now: one line and a link to the fix. I’ll post the open rate here tomorrow.')).not.toBeInTheDocument();
+ // The count is per pod, not a global flag: Launch's ask is ruled and its
+ // badge is gone, while Support's own ask is still open and still counted.
+ expect(podButton('Launch')).not.toHaveTextContent('1');
+ expect(podButton('Support')).toHaveTextContent('1');
+
+ fireEvent.click(podButton('Launch'));
+ // Coming back: the ruling and its reply are still there, the card is not.
+ expect(message('Sending from support@ now: one line and a link to the fix. I’ll post the open rate here tomorrow.')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Email them now' })).not.toBeInTheDocument();
+ expect(podButton('Launch')).toHaveAttribute('aria-current', 'true');
+ // The draft belongs to the pod you were typing in, not the one you return to.
+ expect(screen.getByLabelText('Message Launch')).toHaveValue('');
+ });
+
+ it('rules one pod, then a second, and neither ruling disturbs the other', () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Email them now' }));
+ advance(1200);
+
+ fireEvent.click(podButton('Support'));
+ fireEvent.click(screen.getByRole('button', { name: 'Export everything' }));
+ advance(1200);
+
+ // Two rulings have to stand at once. If the ruled state is a single slot
+ // rather than per pod, ruling Support throws Launch away — and a visitor
+ // watches their own first ruling un-rule itself when they come back.
+ expect(screen.queryByRole('button', { name: 'Export everything' })).not.toBeInTheDocument();
+ expect(message('Exported all of it and sent Dana the link in Slack. The link expires in 7 days.')).toBeInTheDocument();
+ expect(podButton('Launch')).not.toHaveTextContent('1');
+ expect(podButton('Support')).not.toHaveTextContent('1');
+
+ // Support's reply was ruled in Support, so it lives there. A reply
+ // addressed to whichever pod is open — or to the pod you started on —
+ // lands somewhere it does not belong, and this is where that shows.
+ fireEvent.click(podButton('Launch'));
+ expect(message('Sending from support@ now: one line and a link to the fix. I\u2019ll post the open rate here tomorrow.')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Email them now' })).not.toBeInTheDocument();
+ expect(screen.queryByText('Exported all of it and sent Dana the link in Slack. The link expires in 7 days.')).not.toBeInTheDocument();
+
+ // Back in Support: its ruling and its reply survived the round trip.
+ fireEvent.click(podButton('Support'));
+ expect(screen.queryByRole('button', { name: 'Export everything' })).not.toBeInTheDocument();
+ expect(message('Exported all of it and sent Dana the link in Slack. The link expires in 7 days.')).toBeInTheDocument();
+ });
+
+ it('drops a queued reply when the visitor leaves the page', () => {
+ const { unmount } = render();
+ fireEvent.click(screen.getByRole('button', { name: 'Email them now' }));
+ expect(jest.getTimerCount()).toBeGreaterThan(0);
+
+ unmount();
+
+ // Every scripted timer is cleared on unmount: nothing fires into a dead
+ // tree, which on a landing page is a leak rather than a warning.
+ expect(jest.getTimerCount()).toBe(0);
+ });
+
+ it('draws faces from the real kit, never from the design canvas', () => {
+ const { container } = render();
+ const faces = Array.from(container.querySelectorAll('img.v2-demo__face'));
+
+ // The board's /_blob/… URLs do not resolve outside the canvas; the kit's
+ // data URIs do. A single canvas URL here is a broken face on the landing.
+ expect(faces.length).toBeGreaterThan(0);
+ expect(faces.every((face) => face.getAttribute('src')?.startsWith('data:image/svg+xml'))).toBe(true);
+
+ // Dana has no face in the fixture — she is a customer, not a member, and
+ // the spec draws her with an initial. That path has to exist too.
+ fireEvent.click(podButton('Support'));
+ expect(container.querySelectorAll('.v2-demo__face--initial').length).toBe(2);
+ });
+
+ it('names the pod switcher for assistive tech and marks the open pod', () => {
+ render();
+ const strip = screen.getByRole('navigation', { name: 'Sample pods' });
+
+ // Four pods, and exactly one is current.
+ const pods = within(strip).getAllByRole('button');
+ expect(pods.map((p) => p.textContent)).toEqual(['Launch1', 'Support1', 'Website', 'Growth']);
+ expect(pods.filter((p) => p.getAttribute('aria-current') === 'true')).toHaveLength(1);
+ });
+});
diff --git a/frontend/src/v2/landing/demo-workspace.css b/frontend/src/v2/landing/demo-workspace.css
new file mode 100644
index 0000000000..1498388284
--- /dev/null
+++ b/frontend/src/v2/landing/demo-workspace.css
@@ -0,0 +1,641 @@
+/* Landing hero demo (TASK-147). Spec: docs/design/landing-demo/Demo.dc.html.
+ *
+ * The stage is the spec's 1312 x 720 canvas: a 56px rail, a 212px pod column,
+ * a flexible thread and a 272px inspector, on the app's own page background
+ * (--v2-page-bg is #eef0f4, the canvas the board is drawn on). Everything
+ * inside it uses v2 tokens — this is a marketing surface showing the product,
+ * so a stray hex here reads as the product being a different colour than it is.
+ *
+ * The four columns are fixed-or-flexible rather than scaled, so the demo is
+ * the real thing at 1312 and degrades by shrinking its thread, the way the app
+ * does. Below 760px it becomes a phone layout: pods as a wrapping strip, the
+ * thread, then the inspector's panels as a two-column block. The disclosure
+ * line ("replies are scripted") stays visible in every layout — it is the
+ * demo's one honesty requirement, so it is never the item that scrolls away.
+ */
+
+.v2-demo {
+ width: 100%;
+ max-width: 1312px;
+ margin: 0 auto;
+ border-radius: var(--v2-radius);
+ overflow: hidden;
+ background: var(--v2-page-bg);
+}
+
+.v2-demo__stage {
+ box-sizing: border-box;
+ display: grid;
+ grid-template-columns: 56px 212px minmax(0, 1fr) 272px;
+ gap: 12px;
+ height: 720px;
+ padding: 14px;
+ background: var(--v2-page-bg);
+ color: var(--v2-text-primary);
+ font-family: var(--v2-font);
+ font-size: var(--v2-fs-body);
+ line-height: var(--v2-lh-body);
+}
+
+.v2-demo__stage > * { min-width: 0; }
+
+/* ---------- Labels, faces, shared bits ---------- */
+
+.v2-demo__label {
+ font-family: var(--v2-font-mono);
+ font-size: var(--v2-fs-label);
+ line-height: var(--v2-lh-label);
+ color: var(--v2-text-tertiary);
+}
+
+.v2-demo__face {
+ width: 28px;
+ height: 28px;
+ border-radius: var(--v2-radius-sm);
+ flex: 0 0 auto;
+ display: block;
+}
+
+.v2-demo__face--initial {
+ display: grid;
+ place-items: center;
+ background: var(--v2-border-soft);
+ color: var(--v2-text-secondary);
+ font-family: var(--v2-font-mono);
+ font-size: 12px;
+}
+
+.v2-demo__pulse { animation: v2-demo-pulse 1.6s ease-in-out infinite; }
+@keyframes v2-demo-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.35; }
+}
+
+/* The demo animates on its own; a reduced-motion visitor gets the same
+ information (the row is still there, still says it is working). */
+@media (prefers-reduced-motion: reduce) {
+ .v2-demo__pulse { animation: none; }
+}
+
+.v2-demo__muted { color: var(--v2-text-tertiary); }
+
+/* ---------- Rail ---------- */
+
+.v2-demo__rail {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ padding-top: 4px;
+}
+
+.v2-demo__mark {
+ width: 32px;
+ height: 32px;
+ display: grid;
+ place-items: center;
+ border-radius: var(--v2-radius-sm);
+ background: var(--v2-accent);
+ color: #ffffff;
+ font-family: var(--v2-font-display);
+ font-weight: 800;
+ font-size: 18px;
+}
+
+.v2-demo__rail-slot {
+ width: 32px;
+ height: 32px;
+ border-radius: var(--v2-radius-sm);
+ background: var(--v2-border);
+}
+
+.v2-demo__rail-slot--on { background: var(--v2-ink); }
+
+.v2-demo__rail-me { margin-top: auto; }
+
+/* ---------- Pod column ---------- */
+
+.v2-demo__pods {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 4px 0;
+ min-width: 0;
+}
+
+.v2-demo__group {
+ padding: 8px 12px 2px;
+ font-family: var(--v2-font-mono);
+ font-size: var(--v2-fs-label);
+ color: var(--v2-text-placeholder);
+}
+
+/* Specificity-matched against the global button reset (0,0,2,1), same trap
+ v2.css documents for .v2-pods__item. */
+.v2-root button.v2-demo__pod {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ width: 100%;
+ padding: 9px 12px;
+ border: 0;
+ border-radius: var(--v2-radius-sm);
+ background: transparent;
+ color: var(--v2-text-secondary);
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ transition: background 80ms ease, color 80ms ease;
+}
+
+.v2-root button.v2-demo__pod:hover { background: var(--v2-border-soft); }
+
+/* Hover outranks the active rule below, so without this pair hovering the open
+ pod wipes the tint that says it is open. The product's own hover rule is
+ weaker than its active rule (.v2-pods__item:hover = 0,2,0 against
+ .v2-root button.v2-pods__item--active = 0,2,1), so pairing here follows the
+ component rather than inventing a state it does not have. */
+.v2-root button.v2-demo__pod--active:hover { background: var(--v2-accent-soft); }
+
+/* The live rail's active row (v2.css .v2-pods__item--active): accent tint +
+ a 3px accent mark + accent-free title. The design board drew it cobalt-filled,
+ which is not what the product does; ux-lead's gate says follow the component. */
+.v2-root button.v2-demo__pod--active {
+ position: relative;
+ background: var(--v2-accent-soft);
+ color: var(--v2-text-primary);
+ font-weight: 600;
+}
+
+.v2-root button.v2-demo__pod--active::before {
+ content: '';
+ position: absolute;
+ left: -8px;
+ top: 8px;
+ bottom: 8px;
+ width: 3px;
+ border-radius: 0 2px 2px 0;
+ background: var(--v2-accent);
+}
+
+.v2-demo__pad { color: var(--v2-accent-text); }
+.v2-root button.v2-demo__pod--active .v2-demo__pad { color: var(--v2-accent-text); }
+
+.v2-demo__pod-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.v2-demo__pod-count {
+ font-family: var(--v2-font-mono);
+ font-size: 12px;
+ color: var(--v2-accent-text);
+}
+
+.v2-demo__label--direct { margin-top: 14px; }
+
+.v2-demo__dm {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 12px;
+ border-radius: var(--v2-radius-sm);
+ color: var(--v2-text-secondary);
+}
+
+.v2-demo__dm-face { width: 20px; height: 20px; }
+
+.v2-demo__sample {
+ margin-top: auto;
+ padding: 10px 12px;
+ border-top: 1px solid var(--v2-border-soft);
+ font-family: var(--v2-font-mono);
+ font-size: var(--v2-fs-label);
+ line-height: var(--v2-lh-label);
+ color: var(--v2-text-tertiary);
+}
+
+/* ---------- Thread ---------- */
+
+.v2-demo__thread {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+ background: var(--v2-surface);
+ border: 1px solid var(--v2-border);
+ border-radius: var(--v2-radius);
+}
+
+.v2-demo__thread-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 13px 20px;
+ border-bottom: 1px solid var(--v2-border-soft);
+}
+
+.v2-demo__thread-title {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ min-width: 0;
+}
+
+.v2-demo__thread-title h3 {
+ margin: 0;
+ font-family: var(--v2-font-display);
+ font-size: 22px;
+ line-height: 26px;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ color: var(--v2-text-primary);
+}
+
+.v2-demo__thread-sub {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 13px;
+ color: var(--v2-text-tertiary);
+}
+
+.v2-demo__thread-meta {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 0 0 auto;
+ font-family: var(--v2-font-mono);
+ font-size: 12px;
+}
+
+.v2-demo__working { color: var(--v2-accent-text); }
+.v2-demo__channel-inline { color: var(--v2-text-tertiary); }
+
+/* column-reverse keeps the newest exchange at the bottom without reversing
+ the DOM order (one child, so the browser's scroll origin is the bottom). */
+.v2-demo__log {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column-reverse;
+ overflow-y: auto;
+ padding: 10px 16px;
+}
+
+.v2-demo__log-inner {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.v2-demo__msg {
+ display: flex;
+ gap: 12px;
+ padding: 9px 10px;
+ border-radius: var(--v2-radius-sm);
+}
+
+.v2-demo__msg-body { min-width: 0; }
+
+.v2-demo__msg-head {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+}
+
+.v2-demo__msg-head strong { font-weight: 600; }
+.v2-demo__meta {
+ font-family: var(--v2-font-mono);
+ font-size: 12px;
+ color: var(--v2-text-tertiary);
+}
+.v2-demo__msg-text { color: var(--v2-text-primary); }
+.v2-demo__sub {
+ margin-top: 5px;
+ font-family: var(--v2-font-mono);
+ font-size: 12px;
+ color: var(--v2-text-tertiary);
+}
+
+.v2-demo__card { margin: 4px 0; }
+.v2-demo__card .v2-decision-card__agent { font-size: 11px; }
+
+.v2-demo__pulse--dot {
+ width: 8px;
+ height: 8px;
+ background: var(--v2-accent);
+ display: inline-block;
+}
+
+.v2-demo__typing {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 9px 10px;
+}
+
+.v2-demo__typing-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-family: var(--v2-font-mono);
+ font-size: 12px;
+ color: var(--v2-accent-text);
+}
+
+.v2-demo__composer-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin: 0 16px 14px;
+}
+
+.v2-demo__chips {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+
+.v2-root button.v2-demo__chip {
+ padding: 5px 8px;
+ border: 1px solid var(--v2-border);
+ border-radius: var(--v2-radius-sm);
+ background: var(--v2-surface);
+ color: var(--v2-text-secondary);
+ font-family: var(--v2-font-mono);
+ font-size: 12px;
+ line-height: 16px;
+ cursor: pointer;
+ transition: border-color 80ms ease, color 80ms ease;
+}
+
+.v2-root button.v2-demo__chip:hover {
+ border-color: var(--v2-accent);
+ color: var(--v2-accent-text);
+}
+
+.v2-demo__composer {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ border: 1px solid var(--v2-border);
+ border-radius: var(--v2-radius-sm);
+}
+
+.v2-demo__sr {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+}
+
+.v2-demo__input {
+ flex: 1;
+ min-width: 0;
+ border: 0;
+ outline: none;
+ background: transparent;
+ color: var(--v2-text-primary);
+ font: inherit;
+ padding: 0;
+}
+
+.v2-demo__input::placeholder { color: var(--v2-text-placeholder); }
+
+.v2-demo__enter {
+ font-family: var(--v2-font-mono);
+ font-size: 12px;
+ color: var(--v2-text-placeholder);
+ white-space: nowrap;
+}
+
+.v2-root button.v2-demo__send {
+ height: 32px;
+ padding: 0 16px;
+ border: 1px solid var(--v2-ink);
+ border-radius: var(--v2-radius-sm);
+ background: var(--v2-ink);
+ color: var(--v2-on-ink);
+ font: 600 13px/20px var(--v2-font);
+ cursor: pointer;
+}
+
+.v2-root button.v2-demo__send:hover { background: var(--v2-ink-hover); }
+
+/* Focus is a kernel-wide invariant: every interactive piece in the demo has to
+ be reachable and visibly focused, because these are real buttons. */
+.v2-root button.v2-demo__pod:focus-visible,
+.v2-root button.v2-demo__chip:focus-visible,
+.v2-root button.v2-demo__send:focus-visible,
+.v2-demo__input:focus-visible {
+ outline: none;
+ box-shadow: var(--v2-focus-ring);
+ border-color: var(--v2-accent);
+}
+
+/* ---------- Inspector ---------- */
+
+.v2-demo__inspector {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-width: 0;
+}
+
+.v2-demo__panel {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 14px;
+ background: var(--v2-surface);
+ border: 1px solid var(--v2-border);
+ border-radius: var(--v2-radius);
+}
+
+.v2-demo__needs--pending { border: 2px solid var(--v2-accent); }
+
+.v2-demo__needs-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.v2-demo__needs-who {
+ font-family: var(--v2-font-mono);
+ font-size: 12px;
+ color: var(--v2-accent-text);
+ font-weight: 600;
+}
+
+.v2-demo__agent {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.v2-demo__agent-body { flex: 1; min-width: 0; }
+.v2-demo__agent-name { font-weight: 600; }
+
+.v2-demo__agent-status {
+ font-family: var(--v2-font-mono);
+ font-size: var(--v2-fs-label);
+ line-height: var(--v2-lh-label);
+ color: var(--v2-text-tertiary);
+}
+
+.v2-demo__agent-status--waiting { color: var(--v2-accent-text); }
+
+.v2-demo__dot {
+ width: 8px;
+ height: 8px;
+ flex: 0 0 auto;
+ background: var(--v2-border-soft);
+}
+
+.v2-demo__dot[data-state='on'] { background: var(--v2-accent); }
+
+.v2-demo__board-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.v2-demo__board-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.v2-demo__board-state {
+ flex: 0 0 auto;
+ font-size: 12px;
+ color: var(--v2-text-tertiary);
+}
+
+.v2-demo__board-state--accent {
+ color: var(--v2-accent-text);
+ font-weight: 600;
+}
+
+.v2-demo__channel {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.v2-demo__channel-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--v2-ink);
+}
+
+.v2-demo__channel-count {
+ margin-left: auto;
+ font-family: var(--v2-font-mono);
+ font-size: var(--v2-fs-label);
+ color: var(--v2-text-tertiary);
+}
+
+/* ---------- Phone (<= 760px) ----------
+ The 1312 canvas cannot shrink into 390 and stay legible, so the phone layout
+ is a different arrangement of the same pieces: pods as a wrapping strip, the
+ thread with a usable transcript height, then the inspector's panels as a
+ two-column block with "needs you" full width above them. The rail and the
+ DM rows are dropped — they are chrome and navigation the hero does not need.
+ The disclosure line goes last in the strip, wrapping onto its own row so it
+ is never the thing that scrolls out of view. */
+@media (max-width: 760px) {
+ .v2-demo__stage {
+ grid-template-columns: minmax(0, 1fr);
+ height: auto;
+ gap: 8px;
+ padding: 10px;
+ }
+
+ .v2-demo__rail { display: none; }
+
+ .v2-demo__pods {
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ padding: 0;
+ }
+
+ .v2-demo__group,
+ .v2-demo__label--direct,
+ .v2-demo__dm { display: none; }
+
+ .v2-demo__pods > .v2-demo__label { flex: 0 0 auto; }
+
+ .v2-root button.v2-demo__pod {
+ width: auto;
+ padding: 7px 10px;
+ background: var(--v2-surface);
+ border: 1px solid var(--v2-border);
+ }
+
+ /* The row rule above has the same specificity as the desktop --active rule
+ and sits later in the file, so it wins: without a background here the phone
+ strip paints the open pod exactly like its neighbours and the active row
+ loses its only position cue. (ux-lead, #1855 gate at 62ceda24.) */
+ .v2-root button.v2-demo__pod--active {
+ background: var(--v2-accent-soft);
+ border-color: transparent;
+ }
+
+ .v2-root button.v2-demo__pod--active::before {
+ left: -1px;
+ top: 6px;
+ bottom: 6px;
+ }
+
+ .v2-demo__sample {
+ flex: 1 1 100%;
+ order: 99;
+ margin-top: 2px;
+ padding: 8px 2px 0;
+ }
+
+ .v2-demo__thread { min-height: 0; }
+
+ .v2-demo__log { min-height: 300px; }
+
+ .v2-demo__thread-head {
+ align-items: flex-start;
+ padding: 12px 14px;
+ }
+
+ .v2-demo__thread-title { flex-direction: column; gap: 2px; }
+ .v2-demo__thread-meta { flex-direction: column; align-items: flex-end; gap: 2px; }
+ .v2-demo__composer-wrap { margin: 0 12px 12px; }
+
+ .v2-demo__panel { padding: 12px; }
+}
+
+/* Two columns are a 641-760 affordance. At 390 each column is ~156px against a
+ board row that needs ~230, so every row title truncated to an ellipsis with
+ nothing left to read — a phone gets one column instead, with the needs-you
+ panel hoisted to the top. (ux-lead, #1855 gate at 62ceda24.) */
+@media (max-width: 760px) and (min-width: 641px) {
+ .v2-demo__inspector {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 8px;
+ }
+
+ .v2-demo__needs { grid-column: 1 / -1; order: -1; }
+}
+
+@media (max-width: 640px) {
+ .v2-demo__inspector { gap: 8px; }
+ .v2-demo__needs { order: -1; }
+}
diff --git a/frontend/src/v2/landing/demoFixture.ts b/frontend/src/v2/landing/demoFixture.ts
new file mode 100644
index 0000000000..eea4a63317
--- /dev/null
+++ b/frontend/src/v2/landing/demoFixture.ts
@@ -0,0 +1,408 @@
+/**
+ * Fixture data for the landing hero demo (TASK-147).
+ *
+ * This is the sample workspace from `docs/design/landing-demo/Demo.dc.html`
+ * (`#1841`), transcribed. Every name, count, time and PR number here is
+ * invented and must stay obviously sample — the demo says so in its own
+ * footer line ("sample workspace · replies are scripted"), and the hero
+ * caption says it again. No backend is involved: replies are scripted and
+ * delayed locally.
+ *
+ * Faces are NOT in this file. They come from the real kit —
+ * `characterAvatarFor('demo:', kind)` — because the `/_blob/…` URLs in
+ * the design canvas do not resolve outside it.
+ */
+
+export type PodKey = 'launch' | 'support' | 'website' | 'growth';
+
+/** Faces the fixture can reference, with the species each one is drawn as. */
+export type FaceKey =
+ | 'sam' | 'priya' | 'dana'
+ | 'wren' | 'kai' | 'sage' | 'vera' | 'quill' | 'reed';
+
+export const HUMAN_FACES: readonly FaceKey[] = ['sam', 'priya', 'dana'];
+
+export const isHumanFace = (key: FaceKey | undefined): boolean => (
+ Boolean(key) && (HUMAN_FACES as readonly string[]).includes(key as string)
+);
+
+export interface FixtureMessage {
+ /** Display name. A name with no face key renders its initial instead. */
+ who: string;
+ faceKey?: FaceKey;
+ initial?: string;
+ meta: string;
+ text: string;
+ /** The mono line under a message, e.g. "#212 merged · deployed 09:21". */
+ sub?: string;
+}
+
+export interface FixtureOption {
+ id: string;
+ label: string;
+ /** Sam's line once this option is picked. */
+ rule: string;
+ /** The agent's reply to the ruling. */
+ reply: string;
+}
+
+export interface FixtureAsk {
+ who: string;
+ faceKey: FaceKey;
+ title: string;
+ text: string;
+ options: FixtureOption[];
+}
+
+/** One scripted agent reply inside a suggested-prompt chain. */
+export interface ScriptedStep {
+ faceKey: FaceKey;
+ text: string;
+ sub?: string;
+ /** Milliseconds after the prompt is sent. The typing row starts 900ms earlier. */
+ delay: number;
+}
+
+export interface FixtureChip {
+ id: string;
+ text: string;
+ /**
+ * The chain this prompt triggers. Takes whether the pod's decision is
+ * already ruled, because two of the spec's prompts answer differently
+ * depending on it (Launch's "who is working on what?", Support's count).
+ */
+ steps: (decided: boolean) => ScriptedStep[];
+}
+
+export interface FixtureAgent {
+ faceKey: FaceKey;
+ /** Status line before this pod's decision is ruled. */
+ status: string;
+ /** Status line after it is ruled; falls back to `status`. */
+ ruledStatus?: string;
+ /** Draws the accent dot. A waiting agent is also `true` (spec: both are accent). */
+ working?: boolean;
+ waiting?: boolean;
+ /** …and after it is ruled. */
+ ruledWorking?: boolean;
+ ruledWaiting?: boolean;
+}
+
+export interface FixtureBoardRow {
+ title: string;
+ state: string;
+ /** State shown once this pod's decision is ruled; falls back to `state`. */
+ ruledState?: string;
+ /** Renders "needs you" in accent until the decision is ruled. */
+ needsYou?: boolean;
+}
+
+export interface FixturePod {
+ key: PodKey;
+ name: string;
+ sub: string;
+ group: 'pinned' | 'team';
+ channel: string;
+ channelName: string;
+ channelCount: string;
+ linked: boolean;
+ lead: FaceKey;
+ base: FixtureMessage[];
+ ask: FixtureAsk | null;
+ chips: FixtureChip[];
+ agents: FixtureAgent[];
+ board: FixtureBoardRow[];
+}
+
+export const FIXTURE_PODS: FixturePod[] = [
+ {
+ key: 'launch',
+ name: 'Launch',
+ sub: 'ship the new signup',
+ group: 'pinned',
+ channel: 'telegram · linked',
+ channelName: 'Telegram · Launch',
+ channelCount: '8 today',
+ linked: true,
+ lead: 'wren',
+ base: [
+ {
+ who: 'Sam',
+ faceKey: 'sam',
+ meta: '09:02 · from telegram',
+ text: '@wren the signup fix is green. Merge it, then tell me what broke in last night’s deploy.',
+ },
+ {
+ who: 'Wren',
+ faceKey: 'wren',
+ meta: '09:14',
+ text: 'Merged #212. Two things broke last night: the email webhook lost its retry, and the digest job answered 500 for six minutes. Both are fixed in #212.',
+ sub: '#212 merged · deployed 09:21',
+ },
+ {
+ who: 'Kai',
+ faceKey: 'kai',
+ meta: '09:20',
+ text: 'Took TASK-31, the retry gap. One-line fix and a test; PR within the hour.',
+ },
+ ],
+ ask: {
+ who: 'Wren',
+ faceKey: 'wren',
+ title: 'Tell affected users',
+ text: 'The fix is live. Tell the 14 people who hit the error now, or wait for Friday’s changelog?',
+ options: [
+ {
+ id: 'now',
+ label: 'Email them now',
+ rule: 'Email them now.',
+ reply: 'Sending from support@ now: one line and a link to the fix. I’ll post the open rate here tomorrow.',
+ },
+ {
+ id: 'friday',
+ label: 'Wait for Friday',
+ rule: 'Wait for Friday.',
+ reply: 'Holding it for Friday’s changelog. I added a line so nobody who hit the error gets missed.',
+ },
+ {
+ id: 'other',
+ label: 'Other…',
+ rule: 'Email the three paying teams now, everyone else on Friday.',
+ reply: 'Got it: three emails now, the rest in Friday’s changelog. Sending the three.',
+ },
+ ],
+ },
+ chips: [
+ {
+ id: 'l1',
+ text: '@kai is the retry fix up?',
+ steps: () => [
+ { faceKey: 'kai', text: 'PR #214 is up with a test. Vera is reviewing it now.', sub: '#214 open · review requested', delay: 1400 },
+ { faceKey: 'vera', text: 'Reviewed #214. One nit, fixed in place. Approved.', sub: '#214 approved', delay: 3300 },
+ ],
+ },
+ {
+ id: 'l2',
+ text: 'Who is working on what?',
+ steps: (decided) => [{
+ faceKey: 'wren',
+ text: decided
+ ? 'Kai is on TASK-31, the retry gap. Vera is reviewing #214. I’m sending the note to affected users.'
+ : 'Kai is on TASK-31, the retry gap. Vera is reviewing #214. I’m waiting on you for the note to affected users.',
+ delay: 1500,
+ }],
+ },
+ {
+ id: 'l3',
+ text: '@vera what did you review today?',
+ steps: () => [{
+ faceKey: 'vera',
+ text: 'Three PRs. #212 approved, #213 sent back for a missing test, #214 approved after one nit.',
+ delay: 1500,
+ }],
+ },
+ ],
+ agents: [
+ { faceKey: 'wren', status: 'waiting on you', ruledStatus: 'working · note to users', waiting: true, ruledWorking: true, ruledWaiting: false },
+ { faceKey: 'kai', status: 'working · TASK-31', working: true },
+ { faceKey: 'vera', status: 'idle · 18m' },
+ ],
+ board: [
+ { title: 'TASK-30 Signup fix', state: 'done' },
+ { title: 'TASK-31 Retry gap', state: 'kai · wip' },
+ { title: 'TASK-32 Note to users', state: 'needs you', needsYou: true, ruledState: 'wren · wip' },
+ ],
+ },
+ {
+ key: 'support',
+ name: 'Support',
+ sub: 'questions from Slack #help',
+ group: 'team',
+ channel: 'slack · mirror',
+ channelName: 'Slack · #help',
+ channelCount: '9 today',
+ linked: true,
+ lead: 'quill',
+ base: [
+ {
+ who: 'Dana',
+ initial: 'D',
+ meta: '10:41 · from slack #help',
+ text: 'Hi! Is there a way to export our pod history to CSV?',
+ },
+ {
+ who: 'Quill',
+ faceKey: 'quill',
+ meta: '10:42 · answered in slack',
+ text: 'Yes: pod settings, then Export, then CSV. It includes messages, tasks and files. Want me to run it for your workspace?',
+ },
+ {
+ who: 'Dana',
+ initial: 'D',
+ meta: '10:44 · from slack #help',
+ text: 'Please do, for the Launch pod.',
+ },
+ ],
+ ask: {
+ who: 'Quill',
+ faceKey: 'quill',
+ title: 'Export for Dana',
+ text: 'Dana wants a CSV of the Launch pod. It has two private threads. Leave them out, or export everything?',
+ options: [
+ {
+ id: 'out',
+ label: 'Leave them out',
+ rule: 'Leave them out.',
+ reply: 'Exported without the private threads and sent Dana the link in Slack.',
+ },
+ {
+ id: 'all',
+ label: 'Export everything',
+ rule: 'Export everything.',
+ reply: 'Exported all of it and sent Dana the link in Slack. The link expires in 7 days.',
+ },
+ {
+ id: 'other',
+ label: 'Other…',
+ rule: 'Ask Dana whether she needs the private threads first.',
+ reply: 'Asked in Slack. I’ll export as soon as Dana answers.',
+ },
+ ],
+ },
+ chips: [
+ {
+ id: 's1',
+ text: '@quill how many questions today?',
+ steps: (decided) => [{
+ faceKey: 'quill',
+ text: decided
+ ? 'Nine from Slack. Eight answered, one handed to Kai as a bug (TASK-33).'
+ : 'Nine from Slack. Seven answered, one waiting on you, one handed to Kai as a bug (TASK-33).',
+ delay: 1400,
+ }],
+ },
+ {
+ id: 's2',
+ text: 'What bug did Kai take?',
+ steps: () => [{
+ faceKey: 'kai',
+ text: 'TASK-33: the export button hides on narrow screens. Small fix; I’ll put it up after TASK-31.',
+ sub: 'TASK-33 · next',
+ delay: 1500,
+ }],
+ },
+ ],
+ agents: [
+ { faceKey: 'quill', status: 'waiting on you', ruledStatus: 'working · export', waiting: true, ruledWorking: true, ruledWaiting: false },
+ { faceKey: 'kai', status: 'working · TASK-31', working: true },
+ ],
+ board: [
+ { title: 'TASK-33 Export button', state: 'kai · next' },
+ { title: 'TASK-34 Export for Dana', state: 'needs you', needsYou: true, ruledState: 'quill · wip' },
+ ],
+ },
+ {
+ key: 'website',
+ name: 'Website',
+ sub: 'pricing page and guides',
+ group: 'team',
+ channel: 'no channel',
+ channelName: '',
+ channelCount: '',
+ linked: false,
+ lead: 'sage',
+ base: [
+ {
+ who: 'Sage',
+ faceKey: 'sage',
+ meta: '08:30',
+ text: 'The pricing page is live. Two guide pages were indexed this morning.',
+ },
+ {
+ who: 'Priya',
+ faceKey: 'priya',
+ meta: '08:47',
+ text: 'Nice. Can you write the Slack guide next?',
+ },
+ {
+ who: 'Sage',
+ faceKey: 'sage',
+ meta: '08:49',
+ text: 'Started it. A draft will be in this pod by noon.',
+ sub: 'TASK-28 · wip',
+ },
+ ],
+ ask: null,
+ chips: [
+ {
+ id: 'w1',
+ text: '@sage what should the guide cover?',
+ steps: () => [{
+ faceKey: 'sage',
+ text: 'Connect Slack, pick mirror or mentions, and the first thing to ask an agent. Three screenshots, under 600 words.',
+ delay: 1500,
+ }],
+ },
+ ],
+ agents: [{ faceKey: 'sage', status: 'working · TASK-28', working: true }],
+ board: [
+ { title: 'TASK-27 Pricing page', state: 'done' },
+ { title: 'TASK-28 Slack guide', state: 'sage · wip' },
+ ],
+ },
+ {
+ key: 'growth',
+ name: 'Growth',
+ sub: 'first lines and follow-ups',
+ group: 'team',
+ channel: 'no channel',
+ channelName: '',
+ channelCount: '',
+ linked: false,
+ lead: 'reed',
+ base: [
+ {
+ who: 'Reed',
+ faceKey: 'reed',
+ meta: '07:55',
+ text: 'Twelve teams asked about Slack agents this week. I drafted a first line for each; they are on the board as TASK-40.',
+ },
+ {
+ who: 'Priya',
+ faceKey: 'priya',
+ meta: '08:10',
+ text: 'I’ll read them after lunch.',
+ },
+ ],
+ ask: null,
+ chips: [
+ {
+ id: 'g1',
+ text: '@reed show me the best one',
+ steps: () => [{
+ faceKey: 'reed',
+ text: '“Saw your thread about the Slack bot that forgets everything. Ours keeps a memory per channel; want to try it on #help?”',
+ sub: 'TASK-40 · 1 of 12',
+ delay: 1500,
+ }],
+ },
+ ],
+ agents: [{ faceKey: 'reed', status: 'idle · 2h' }],
+ board: [{ title: 'TASK-40 First lines', state: 'priya · review' }],
+ },
+];
+
+/** The fallback line every free-text message gets, from the pod's lead agent. */
+export const GENERIC_REPLY = 'Noted. I’ll pick it up after the task I’m on and post here when it’s done.';
+
+export const FACE_NAMES: Record = {
+ sam: 'Sam',
+ priya: 'Priya',
+ dana: 'Dana',
+ wren: 'Wren',
+ kai: 'Kai',
+ sage: 'Sage',
+ vera: 'Vera',
+ quill: 'Quill',
+ reed: 'Reed',
+};
diff --git a/frontend/src/v2/landing/v2-landing.css b/frontend/src/v2/landing/v2-landing.css
index c0ec078a20..f436d8982e 100644
--- a/frontend/src/v2/landing/v2-landing.css
+++ b/frontend/src/v2/landing/v2-landing.css
@@ -123,11 +123,14 @@
.v2-landing__cta-row { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 32px; }
-/* ---------- Hero ---------- */
+/* ---------- Hero ----------
+/* Copy above, the product below. The hero demo (TASK-147) is the real thing
+ at 1312 itself, so it needs the full content column rather than the old
+ two-column split with a screenshot beside the copy. */
.v2-landing__hero {
display: grid;
- grid-template-columns: 1.05fr 0.95fr;
- gap: 56px;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 40px;
align-items: center;
padding-top: 72px;
padding-bottom: 72px;
@@ -777,8 +780,11 @@
}
@media (max-width: 900px) {
- .v2-landing__hero { grid-template-columns: 1fr; gap: 36px; padding-top: 48px; padding-bottom: 48px; }
- .v2-landing__hero-art { order: -1; }
+ .v2-landing__hero { grid-template-columns: 1fr; gap: 28px; padding-top: 48px; padding-bottom: 48px; }
+ /* The demo stays BELOW the copy on a phone: it is the thing being explained,
+ and a 390 visitor who has not read the headline has no reason to poke at a
+ pod switcher. It used to be lifted above on mobile, when it was a video
+ that explained itself. */
.v2-landing__open-grid { grid-template-columns: 1fr; gap: 28px; }
.v2-landing__tiles,
.v2-landing__adapters,