Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,9 @@
"titleSuffix": "对话",
"lede": "和一支 AI 团队一起把正事办成——每个 agent 都有自己的记忆和工位,在同一个群里互相交接,不用你一遍遍重复解释。开源、支持任意运行时、部署在你自己的基础设施上,绝不按 agent 收费。",
"selfHostInstall": "自托管安装命令",
"copy": "复制",
"copied": "已复制",
"copyInstallAria": "复制安装命令",
"builtBy": "公开构建者",
"demoAria": "交互演示:一个示例工作区,有四个 Pod、正在工作的 agent,还有一张你可以直接裁决的决策卡",
"demoCaption": "这就是产品本身,不是视频。在卡片上选一项,或者直接问 agent。",
Expand Down
58 changes: 55 additions & 3 deletions frontend/src/v2/landing/V2LandingPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<HTMLElement | null>(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 <code> 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
Expand Down Expand Up @@ -295,8 +331,24 @@ const V2LandingPage: React.FC = () => {
</div>

<div className="v2-landing__install" aria-label={t('landing.hero.selfHostInstall')}>
<span className="v2-landing__install-prompt">$</span>
<code className="v2-landing__install-cmd">{SELF_HOST_COMMAND}</code>
{/* 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). */}
<div className="v2-landing__install-scroll">
<span className="v2-landing__install-prompt">$</span>
<code className="v2-landing__install-cmd" ref={installCmdRef}>{SELF_HOST_COMMAND}</code>
</div>
<button
type="button"
className="v2-landing__install-copy"
onClick={copyInstallCommand}
aria-label={t('landing.hero.copyInstallAria')}
>
{installCopied ? t('landing.hero.copied') : t('landing.hero.copy')}
</button>
<span className="v2-landing__install-status" role="status" aria-live="polite">
{installCopied ? t('landing.hero.copied') : ''}
</span>
</div>

<div className="v2-landing__hero-by">
Expand Down
228 changes: 228 additions & 0 deletions frontend/src/v2/landing/__tests__/landingInstallCopy.test.tsx
Original file line number Diff line number Diff line change
@@ -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<number>((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(
<MemoryRouter>
<V2LandingPage />
</MemoryRouter>,
);

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');
});
});
Loading
Loading