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 ; +}; + +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 */} + + + + +
+
+
+

{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. +
+
+ {pendingAsk.who} needs you + decision +
+

{pendingAsk.text}

+
+ {pendingAsk.options.map((option, index) => ( + + ))} +
+
+ )} + + {typing && typing.pod === pod && ( +
+ + + +
+ )} +
+
+ +
+ {chips.length > 0 && ( +
+ try + {chips.map((chip) => ( + + ))} +
+ )} +
{ + event.preventDefault(); + const value = draft; + setDraft(''); + send(pod, value); + }} + > + + setDraft(event.target.value)} + placeholder={`Message ${activePod.name}… @ an agent to wake it`} + autoComplete="off" + /> + + +
+
+
+ + +
+
+ ); +}; + +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. */}
-
- -