diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 023b9922c..e083b2d0e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -624,6 +624,9 @@ "titleSuffix": "", "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", + "copy": "Copy", + "copied": "Copied", + "copyInstallAria": "Copy install command", "builtBy": "Built in the open by", "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.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 8a2ed2fb7..632e30fc1 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -623,6 +623,9 @@ "titleSuffix": "对话", "lede": "和一支 AI 团队一起把正事办成——每个 agent 都有自己的记忆和工位,在同一个群里互相交接,不用你一遍遍重复解释。开源、支持任意运行时、部署在你自己的基础设施上,绝不按 agent 收费。", "selfHostInstall": "自托管安装命令", + "copy": "复制", + "copied": "已复制", + "copyInstallAria": "复制安装命令", "builtBy": "公开构建者", "demoAria": "交互演示:一个示例工作区,有四个 Pod、正在工作的 agent,还有一张你可以直接裁决的决策卡", "demoCaption": "这就是产品本身,不是视频。在卡片上选一项,或者直接问 agent。", diff --git a/frontend/src/v2/landing/V2LandingPage.tsx b/frontend/src/v2/landing/V2LandingPage.tsx index e9867519a..1c4d81061 100644 --- a/frontend/src/v2/landing/V2LandingPage.tsx +++ b/frontend/src/v2/landing/V2LandingPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from 'axios'; @@ -188,6 +188,42 @@ 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); + const [installCopied, setInstallCopied] = useState(false); + const installCmdRef = useRef(null); + + // TASK-154. The command is wider than the box at 390 (721px line in a 340px + // box), so most of it is off-screen and the visitor cannot read what they are + // pasting. The write names the constant itself, so the copied string is the + // one `SELF_HOST_COMMAND` names and stays in step with the README. + // + // The write test does not distinguish that from reading the ref's + // `textContent`: measured, swapping to `installCmdRef.current.textContent` + // stays green, because the ref is the and the two strings are identical + // as written — the `$` is a sibling span outside it, and CSS truncation never + // changes textContent. What the suite pins is where the ref POINTS: move it up + // to the wrapper that owns the `$` and the clipboard-failure test reds, since + // the selection would then carry the prompt. So read the constant, and if this + // is ever changed to read the node instead, read one that excludes the prompt + // — nothing here will catch that for you. (#1868, sprint-review.) + const copyInstallCommand = async () => { + try { + await navigator.clipboard.writeText(SELF_HOST_COMMAND); + setInstallCopied(true); + window.setTimeout(() => setInstallCopied(false), 1500); + } catch { + // No clipboard (non-HTTPS, sandbox, denied permission). Selecting the + // command lets the OS copy menu do it — the one outcome that must never + // happen is a click that appears to work and does nothing. + const node = installCmdRef.current; + const selection = typeof window !== 'undefined' ? window.getSelection() : null; + if (node && selection) { + const range = document.createRange(); + range.selectNodeContents(node); + selection.removeAllRanges(); + selection.addRange(range); + } + } + }; // 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 @@ -295,8 +331,24 @@ const V2LandingPage: React.FC = () => {
- $ - {SELF_HOST_COMMAND} + {/* The scroll region is this wrapper, not the box, so the Copy + control beside it is visible at every width and scroll + position (TASK-154). */} +
+ $ + {SELF_HOST_COMMAND} +
+ + + {installCopied ? t('landing.hero.copied') : ''} +
diff --git a/frontend/src/v2/landing/__tests__/landingInstallCopy.test.tsx b/frontend/src/v2/landing/__tests__/landingInstallCopy.test.tsx new file mode 100644 index 000000000..06c2335ea --- /dev/null +++ b/frontend/src/v2/landing/__tests__/landingInstallCopy.test.tsx @@ -0,0 +1,228 @@ +import fs from 'fs'; +import path from 'path'; +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import axios from 'axios'; +import { useAuth } from '../../../context/AuthContext'; +import V2LandingPage from '../V2LandingPage'; + +/** + * TASK-154. The self-host command is 721px wide inside a 340px box at 390, so 37 + * of 86 characters showed and `cd commonly && ./install.sh` was entirely + * off-screen (ux-lead, measured on the live hero). The Copy control is what + * makes the hidden half reachable, so these tests pin the three things that make + * it work rather than the markup that draws it: + * + * 1. the click writes SELF_HOST_COMMAND exactly — read out of the component + * rather than restated, so the constant stays single-sourced with the + * README guard #1866 added (`$` and any truncation are render-only); + * 2. the button is OUTSIDE the scrolling region, which is the whole fix — a + * Copy that scrolls away is not a fix; + * 3. a denied clipboard selects the command instead of doing nothing. + * + * jsdom has no layout engine, so the width claims above are not re-measured + * here; what is asserted is the structure that addresses them. The visual gate + * is ux-lead's, at 1200, 390 and zh-CN 390, with the clipboard read in a real + * browser. + */ + +jest.mock('axios'); +jest.mock('../../../context/AuthContext', () => ({ + useAuth: jest.fn(), +})); + +const mockAxiosGet = axios.get as jest.Mock; +const mockUseAuth = useAuth as jest.Mock; + +const LANDING_SOURCE = fs.readFileSync(path.join(__dirname, '..', 'V2LandingPage.tsx'), 'utf8'); +const LANDING_CSS = fs.readFileSync(path.join(__dirname, '..', 'v2-landing.css'), 'utf8'); +const V2_ROOT_CSS = fs.readFileSync(path.join(__dirname, '..', '..', 'v2.css'), 'utf8'); + +/** + * TASK-154, ux-lead's design gate. `.v2-root button:not(.MuiButtonBase-root)` + * (v2.css:201) resets every button's padding, border, background and colour at + * (0,2,1) — one class, the :not() argument, and the element — so the pill + * written as a bare `.v2-landing__install-copy` (0,1,0) lost all four and + * computed as text, 27×18 with a 27×44 phone target. The fix is to write it in + * the reset's own tier, as .v2-byo__copy does. + * + * The comparator below is deliberately coarse and applied to BOTH selectors, so + * it answers "is the pill at least at the reset's tier" without anyone having to + * trust a specificity number restated by hand. A tie is resolved by sheet order, + * which is why this file's rule comes from the lazily-loaded landing chunk. + */ +const BUTTON_RESET_SELECTOR = '.v2-root button:not(.MuiButtonBase-root)'; +const PILL_SELECTOR = '.v2-root button.v2-landing__install-copy'; + +const specificity = (selector: string): number => { + const notArgs = (selector.match(/:not\(([^)]*)\)/g) ?? []) as string[]; + const base = selector.replace(/:not\([^)]*\)/g, ''); + const classes = (base.match(/\.[\w-]+/g) ?? []).length + + notArgs.reduce((n, arg) => n + (arg.match(/\.[\w-]+/g) ?? []).length, 0); + const elements = (base.match(/(^|\s)[a-z]+/g) ?? []).length; + return classes * 10 + elements; +}; + +/** The shipped constant, read from source — never a copy of it. */ +const selfHostCommand = (): string => { + const match = /const SELF_HOST_COMMAND = '([^']+)'/.exec(LANDING_SOURCE); + if (!match) throw new Error('SELF_HOST_COMMAND is no longer a single-quoted literal in V2LandingPage.tsx'); + return match[1]; +}; + +/** + * One declaration block, matched on a LINE-START selector. The ` {` is what + * separates `.v2-landing__install {` from `.v2-landing__install-scroll {` and + * from any indented media-block copy of the same name. + */ +const ruleBody = (selector: string): string => { + const at = LANDING_CSS.indexOf(`\n${selector} {`); + if (at < 0) throw new Error(`${selector} is no longer a top-level rule in v2-landing.css`); + const open = LANDING_CSS.indexOf('{', at); + return LANDING_CSS.slice(open + 1, LANDING_CSS.indexOf('}', open)); +}; + +/** A whole at-rule block, matched INCLUDING its trailing `{` — a bare + * '@media (max-width: 760px)' is a text prefix of a longer query. */ +const mediaAt = (atRule: string): string => { + const at = LANDING_CSS.indexOf(`${atRule} {`); + if (at < 0) throw new Error(`${atRule} is no longer declared in v2-landing.css`); + const open = LANDING_CSS.indexOf('{', at); + let depth = 0; + for (let i = open; i < LANDING_CSS.length; i += 1) { + if (LANDING_CSS[i] === '{') depth += 1; + if (LANDING_CSS[i] === '}') { + depth -= 1; + if (depth === 0) return LANDING_CSS.slice(open + 1, i); + } + } + throw new Error(`${atRule} is unbalanced in v2-landing.css`); +}; + +const renderLanding = () => render( + + + , +); + +const installBox = () => document.querySelector('.v2-landing__install') as HTMLElement; +const installButton = () => screen.getByRole('button', { name: 'Copy install command' }); + +describe('landing install line copy control (TASK-154)', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseAuth.mockReturnValue({ isAuthenticated: false }); + mockAxiosGet.mockResolvedValue({ data: {} }); + Object.assign(navigator, { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + }); + }); + + it('writes the shipped command exactly, with no prompt and no trailing newline', () => { + renderLanding(); + fireEvent.click(installButton()); + + expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(selfHostCommand()); + expect(selfHostCommand()).not.toContain('$'); + }); + + it('confirms in place for 1.5s, then returns to Copy', async () => { + jest.useFakeTimers(); + try { + renderLanding(); + const button = installButton(); + expect(button).toHaveTextContent('Copy'); + + // The write is awaited before the label swaps, so the flush is the point: + // a click that announced "Copied" before the clipboard resolved would be + // claiming an outcome it had not got yet. + await act(async () => { fireEvent.click(button); }); + expect(button).toHaveTextContent('Copied'); + // Polite announcement for the state the button's own label carries: a + // label swap on a focused button is not reliably read out. + expect(screen.getByRole('status')).toHaveTextContent('Copied'); + + act(() => { jest.advanceTimersByTime(1500); }); + expect(button).toHaveTextContent('Copy'); + expect(screen.getByRole('status')).toHaveTextContent(''); + } finally { + jest.useRealTimers(); + } + }); + + it('keeps the control out of the scrolling region it exists to compensate for', () => { + renderLanding(); + const box = installBox(); + const button = installButton(); + const scroll = box.querySelector('.v2-landing__install-scroll') as HTMLElement; + + // The scroll region is the command's wrapper, and it is where the overflow + // lives — so the button cannot be inside it, or it scrolls out of reach. + expect(scroll).not.toBeNull(); + expect(scroll.contains(button)).toBe(false); + expect(button.parentElement).toBe(box); + expect(scroll).toContainElement(document.querySelector('.v2-landing__install-cmd') as HTMLElement); + + // The CSS has to match the markup: scroll on the wrapper, not on the box. + expect(ruleBody('.v2-landing__install')).not.toContain('overflow-x'); + expect(ruleBody('.v2-landing__install-scroll')).toContain('overflow-x: auto'); + }); + + it('writes the pill in the button reset\u2019s own tier, so the reset cannot erase it', () => { + // The reset this guards against has to still be in the sheet, or the + // comparison below is against nothing. + expect(V2_ROOT_CSS).toContain(`${BUTTON_RESET_SELECTOR} {`); + expect(specificity(PILL_SELECTOR)).toBeGreaterThanOrEqual(specificity(BUTTON_RESET_SELECTOR)); + + // Read the declarations THROUGH the qualified selector: ruleBody throws if + // the prefix is gone, so this cannot pass on a bare-class rule the reset + // would win against. + const pill = ruleBody(PILL_SELECTOR); + expect(pill).toContain('padding: 4px 10px'); + expect(pill).toContain('border: 1px solid var(--v2-border)'); + expect(pill).toContain('background: var(--v2-surface, #fff)'); + expect(pill).toContain('color: var(--v2-text-secondary)'); + expect(pill).toContain('font-size: 12px'); + expect(pill).toContain('flex: none'); + // The hover state needs the same tier: at (0,2,0) it lost to the (0,2,1) reset. + expect(ruleBody(`${PILL_SELECTOR}:hover`)).toContain('background: var(--v2-accent-soft)'); + }); + + it('keeps the line 46px tall and gives the pill a 44px target on phones', () => { + // The pill is taller than the command text, so the box's height is a + // decision, not a consequence — and it is pinned as one. + const box = ruleBody('.v2-landing__install'); + expect(box).toContain('min-height: 46px'); + expect(box).toContain('padding: 8px 16px'); + + const phone = mediaAt('@media (max-width: 760px)'); + expect(phone).toContain('.v2-landing__install-copy { position: relative; }'); + expect(phone).toContain('height: 44px'); + // Transparent extension, so the visible pill keeps its size. + expect(phone).not.toContain('min-height: 44px'); + }); + + it('selects the command when the clipboard is unavailable, never a silent no-op', async () => { + const selected: string[] = []; + const range = { + selectNodeContents: (node: Node) => { selected.push((node as HTMLElement).textContent || ''); }, + }; + (navigator.clipboard.writeText as jest.Mock).mockRejectedValueOnce(new Error('denied')); + window.getSelection = jest.fn().mockReturnValue({ + removeAllRanges: jest.fn(), + addRange: jest.fn(), + }) as unknown as typeof window.getSelection; + document.createRange = jest.fn().mockReturnValue(range) as unknown as typeof document.createRange; + + renderLanding(); + fireEvent.click(installButton()); + + // The click still has an outcome the visitor can act on: the text is + // selected, so the OS copy menu works. + await act(async () => { await Promise.resolve(); }); + expect(selected).toEqual([selfHostCommand()]); + expect(installButton()).toHaveTextContent('Copy'); + }); +}); diff --git a/frontend/src/v2/landing/v2-landing.css b/frontend/src/v2/landing/v2-landing.css index f436d8982..dbcb32a6d 100644 --- a/frontend/src/v2/landing/v2-landing.css +++ b/frontend/src/v2/landing/v2-landing.css @@ -185,16 +185,31 @@ } .v2-landing__badge svg { font-size: 16px; color: var(--v2-accent); } -/* Hero install line */ +/* Hero install line. The horizontal scroll lives on the inner wrapper, not the + box, so the Copy control beside it stays visible at every width and scroll + position — at 390 the box is 340 wide against a 721 command, so only 37 of 86 + characters show and `cd commonly && ./install.sh` is entirely off-screen + (TASK-154, measured by ux-lead on the live hero). */ .v2-landing__install { display: flex; align-items: center; gap: 10px; margin-top: 24px; - padding: 12px 16px; + padding: 8px 16px; + /* The line stays 46px tall: the copy pill is taller than the command text, so + its height comes out of this padding, and min-height pins the box against a + future pill/line-height change rather than letting the row grow. */ + min-height: 46px; background: var(--v2-surface-tint); border: 1px solid var(--v2-border-soft); border-radius: var(--v2-radius); +} +.v2-landing__install-scroll { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; overflow-x: auto; } .v2-landing__install-prompt { color: var(--v2-accent); font-family: var(--v2-font-mono); font-weight: 700; } @@ -204,6 +219,59 @@ color: var(--v2-text-primary); white-space: nowrap; } +/* The BYO add-computer pill (.v2-byo__copy), restated under this surface's own + name: the two are styled separately even though the shape is the same. + Qualified as `.v2-root button.` exactly as .v2-byo__copy is, because the v2 + button reset (`.v2-root button:not(.MuiButtonBase-root)`, v2.css:201) is + (0,2,1) and clears padding, border, background and colour: unqualified, this + rule is (0,1,0) and lost all four, so the pill computed as bare 27×18 text + with a 27×44 phone target (ux-lead's design gate on #1868; a scratch build + then measured the pill at 49.1×28, zh 46.5×28, and the target at 47.1×44). + Same tier as the reset, and this sheet loads after v2.css — the landing page + is a lazy route, so its chunk's CSS follows the shell's. */ +.v2-root button.v2-landing__install-copy { + flex: none; + padding: 4px 10px; + border-radius: 6px; + background: var(--v2-surface, #fff); + border: 1px solid var(--v2-border); + color: var(--v2-text-secondary); + font-size: 12px; + line-height: 18px; + cursor: pointer; +} +.v2-root button.v2-landing__install-copy:hover { + background: var(--v2-accent-soft); + border-color: var(--v2-accent-soft); + color: var(--v2-accent-text); +} +/* The Copied state is announced by a live region, not read from the button's own + label swap, so it is out of flow and silent to sighted users. */ +.v2-landing__install-status { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +@media (max-width: 760px) { + /* A thumb-sized target (44px) without growing the visible pill: the extension + is transparent and stays inside the box's own padding. */ + .v2-landing__install-copy { position: relative; } + .v2-landing__install-copy::after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 44px; + transform: translateY(-50%); + } +} .v2-landing__hero-by { margin-top: 20px; font-size: 13px; color: var(--v2-text-tertiary); } .v2-landing__hero-by a { color: var(--v2-accent); text-decoration: none; } .v2-landing__hero-by a:hover { text-decoration: underline; }