From ef7830b22adf46fab9a6b52c74ecb9708e109662 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:44:39 -0700 Subject: [PATCH 1/3] feat(landing): copy control for the self-host command (TASK-154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 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 — the half that matters was unreachable on a phone. - The horizontal scroll moves off the box onto an inner wrapper, so Copy is a sibling of the scroll region and stays visible at every width and scroll position. - Copy writes SELF_HOST_COMMAND itself, never the rendered text (which carries the `$` and follows the truncation), so the copied string stays single-sourced with the README guard #1866 added. - The label swaps Copy -> Copied for 1.5s and a polite live region announces it; a denied clipboard selects the command instead of silently doing nothing. - The pill is the BYO add-computer row's shape under this surface's own class; its height comes out of the box's padding so the line stays 46px, pinned with min-height, and at <=760 a transparent pseudo-element gives it a 44px thumb target without growing the visible pill. --- frontend/src/i18n/locales/en.json | 3 + frontend/src/i18n/locales/zh-CN.json | 3 + frontend/src/v2/landing/V2LandingPage.tsx | 49 ++++- .../__tests__/landingInstallCopy.test.tsx | 182 ++++++++++++++++++ frontend/src/v2/landing/v2-landing.css | 64 +++++- 5 files changed, 296 insertions(+), 5 deletions(-) create mode 100644 frontend/src/v2/landing/__tests__/landingInstallCopy.test.tsx 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..7e9d53829 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,33 @@ 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 button writes the constant itself — never the rendered text, + // which carries the `$` and follows the truncation — so the copied string is + // the one `SELF_HOST_COMMAND` names and stays in step with the README. + 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 +322,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..844302f41 --- /dev/null +++ b/frontend/src/v2/landing/__tests__/landingInstallCopy.test.tsx @@ -0,0 +1,182 @@ +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'); + +/** 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('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..1df53d195 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,51 @@ 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. */ +.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-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; } From ed89f0df81c68cc8c15d92cc91531bb2ab04d7b1 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:29:16 -0700 Subject: [PATCH 2/3] docs(landing): correct the rationale on the copy click, and measure which guard holds (TASK-154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sprint-review's gate on #1868 passed the code and corrected the reason given for it: the comment claimed the button avoids the rendered text "which carries the `$` and follows the truncation", but the ref is the , whose textContent is currently identical to the constant. Measured, each in isolation, on top of ef7830b2: - swapping the write to `installCmdRef.current.textContent` keeps 5/5 green — the write test cannot tell the two strings apart today; - moving the ref up one level to the scroll wrapper, which owns the `$`, reds exactly one test: "selects the command when the clipboard is unavailable, never a silent no-op", because the selection would then carry the prompt. (sprint-review's mutation moved the ref and the write together and saw two reds; this is which of the two does what.) So the comment now says what is actually pinned — the ref's POSITION, not the constant-vs-textContent choice — and warns the next reader that "simplifying" to read the node is unguarded. Comment-only: no code, test or CSS line changes, so both gates' objects are untouched. --- frontend/src/v2/landing/V2LandingPage.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/frontend/src/v2/landing/V2LandingPage.tsx b/frontend/src/v2/landing/V2LandingPage.tsx index 7e9d53829..1c4d81061 100644 --- a/frontend/src/v2/landing/V2LandingPage.tsx +++ b/frontend/src/v2/landing/V2LandingPage.tsx @@ -193,9 +193,18 @@ const V2LandingPage: React.FC = () => { // 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 button writes the constant itself — never the rendered text, - // which carries the `$` and follows the truncation — so the copied string is - // the one `SELF_HOST_COMMAND` names and stays in step with the README. + // 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); From ffc2e3e2460000290534def9180665700990da66 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:02:03 -0700 Subject: [PATCH 3/3] fix(landing): write the copy pill in the button reset's tier (TASK-154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ux-lead's design gate failed #1868 at ed89f0df, and the reason is the trap this whole surface sits on: `.v2-root button:not(.MuiButtonBase-root)` (v2.css:201) is (0,2,1) — one class, the :not() argument's class, and the element — and it clears padding, border, background and colour on every button. The pill was written as a bare `.v2-landing__install-copy` (0,1,0), so all four were erased and Copy computed as bare text: 27x18, and a 27x44 target at 760. Fixed the way .v2-byo__copy already is: qualified as `.v2-root button.`, on the rule and on its :hover (which at (0,2,0) also lost to the (0,2,1) reset). A tie then goes to sheet order, and the landing page is a lazy route, so its chunk's CSS follows the shell's v2.css. ux-lead's scratch build of this shape measured the pill at 49.1x28 (zh 46.5x28), the phone target at 47.1x44, and the box at 46px. The guard was the other half of the miss: nothing in landingInstallCopy.test.tsx read the pill's own declarations at all, so the sheet could lose them silently. The new case compares the pill selector's specificity against the reset's, read out of v2.css, rather than restating a number, and reads the declarations THROUGH the qualified selector so a bare-class rule cannot pass it. --- .../__tests__/landingInstallCopy.test.tsx | 46 +++++++++++++++++++ frontend/src/v2/landing/v2-landing.css | 14 ++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/frontend/src/v2/landing/__tests__/landingInstallCopy.test.tsx b/frontend/src/v2/landing/__tests__/landingInstallCopy.test.tsx index 844302f41..06c2335ea 100644 --- a/frontend/src/v2/landing/__tests__/landingInstallCopy.test.tsx +++ b/frontend/src/v2/landing/__tests__/landingInstallCopy.test.tsx @@ -37,6 +37,32 @@ 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 => { @@ -144,6 +170,26 @@ describe('landing install line copy control (TASK-154)', () => { 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. diff --git a/frontend/src/v2/landing/v2-landing.css b/frontend/src/v2/landing/v2-landing.css index 1df53d195..dbcb32a6d 100644 --- a/frontend/src/v2/landing/v2-landing.css +++ b/frontend/src/v2/landing/v2-landing.css @@ -220,8 +220,16 @@ 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. */ -.v2-landing__install-copy { + 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; @@ -232,7 +240,7 @@ line-height: 18px; cursor: pointer; } -.v2-landing__install-copy:hover { +.v2-root button.v2-landing__install-copy:hover { background: var(--v2-accent-soft); border-color: var(--v2-accent-soft); color: var(--v2-accent-text);