From 6560f8149d8cc899989e25a20f8fa80ddc6c8567 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:50:44 -0800 Subject: [PATCH 1/2] fix(first-run): the setup dialog, lifted from the d215 frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog that walks a new person through connecting an agent now matches its four frames: the pop surface at 16px over the shell scrim, a mono eyebrow, the display title balanced over two lines, step cards with ringed marks (accent when done, a check glyph instead of a text tick), and the key and fill buttons. On a phone it is a sheet on the bottom edge with 44px targets. Waiting carries the presence dot. Step 2 keeps its title once connected and says Connected once, with a check, instead of the title and a "✓ Connected" status saying it twice. Say hello shows from the start, disabled until the agent connects, so the last step reads as one click away. A failed hello is an alert. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- frontend/src/i18n/locales/en.json | 1 - frontend/src/i18n/locales/zh-CN.json | 1 - .../src/v2/__tests__/V2FirstRunHero.test.tsx | 4 +- .../v2/__tests__/v2-layout-invariants.test.ts | 28 ++-- frontend/src/v2/components/V2FirstRunHero.tsx | 37 +++-- frontend/src/v2/v2.css | 150 ++++++++++++------ 6 files changed, 147 insertions(+), 74 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 5c3a492418..fe6f815a1b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -520,7 +520,6 @@ "start": { "title": "Start your agent", "connected": "Connected", - "connectedStatus": "✓ Connected", "waiting": "Waiting for your agent to connect…" }, "hello": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index c5250b570f..f21ac8cfad 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -520,7 +520,6 @@ "start": { "title": "启动智能体", "connected": "已连接", - "connectedStatus": "✓ 已连接", "waiting": "正在等待智能体连接…" }, "hello": { diff --git a/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx b/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx index 84d36696b8..05e8ce62cd 100644 --- a/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx +++ b/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx @@ -156,7 +156,9 @@ describe('V2FirstRunHero', () => { await Promise.resolve(); }); - expect(await screen.findByText('✓ Connected')).toBeInTheDocument(); + // Connected is a check glyph and the word, under the step's own title. + expect(await screen.findByText('Connected')).toHaveClass('v2-first-run__connected'); + expect(screen.getByText('Start your agent')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Say hello' })); await flush(); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 1a69c3d99d..2cdd01dfe3 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1374,16 +1374,25 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(v2, '.v2-first-run')).toContain('overflow-y: auto'); }); - test('the first-run setup CTA beats the global inherited anchor color with ink', () => { + test('the first-run setup CTA beats the global inherited anchor color with its own', () => { // `.v2-root a { color: inherit }` outranks a bare class selector. The // first browser pass rendered inherited text on the filled CTA until this // compound selector matched/exceeded the reset specificity. - const rule = ruleBody( - v2, - '.v2-root a.v2-first-run__setup,\n.v2-root button.v2-first-run__hello', - ); - expect(rule).toContain('background: var(--v2-ink)'); - expect(rule).toContain('color: var(--v2-on-ink)'); + // The shared key rule lists the anchor on its second line, so read its own, later block. + expect(lastRuleBody(v2, '.v2-root a.v2-first-run__setup')).toContain('color: var(--v2-text-primary)'); + expect(lastRuleBody(v2, '.v2-root a.v2-first-run__setup')).toContain('linear-gradient(180deg, var(--v2-key-a), var(--v2-key-b))'); + expect(ruleBody(v2, '.v2-root button.v2-first-run__hello')).toContain('color: var(--v2-on-fill)'); + }); + + test('the first-run dialog is a bottom sheet on a phone, its waiting dot still under reduced motion', () => { + // D215 phone: the dialog sits on the bottom edge with 44px touch targets. + const phone = mediaBlockContaining(v2, '.v2-first-run__overlay {'); + expect(ruleBody(v2, '.v2-first-run__overlay', { within: phone })).toContain('place-items: end center'); + expect(ruleBody(v2, '.v2-root button.v2-first-run__skip', { within: phone })).toContain('height: 44px'); + expect(phone).toMatch(/\.v2-root a\.v2-first-run__setup,\n\s+\.v2-root button\.v2-first-run__hello \{\s+height: 44px;/); + expect(ruleBody(v2, '.v2-first-run__title')).toContain('text-wrap: balance'); + expect(ruleBody(v2, '.v2-first-run__dot::after')).toContain('animation: v2-presence-pulse'); + expect(v2).toMatch(/@media \(prefers-reduced-motion: reduce\) \{\s+\.v2-first-run__dot::after \{ animation: none; \}/); }); test('Settings is the one home for the folded self-profile and device routes', () => { @@ -2270,7 +2279,8 @@ describe('v2 layout invariants (CSS rule presence)', () => { describe('Signal chat + inspector color contract (TASK-124)', () => { // The composer's send and mode buttons left with the old composer (TASK-758). const filledControls = [ - '.v2-root button.v2-first-run__hello', + // The first-run hello takes the Signal fill, the ink gradient (D215 keys). + '\n.v2-root button.v2-first-run__hello {', '.v2-syscard__cta', '.v2-root button.v2-approval__btn--approve', ]; @@ -2278,7 +2288,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { test('every ordinary filled control uses ink, never cobalt', () => { for (const selector of filledControls) { const rule = selectorRuleBody(v2, selector); - expect(rule).toContain('var(--v2-ink)'); + expect(rule).toMatch(/var\(--v2-(?:ink|fill-a)\)/); expect(rule).not.toContain('var(--v2-accent)'); } }); diff --git a/frontend/src/v2/components/V2FirstRunHero.tsx b/frontend/src/v2/components/V2FirstRunHero.tsx index 115d4d5914..7cae7b740c 100644 --- a/frontend/src/v2/components/V2FirstRunHero.tsx +++ b/frontend/src/v2/components/V2FirstRunHero.tsx @@ -5,9 +5,9 @@ import { useNavigate } from 'react-router-dom'; import { useV2Api } from '../hooks/useV2Api'; import { useTranslation } from 'react-i18next'; import { FIRST_RUN_REOPEN_EVENT, FIRST_RUN_STARTED_KEY } from '../firstRunGuide'; +import { ShellGlyph } from '../icons/glyphs'; const POLL_INTERVAL_MS = 3_000; -const CHECK_MARK = '✓'; const EXTERNAL_LINK_MARK = '↗'; export { FIRST_RUN_STARTED_KEY } from '../firstRunGuide'; @@ -48,7 +48,7 @@ const writeFlag = (key: string, value: boolean): void => { const StepMark: React.FC<{ done: boolean; children: React.ReactNode }> = ({ done, children }) => ( ); @@ -281,11 +281,13 @@ const V2FirstRunHero: React.FC = ({ onVisibilityChange }) =
  • 2
    - {status?.connected ? t('firstRun.steps.start.connected') : t('firstRun.steps.start.title')} - - {status?.connected ? t('firstRun.steps.start.connectedStatus') : t('firstRun.steps.start.waiting')} + {t('firstRun.steps.start.title')} + + {status?.connected + ? <>{t('firstRun.steps.start.connected')} + : <> - {statusError && {statusError}} + {statusError && {statusError}}
  • @@ -294,17 +296,18 @@ const V2FirstRunHero: React.FC = ({ onVisibilityChange }) =
    {t('firstRun.steps.hello.title')} {t('firstRun.steps.hello.text')} - {status?.connected && status.connectedAgent && ( - - )} - {roomError && {roomError}} + {/* Shown before the agent connects, disabled, so the last step + reads as one click away (D215 waiting frame). */} + + {roomError && {roomError}}
    diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index a78b84dbb6..1c5e9179c3 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -1181,30 +1181,27 @@ body.modern-ui.v2-canvas { place-items: center; overflow-y: auto; padding: 24px; - background: rgba(15, 23, 42, 0.42); + background: var(--v2-scrim); } +/* D215 .v2-first-run: the pop surface at 16px, its edge carried by the pop + shadow rather than a border. */ .v2-first-run { width: min(720px, 100%); max-height: calc(100dvh - 48px); overflow-y: auto; padding: 28px; - border: 1px solid var(--v2-border); - border-radius: var(--v2-radius-lg); + border-radius: 16px; background: var(--v2-surface); color: var(--v2-text-primary); - box-shadow: var(--v2-shadow-md, 0 8px 24px rgba(15, 23, 42, 0.12)); + box-shadow: var(--v2-shadow-pop); outline: none; } .v2-first-run__eyebrow { margin-bottom: 8px; - color: var(--v2-text-tertiary); - font-family: var(--v2-font-mono); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.09em; - text-transform: uppercase; + color: var(--v2-text-muted); + font: 500 11.5px/16px var(--v2-font-mono); } .v2-first-run__heading-row { @@ -1216,9 +1213,11 @@ body.modern-ui.v2-canvas { .v2-first-run__title { margin: 0; - font-size: clamp(22px, 3vw, 30px); - line-height: 1.15; + color: var(--v2-text-primary); + font: 700 28px/1.15 var(--v2-font-display); letter-spacing: -0.025em; + /* Two even lines on a phone, not a lone last word. */ + text-wrap: balance; } .v2-first-run__lede { @@ -1230,33 +1229,38 @@ body.modern-ui.v2-canvas { } .v2-root button.v2-first-run__skip { - flex-shrink: 0; - padding: 4px 0; - color: var(--v2-text-tertiary); - font-size: 12px; - font-weight: 600; + flex: none; + display: inline-flex; + align-items: center; + height: 32px; + padding: 0 10px; + border-radius: var(--v2-r-9); + color: var(--v2-text-secondary); + font: 600 12.5px/1 var(--v2-font); } .v2-root button.v2-first-run__skip:hover { + background: var(--v2-surface-tint); color: var(--v2-text-primary); } .v2-first-run__steps { display: grid; - gap: 14px; - margin: 24px 0 0; + gap: 10px; + margin: 22px 0 0; padding: 0; list-style: none; } .v2-first-run__step { display: grid; - grid-template-columns: 30px minmax(0, 1fr); + grid-template-columns: 28px minmax(0, 1fr); gap: 12px; align-items: start; padding: 14px; - border-radius: var(--v2-radius); + border-radius: var(--v2-r-12); background: var(--v2-bg-subtle); + box-shadow: inset 0 0 0 1px var(--v2-border-soft); } .v2-first-run__step-mark { @@ -1264,18 +1268,17 @@ body.modern-ui.v2-canvas { place-items: center; width: 28px; height: 28px; - border: 1px solid var(--v2-border); border-radius: 50%; background: var(--v2-surface); + box-shadow: inset 0 0 0 1px var(--v2-border); color: var(--v2-text-secondary); - font-size: 12px; - font-weight: 700; + font: 600 12px/1 var(--v2-font-mono); } .v2-first-run__step-mark--done { - border-color: var(--v2-border); - background: var(--v2-surface); - color: var(--v2-accent); + background: var(--v2-accent-soft); + box-shadow: inset 0 0 0 1px var(--v2-accent); + color: var(--v2-accent-text); } .v2-first-run__step-body { @@ -1292,52 +1295,95 @@ body.modern-ui.v2-canvas { .v2-first-run__step-body strong { color: var(--v2-text-primary); font-size: 14px; + font-weight: 600; } -.v2-root a.v2-first-run__setup, -.v2-root button.v2-first-run__hello { +/* D215 keys: setup is the key material, hello the one filled action + (TASK-647 item 4: the card keeps one ink action). */ +.v2-root button.v2-first-run__hello, +.v2-root a.v2-first-run__setup { display: inline-flex; align-items: center; - gap: 6px; - min-height: 34px; + gap: 7px; + height: 32px; margin-top: 6px; - padding: 8px 12px; - border-radius: var(--v2-radius-sm); - background: var(--v2-ink); - color: var(--v2-on-ink); - font-size: 12px; - font-weight: 700; + padding: 0 13px; + border-radius: var(--v2-r-10); + font: 600 13px/1 var(--v2-font); text-decoration: none; } .v2-root a.v2-first-run__setup { - /* Bordered, not ink: the first-run card keeps one ink action (TASK-647 item 4). */ - background: var(--v2-surface); - border: 1px solid var(--v2-border); + border: 1px solid var(--v2-key-edge); + background: linear-gradient(180deg, var(--v2-key-a), var(--v2-key-b)); + box-shadow: var(--v2-key-inset); color: var(--v2-text-primary); } .v2-root a.v2-first-run__setup:hover { - background: var(--v2-surface-hover); border-color: var(--v2-border-strong); } +.v2-root button.v2-first-run__hello { + border: 1px solid var(--v2-fill-edge); + background: linear-gradient(180deg, var(--v2-fill-a), var(--v2-fill-b)); + color: var(--v2-on-fill); +} .v2-root button.v2-first-run__hello:hover:not(:disabled) { background: var(--v2-ink-hover); - color: var(--v2-on-ink); } +/* Disabled before the agent connects, as drawn; busy while the room opens. */ .v2-root button.v2-first-run__hello:disabled { + cursor: default; + opacity: 0.45; +} +.v2-root button.v2-first-run__hello[aria-busy="true"] { cursor: wait; opacity: 0.65; } +.v2-first-run__waiting, +.v2-first-run__connected, +.v2-first-run__error { + display: inline-flex; + align-items: center; + gap: 8px; +} + +/* The waiting dot is the presence dot: cobalt, with the shared pulse. */ +.v2-first-run__dot { + position: relative; + flex: none; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--v2-accent); +} +.v2-first-run__dot::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 50%; + border: 2px solid var(--v2-accent); + opacity: 0; + animation: v2-presence-pulse var(--motion-pulse) var(--motion-ease-state) infinite; +} +@media (prefers-reduced-motion: reduce) { + .v2-first-run__dot::after { animation: none; } +} + .v2-first-run__connected { - color: var(--v2-success); - font-weight: 700; + color: var(--v2-accent-text); + font-weight: 600; } .v2-first-run__error { + color: var(--v2-text-primary); + font-size: 12.5px; +} + +.v2-first-run__error svg { + flex: none; color: var(--v2-danger); - font-size: 12px; } @keyframes v2-typing-dot { @@ -2696,8 +2742,10 @@ body.modern-ui.v2-canvas { grid-template-columns: var(--v2-rail-w) minmax(0, 1fr); } + /* D215 phone: a sheet on the bottom edge, 44px touch targets. */ .v2-first-run__overlay { padding: 12px; + place-items: end center; } .v2-first-run { @@ -2710,8 +2758,20 @@ body.modern-ui.v2-canvas { gap: 8px; } + .v2-first-run__title { + font-size: 24px; + } + .v2-root button.v2-first-run__skip { align-self: flex-start; + height: 44px; + padding-left: 0; + } + + .v2-root a.v2-first-run__setup, + .v2-root button.v2-first-run__hello { + height: 44px; + font-size: 14px; } /* Hide secondary panes — but NOT the pods sidebar OR the inspector, which From e6158f3d50bca9fa8a105bc7ecf12c2e25bee40c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:53:25 -0800 Subject: [PATCH 2/2] test(first-run): Say hello is disabled while waiting and enabled once connected Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- frontend/src/v2/__tests__/V2FirstRunHero.test.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx b/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx index 05e8ce62cd..35678e2f70 100644 --- a/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx +++ b/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx @@ -149,6 +149,8 @@ describe('V2FirstRunHero', () => { expect(screen.getByRole('dialog', { name: 'Bring your agent into your pod' })) .toHaveAttribute('aria-modal', 'true'); expect(screen.getByText('Waiting for your agent to connect…')).toBeInTheDocument(); + // The last step is drawn while waiting, but cannot be used yet. + expect(screen.getByRole('button', { name: 'Say hello' })).toBeDisabled(); expect(screen.getByRole('link', { name: /Open connection setup/i })).toHaveAttribute('target', '_blank'); await act(async () => { @@ -350,10 +352,13 @@ describe('V2FirstRunHero', () => { .toBeInTheDocument(); expect(localStorage.getItem(FIRST_RUN_STARTED_KEY)).toBe('1'); await flush(); - expect(await screen.findByRole( + const control = await screen.findByRole( status.connected ? 'button' : 'link', { name: readyControl }, - )).toBeInTheDocument(); + ); + // Say hello is drawn from the start, so only an enabled one means connected. + if (status.connected) await waitFor(() => expect(control).toBeEnabled()); + else expect(screen.getByRole('button', { name: 'Say hello' })).toBeDisabled(); fireEvent.click(screen.getByRole('button', { name: 'Skip for now' })); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); @@ -366,7 +371,8 @@ describe('V2FirstRunHero', () => { renderHero(); await flush(); - expect(await screen.findByRole('button', { name: 'Say hello' })).toBeInTheDocument(); + const hello = await screen.findByRole('button', { name: 'Say hello' }); + await waitFor(() => expect(hello).toBeEnabled()); }); test('a Guide request made before the hero mounts opens it on the shell', async () => {