From c1fe3a071191057e49aa3408efc8cf57df184a20 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:40:08 -0800 Subject: [PATCH 1/4] docs(design): d215 s-cli-authorize frames, the CLI sign-in page in the auth card family Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- docs/design/artboards/d215/index.html | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/design/artboards/d215/index.html b/docs/design/artboards/d215/index.html index b560b863a..de137fd19 100644 --- a/docs/design/artboards/d215/index.html +++ b/docs/design/artboards/d215/index.html @@ -706,6 +706,15 @@ .authcard a { text-decoration: none; } .authcard .alt a { color: var(--c-text); font-weight: 600; } .authcard .sub { margin: 0; font-size: 13.5px; line-height: 1.5; color: var(--se); } +.authcard .field.code { font: 600 18px/1 var(--f-mono); letter-spacing: 0.08em; } +.authcard .facts { margin: 0; border-radius: 10px; box-shadow: inset 0 0 0 1px var(--d); } +.authcard .facts div { display: flex; justify-content: space-between; gap: 16px; padding: 10px 14px; border-top: 1px solid var(--d); font-size: 13px; } +.authcard .facts div:first-child { border-top: 0; } +.authcard .facts dt { color: var(--se); } +.authcard .facts dd { margin: 0; font: 500 12.5px/1.4 var(--f-mono); color: var(--ink); text-align: right; overflow-wrap: anywhere; } +.authcard h1 .host { white-space: nowrap; } +.authcard .keys2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.authcard .sub { margin: 0; font-size: 13.5px; line-height: 1.5; color: var(--se); } .authcard .hint { margin: 0; font-size: 13px; line-height: 1.5; color: var(--se); } .authcard .hint a, .authcard .legal a { color: var(--c-text); font-weight: 600; } .authcard .legal { font-size: 12px; line-height: 1.5; color: var(--mu); text-align: center; } @@ -1303,6 +1312,12 @@

Same look. Less said.

Choose a new password

/v2/reset-password?token=…
New password · 1440
commonly

Choose a new password

New password
••••••••••
Confirm password
••••••••••
Updated · 1440
commonly

Choose a new password

Password updated. You can sign in now.
New password · 390
commonly

Choose a new password

New password
••••••••••
Confirm password
••••••••••
Mismatch · 390
commonly

Choose a new password

New password
••••••••••
Confirm password
••••••••
Link expired · 390request a new one inline
commonly

Choose a new password

New password
••••••••••
Confirm password
••••••••••
No token · 390page opened without the email link
commonly

Choose a new password

This page needs the reset link from your email. Request a new one below.

+
+

CLI sign-in

/cli/authorize?code=…
+
  • The sign-in card again. commonly login opens this page from the terminal, so it carries the same brand row, card, field and keys as sign-in. The lettered “C” tile and the all-caps eyebrow are gone; the mark is the product mark.
  • The code is the field. Mono, 18px, spaced, so it reads against the terminal at a glance. Signed out, it shows read-only above Sign in to continue, and comes back filled.
  • Approving is the one decision. What is asking (client, instance, when) sits in a bordered list; the caution is the notice row with the info glyph, not a warning colour; Deny and Authorize share the row, Authorize in ink.

Each state, one line

Enter codeDrawn. Signed in, from a link without a code or by hand.
Signed outNot drawn. The field is read-only and the key is Sign in to continue.
ApproveDrawn. After Continue finds a pending request.
AuthorizedDrawn. Title and a status row.
Denied, expired, used, errorNot drawn. Same as Authorized: the title says which, and one row says what to do in the terminal.
Enter code · 1440signed in
commonly

Authorize Commonly CLI

Enter the code shown in your terminal.

Device code
WDJB-MJHT
+
Enter code · 390
commonly

Authorize Commonly CLI

Enter the code shown in your terminal.

Device code
WDJB-MJHT
Approve · 390a pending request
commonly

Authorize alex-mbp as @alex?

This device is asking to use your Commonly account.

Client
commonly-cli 0.1.84
Instance
api.commonly.me
Requested
just now
Only approve a code you requested from your own terminal.
Authorized · 390
commonly

Device authorized

You can return to your terminal.
+
+

Dark mode

one dark value per token
From 566e5a71fe14a82aa220f4431d87d3eb4619d0df Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:44:26 -0800 Subject: [PATCH 2/4] feat(cli-authorize): the CLI sign-in page on the shared auth card (d215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /cli/authorize now draws the d215 s-cli-authorize frames: the auth card family's brand row, title, subtitle and field; the device code in mono 18px; the approve state's facts as a bordered list, the caution as a notice row with the info glyph (role="note"), and Deny / Authorize as ghost and ink keys on one row. Done, denied, expired and used are a title plus a status row; error is an alert row. The lettered "C" tile and the CLI SIGN-IN eyebrow are gone. Signed out, the read-only code sits above a "Sign in to continue" key that navigates to /v2/login?next=… with the code. Logic is unchanged. The page mounts outside V2App (body has modern-ui, not v2-canvas), so V2CliAuthorize.css restates the input and key properties App.css's legacy rules set, at higher specificity; everything else comes from .v2-login in v2.css, which is also the page's scroller. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- .../src/v2/__tests__/V2CliAuthorize.test.tsx | 119 ++++++++++-- .../src/v2/__tests__/signalAccent.test.ts | 33 ++-- .../v2/__tests__/v2-layout-invariants.test.ts | 24 ++- frontend/src/v2/components/V2CliAuthorize.css | 173 +++++++++++------- frontend/src/v2/components/V2CliAuthorize.tsx | 171 +++++++++++------ 5 files changed, 350 insertions(+), 170 deletions(-) diff --git a/frontend/src/v2/__tests__/V2CliAuthorize.test.tsx b/frontend/src/v2/__tests__/V2CliAuthorize.test.tsx index ab83dace7..28a153812 100644 --- a/frontend/src/v2/__tests__/V2CliAuthorize.test.tsx +++ b/frontend/src/v2/__tests__/V2CliAuthorize.test.tsx @@ -1,7 +1,7 @@ // @ts-nocheck import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import axios from 'axios'; import { AuthContext } from '../../context/AuthContext'; import V2CliAuthorize from '../components/V2CliAuthorize'; @@ -28,6 +28,34 @@ const renderPage = (path = '/cli/authorize?code=ABCD-EFGH', value = auth) => ren , ); +const LoginProbe = () => { + const location = useLocation(); + return

{location.pathname + location.search}

; +}; + +const renderWithLogin = (path, value) => render( + + + + } /> + } /> + + + , +); + +const signedOut = { ...auth, currentUser: null, user: null, token: null, isAuthenticated: false }; + +// The d215 auth card: the product mark's brand row, never the old lettered +// tile or the all-caps eyebrow. +const expectAuthCard = (container) => { + expect(container.querySelector('main.v2-root.v2-cli-authorize .v2-login .v2-login__card.v2-login__card--stack')).not.toBeNull(); + expect(container.querySelector('.v2-login__brand')).not.toBeNull(); + expect(container.querySelector('.v2-cli-authorize__mark')).toBeNull(); + expect(screen.queryByText('CLI SIGN-IN')).toBeNull(); + expect(screen.queryByText('C', { exact: true })).toBeNull(); +}; + afterEach(() => jest.clearAllMocks()); describe('V2CliAuthorize', () => { @@ -35,28 +63,69 @@ describe('V2CliAuthorize', () => { axios.post .mockResolvedValueOnce({ data: { status: 'pending', request: { hostname: 'sam-laptop', clientName: 'commonly-cli', clientVersion: '0.1.26', createdAt: '2026-08-31T00:00:00.000Z' } } }) .mockResolvedValueOnce({ data: { status: 'authorized' } }); - renderPage(); + const { container } = renderPage(); - expect(screen.getByLabelText('Device code')).toHaveValue('ABCD-EFGH'); + expectAuthCard(container); + expect(screen.getByRole('heading', { level: 1, name: 'Authorize Commonly CLI' })).toHaveClass('v2-login__title'); + expect(screen.getByText('Enter the code shown in your terminal.')).toHaveClass('v2-login__subtitle'); + const input = screen.getByLabelText('Device code'); + expect(input).toHaveValue('ABCD-EFGH'); + expect(input).toHaveClass('v2-login__input', 'v2-cli-authorize__code'); + expect(input.closest('label')).toHaveClass('v2-login__field'); + expect(screen.getByRole('button', { name: 'Continue' })).toHaveClass('v2-login__submit'); fireEvent.click(screen.getByRole('button', { name: 'Continue' })); await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/auth/device/authorize', { userCode: 'ABCD-EFGH' })); - expect(await screen.findByText('Authorize sam-laptop as @lily?')).toBeInTheDocument(); - expect(screen.getByText('CLI SIGN-IN')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Authorize' })); + const title = await screen.findByRole('heading', { level: 1, name: 'Authorize sam-laptop as @lily?' }); + expectAuthCard(container); + expect(title.querySelector('.v2-cli-authorize__host')).toHaveTextContent('sam-laptop'); + const facts = container.querySelector('dl.v2-cli-authorize__facts'); + expect(facts).not.toBeNull(); + expect(Array.from(facts.querySelectorAll('dt')).map((dt) => dt.textContent)).toEqual(['Client', 'Instance', 'Requested']); + expect(screen.getByText('commonly-cli 0.1.26').tagName).toBe('DD'); + const caution = screen.getByRole('note'); + expect(caution).toHaveClass('v2-login__notice'); + expect(caution).toHaveTextContent('Only approve a code you requested from your own terminal.'); + expect(caution.querySelector('svg')).not.toBeNull(); + const deny = screen.getByRole('button', { name: 'Deny' }); + const authorize = screen.getByRole('button', { name: 'Authorize' }); + expect(deny).toHaveClass('v2-login__submit', 'v2-login__submit--ghost'); + expect(authorize).toHaveClass('v2-login__submit'); + expect(authorize).not.toHaveClass('v2-login__submit--ghost'); + expect(deny.parentElement).toHaveClass('v2-cli-authorize__keys'); + expect(authorize.parentElement).toBe(deny.parentElement); + + fireEvent.click(authorize); await waitFor(() => expect(axios.post).toHaveBeenLastCalledWith('/api/auth/device/authorize', { userCode: 'ABCD-EFGH', decision: 'authorize', })); - expect(await screen.findByText('Device authorized')).toBeInTheDocument(); + expect(await screen.findByRole('heading', { level: 1, name: 'Device authorized' })).toBeInTheDocument(); + const status = screen.getByRole('status'); + expect(status).toHaveClass('v2-login__notice'); + await waitFor(() => expect(status).toHaveTextContent('You can return to your terminal.')); }); test('signed-out users get an explicit return path after sign-in', () => { - renderPage('/cli/authorize?code=ABCD-EFGH', { ...auth, currentUser: null, user: null, token: null, isAuthenticated: false }); - expect(screen.getByRole('link', { name: 'Sign in to continue' })).toHaveAttribute( - 'href', - expect.stringContaining('next=%2Fcli%2Fauthorize%3Fcode%3DABCD-EFGH'), - ); + const { container } = renderWithLogin('/cli/authorize?code=ABCD-EFGH', signedOut); + expectAuthCard(container); + expect(screen.getByRole('heading', { level: 1, name: 'Authorize Commonly CLI' })).toBeInTheDocument(); expect(screen.getByLabelText('Device code')).toBeDisabled(); + expect(screen.getByLabelText('Device code')).toHaveValue('ABCD-EFGH'); + expect(screen.queryByRole('link', { name: 'Sign in to continue' })).toBeNull(); + const key = screen.getByRole('button', { name: 'Sign in to continue' }); + expect(key).toHaveClass('v2-login__submit'); + + fireEvent.click(key); + expect(screen.getByTestId('login-probe')).toHaveTextContent( + '/v2/login?next=%2Fcli%2Fauthorize%3Fcode%3DABCD-EFGH', + ); + }); + + test('the session check is the card with one status line', () => { + const { container } = renderPage('/cli/authorize?code=ABCD-EFGH', { ...signedOut, loading: true }); + expectAuthCard(container); + expect(screen.getByRole('status')).toHaveTextContent('Checking your session…'); + expect(screen.queryByRole('heading', { level: 1 })).toBeNull(); }); test('shows the request instance and the expired state for signed-in users', async () => { @@ -69,7 +138,10 @@ describe('V2CliAuthorize', () => { fireEvent.click(screen.getByRole('button', { name: 'Continue' })); expect(await screen.findByText('self-hosted.example')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Authorize' })); - expect(await screen.findByText('Code expired')).toBeInTheDocument(); + expect(await screen.findByRole('heading', { level: 1, name: 'Code expired' })).toBeInTheDocument(); + const status = screen.getByRole('status'); + await waitFor(() => expect(status).toHaveTextContent('Return to your terminal and run commonly login again.')); + expect(status.querySelector('code')).toHaveTextContent('commonly login'); axios.defaults.baseURL = ''; }); @@ -78,6 +150,25 @@ describe('V2CliAuthorize', () => { renderPage(); fireEvent.click(screen.getByRole('button', { name: 'Continue' })); - expect(await screen.findByText('Code already used')).toBeInTheDocument(); + expect(await screen.findByRole('heading', { level: 1, name: 'Code already used' })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('This code was already approved or completed.')); + }); + + test('a denied request says no token was issued', async () => { + axios.post.mockResolvedValue({ data: { status: 'denied' } }); + renderPage(); + + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(await screen.findByRole('heading', { level: 1, name: 'Authorization denied' })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('No token was issued for this device.')); + }); + + test('a failed lookup is an alert row', async () => { + axios.post.mockRejectedValue({ response: { status: 500 } }); + renderPage(); + + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(await screen.findByRole('heading', { level: 1, name: 'Couldn’t authorize this device' })).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('Check the code and try again from your terminal.'); }); }); diff --git a/frontend/src/v2/__tests__/signalAccent.test.ts b/frontend/src/v2/__tests__/signalAccent.test.ts index f68489943..ddee0ae96 100644 --- a/frontend/src/v2/__tests__/signalAccent.test.ts +++ b/frontend/src/v2/__tests__/signalAccent.test.ts @@ -108,27 +108,24 @@ describe('Signal accent on standalone and bridged surfaces', () => { test('the CLI authorize surface mounts in .v2-root and reads tokens only', () => { // TASK-682: wrapped in .v2-root so the tokens resolve and the dark theme reaches it. + // d215: it sits on the shared auth card, so the page CSS keeps only its own pieces. const tsx = read('src/v2/components/V2CliAuthorize.tsx'); expect(tsx).toContain('
'); + expect(tsx).toContain('
{ describe('v2 layout invariants (CSS rule presence)', () => { test('/cli/authorize scrolls inside the .v2-root shell on a short viewport (TASK-682)', () => { - // .v2-root is height 100vh + overflow hidden; the page must override it or - // the lower controls (Deny / Authorize) are unreachable on a short screen. - const cli = fs.readFileSync(path.join(__dirname, '../components/V2CliAuthorize.css'), 'utf8'); - const root = /^\.v2-root\.v2-cli-authorize \{([^}]*)\}/m.exec(cli)?.[1] ?? ''; - expect(root).toContain('overflow-y: auto'); - expect(root).toContain('display: grid'); - expect(root).not.toContain('place-items: center'); - expect(/^\.v2-cli-authorize__card \{([^}]*)\}/m.exec(cli)?.[1]).toContain('margin: auto'); - // The legacy V1 form rule (0,2,2) would paint the form #111827. - expect(cli).toContain('.v2-root.v2-cli-authorize .v2-cli-authorize__card form {'); + // .v2-root is height 100vh + overflow hidden; the page must sit in a + // scroller or the lower controls (Deny / Authorize) are unreachable on a + // short screen. Since d215 that scroller is the auth family's .v2-login. + const tsx = fs.readFileSync(path.join(__dirname, '../components/V2CliAuthorize.tsx'), 'utf8'); + expect(tsx).toMatch(/
\s*
/); + const login = /^\.v2-login \{([^}]*)\}/m.exec(readV2Css())?.[1] ?? ''; + expect(login).toContain('overflow-y: auto'); + expect(login).toContain('min-height: 0'); + expect(login).not.toContain('place-items: center'); + expect(/^\.v2-login__card \{([^}]*)\}/m.exec(readV2Css())?.[1]).toContain('margin: auto'); + // The legacy V1 form rule (0,2,2) would paint the form #111827: the code + // form is display: contents inside a
card, so it draws no box. + expect(tsx).not.toContain('
carries `modern-ui` without + `v2-canvas` and App.css's legacy V1 rules reach it: + `body.modern-ui:not(.v2-canvas) input` (0,2,2), its :focus (0,3,2), + `...:not(.guide-canvas) button` (0,3,2), its :hover/:active (0,4,2). The + family's own rules sit at (0,1,0)-(0,4,1), so the overrides below restate + exactly the properties those legacy rules set, scoped through + .v2-root.v2-cli-authorize .v2-login__card to win on specificity. The card is + a
and the code form inside it is display: contents (v2.css), so the + legacy `form` rule paints no box. */ /* .v2-root is the app shell: height 100vh, overflow hidden, flex column. - This route is a page, not a shell, so it scrolls itself; the card centres - with margin:auto, which never pushes it past the top edge on a short - viewport the way place-items:center would. */ -.v2-root.v2-cli-authorize { - height: 100vh; - overflow-y: auto; - display: grid; - padding: 24px; + .v2-login (flex: 1, overflow-y: auto) is the scroller inside it, and the + card centres with margin: auto, so a short viewport scrolls instead of + clipping Deny / Authorize. Nothing to add here; the invariant test pins it. */ + +/* The code reads against the terminal at a glance: mono, 18px, spaced. */ +.v2-root.v2-cli-authorize .v2-login__card input.v2-cli-authorize__code { + margin: 0; + padding: 0 12px; + border: 0; + border-radius: var(--v2-r-10); background: var(--v2-bg-subtle); color: var(--v2-text-primary); - font-family: var(--v2-font); + font: 600 18px/1 var(--v2-font-mono); + letter-spacing: 0.08em; +} +.v2-root.v2-cli-authorize .v2-login__card input.v2-cli-authorize__code:focus { + border: 0; + box-shadow: inset 0 0 0 1px var(--v2-accent), var(--v2-focus-ring); +} +/* Signed out the field is read-only: full ink, not the browser's grey. */ +.v2-root.v2-cli-authorize .v2-login__card input.v2-cli-authorize__code:disabled { + opacity: 1; + -webkit-text-fill-color: var(--v2-text-primary); + cursor: default; +} + +/* The keys: the family's ink fill and ghost key, restated over App.css. */ +.v2-root.v2-cli-authorize .v2-login__card button.v2-login__submit { + padding: 0 14px; + border: 1px solid var(--v2-fill-edge); + border-radius: var(--v2-r-10); + background-color: var(--v2-fill-b); + color: var(--v2-on-fill); + font-size: 14px; + font-weight: 600; + transform: none; +} +.v2-root.v2-cli-authorize .v2-login__card button.v2-login__submit--ghost { + border-color: var(--v2-key-edge); + background-color: var(--v2-key-b); + color: var(--v2-text-primary); +} +.v2-root.v2-cli-authorize .v2-login__card button.v2-login__submit:hover:not(:disabled) { + background-color: var(--v2-ink-hover); + transform: none; +} +.v2-root.v2-cli-authorize .v2-login__card button.v2-login__submit--ghost:hover:not(:disabled) { + background-color: var(--v2-key-b); +} +.v2-root.v2-cli-authorize .v2-login__card button.v2-login__submit:active { + transform: none; } -.v2-cli-authorize__card { - width: min(100%, 460px); - margin: auto; - padding: 32px; - border: 1px solid var(--v2-border); - border-radius: var(--v2-radius-lg); - background: var(--v2-surface); +/* The hostname never breaks at its hyphen. */ +.v2-cli-authorize__host { + white-space: nowrap; } -/* Outranks App.css's legacy `body.modern-ui:not(.v2-canvas) form` (0,2,2). */ -.v2-root.v2-cli-authorize .v2-cli-authorize__card form { max-width: none; margin: 0; padding: 0; border: 0; border-radius: 0; background: transparent; box-shadow: none; } +/* What is asking: a bordered list, label left, value mono right. */ +.v2-cli-authorize__facts { + margin: 0; + border-radius: var(--v2-r-10); + box-shadow: inset 0 0 0 1px var(--v2-border-soft); +} +.v2-cli-authorize__facts div { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 10px 14px; + border-top: 1px solid var(--v2-border-soft); + font-size: 13px; +} +.v2-cli-authorize__facts div:first-child { + border-top: 0; +} +.v2-cli-authorize__facts dt { + color: var(--v2-text-secondary); +} +.v2-cli-authorize__facts dd { + margin: 0; + font: 500 12.5px/1.4 var(--v2-font-mono); + color: var(--v2-text-primary); + text-align: right; + overflow-wrap: anywhere; +} -.v2-cli-authorize__mark { +/* Deny and Authorize share the row. */ +.v2-cli-authorize__keys { display: grid; - width: 36px; - height: 36px; - place-items: center; - border-radius: var(--v2-radius-lg); - background: var(--v2-accent-fill); - color: var(--v2-on-accent); - font-weight: 800; + grid-template-columns: 1fr 1fr; + gap: 10px; } -.v2-cli-authorize__eyebrow { margin: 16px 0 0 !important; color: var(--v2-accent-text) !important; font-size: 11px; font-weight: 800; letter-spacing: .11em; } -.v2-cli-authorize__card h1 { margin: 18px 0 8px; font-size: 24px; } -.v2-cli-authorize__card p { margin: 0 0 20px; color: var(--v2-text-secondary); line-height: 1.5; } -.v2-cli-authorize__field { display: grid; gap: 7px; margin-bottom: 18px; font-size: 13px; font-weight: 700; } -.v2-root.v2-cli-authorize .v2-cli-authorize__field input { width: 100%; box-sizing: border-box; margin: 0; padding: 12px; border: 1px solid var(--v2-border-strong); border-radius: var(--v2-radius); background: var(--v2-surface); color: var(--v2-text-primary); font: 600 18px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; } -.v2-root.v2-cli-authorize .v2-cli-authorize__field input:focus { outline: none; border-color: var(--v2-accent); box-shadow: var(--v2-focus-ring); } -.v2-root.v2-cli-authorize .v2-cli-authorize__card .v2-cli-authorize__primary, .v2-root.v2-cli-authorize .v2-cli-authorize__card .v2-cli-authorize__secondary { display: inline-flex; justify-content: center; align-items: center; min-height: 42px; padding: 0 16px; border-radius: var(--v2-radius); font: inherit; font-weight: 700; text-decoration: none; cursor: pointer; } -.v2-root.v2-cli-authorize .v2-cli-authorize__card .v2-cli-authorize__primary { border: 1px solid var(--v2-accent-fill); background: var(--v2-accent-fill); color: var(--v2-on-accent); } -.v2-root.v2-cli-authorize .v2-cli-authorize__card .v2-cli-authorize__primary:disabled { cursor: wait; opacity: .65; } -.v2-root.v2-cli-authorize .v2-cli-authorize__card .v2-cli-authorize__secondary { border: 1px solid var(--v2-border-strong); background: var(--v2-surface); color: var(--v2-text-secondary); } -.v2-root.v2-cli-authorize .v2-cli-authorize__card .v2-cli-authorize__primary:hover, .v2-root.v2-cli-authorize .v2-cli-authorize__card .v2-cli-authorize__primary:active { background: var(--v2-accent-fill); transform: none; } -.v2-root.v2-cli-authorize .v2-cli-authorize__card .v2-cli-authorize__secondary:hover, .v2-root.v2-cli-authorize .v2-cli-authorize__card .v2-cli-authorize__secondary:active { background: var(--v2-surface-hover); transform: none; } -.v2-cli-authorize__facts { margin: 0 0 18px; padding: 14px; border-radius: var(--v2-radius-lg); background: var(--v2-bg-subtle); } -.v2-cli-authorize__facts div { display: flex; justify-content: space-between; gap: 18px; padding: 5px 0; } -.v2-cli-authorize__facts dt { color: var(--v2-text-muted); } -.v2-cli-authorize__facts dd { margin: 0; font-weight: 600; text-align: right; } -.v2-cli-authorize__warning { padding: 10px 12px; border-radius: var(--v2-radius); background: var(--v2-warning-soft); color: var(--v2-warning-ink) !important; font-size: 13px; } -.v2-cli-authorize__actions { display: flex; justify-content: flex-end; gap: 10px; } +.v2-cli-authorize .v2-login__notice code { + font: 500 12.5px/1 var(--v2-font-mono); +} +/* The 44px touch floor and 16px type the family holds at phone width, + restated because the key rule above outranks v2.css's. */ @media (max-width: 480px) { - .v2-root.v2-cli-authorize { padding: 16px; } - .v2-cli-authorize__card { margin: 0 auto auto; } - .v2-cli-authorize__card { padding: 24px 20px; } - .v2-cli-authorize__facts div { display: grid; gap: 2px; } - .v2-cli-authorize__facts dd { text-align: left; } - .v2-cli-authorize__actions { display: grid; grid-template-columns: 1fr 1fr; } + .v2-root.v2-cli-authorize .v2-login__card button.v2-login__submit { + min-height: 44px; + font-size: 16px; + } } diff --git a/frontend/src/v2/components/V2CliAuthorize.tsx b/frontend/src/v2/components/V2CliAuthorize.tsx index 1cf147277..eeb4a61c5 100644 --- a/frontend/src/v2/components/V2CliAuthorize.tsx +++ b/frontend/src/v2/components/V2CliAuthorize.tsx @@ -1,7 +1,10 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { Link, useLocation } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import axios from '../../utils/axiosConfig'; import { useAuth } from '../../context/AuthContext'; +import { ShellGlyph } from '../icons/glyphs'; +import V2AuthBrand from './V2AuthBrand'; +import V2AuthNotice from './V2AuthNotice'; import './V2CliAuthorize.css'; interface AuthorizationRequest { @@ -40,6 +43,7 @@ const currentInstance = () => { const V2CliAuthorize: React.FC = () => { const { isAuthenticated, loading, user } = useAuth(); const location = useLocation(); + const navigate = useNavigate(); const queryCode = useMemo(() => normalizeCode(new URLSearchParams(location.search).get('code') || ''), [location.search]); const [code, setCode] = useState(queryCode); const [screen, setScreen] = useState('code'); @@ -92,69 +96,114 @@ const V2CliAuthorize: React.FC = () => { const next = `/cli/authorize${code ? `?code=${encodeURIComponent(code)}` : ''}`; - return ( + // Layout: the d215 s-cli-authorize frames. The sign-in card family: brand + // row, title, one prose line, the code as the field, one key per state. + // The page mounts outside V2App, so it carries its own .v2-root. + const card = (children: React.ReactNode) => (
-
- -

CLI SIGN-IN

- {loading &&

Checking your session…

} - {!loading && !isAuthenticated && ( - <> -

Authorize Commonly CLI

-

Sign in to approve this device. The code stays tied to this browser tab.

- - Sign in to continue - - )} - {!loading && isAuthenticated && screen === 'code' && ( - -

Authorize Commonly CLI

-

Enter the code shown in your terminal.

- - - - )} - {!loading && isAuthenticated && screen === 'confirm' && request && ( - <> -

Authorize {request.hostname} as @{user?.username || 'you'}?

-

This device is asking to use your Commonly account.

-
-
Client
{request.clientName}{request.clientVersion ? ` ${request.clientVersion}` : ''}
-
Instance
{currentInstance()}
-
Requested
{requestAge(request.createdAt)}
-
-

Only approve a code you requested from your own terminal.

-
- - -
- - )} - {!loading && isAuthenticated && screen === 'done' && <>

Device authorized

You can return to your terminal.

} - {!loading && isAuthenticated && screen === 'denied' && <>

Authorization denied

No token was issued for this device.

} - {!loading && isAuthenticated && screen === 'expired' && <>

Code expired

Return to your terminal and run commonly login again.

} - {!loading && isAuthenticated && screen === 'used' && <>

Code already used

This code was already approved or completed. Return to your terminal.

} - {!loading && isAuthenticated && screen === 'error' && <>

Couldn’t authorize this device

Check the code and try again from your terminal.

} -
+
+
+ + {children} +
+
); + const outcome = (title: string, message: React.ReactNode, kind: 'alert' | 'status' = 'status') => card( + <> +

{title}

+ {message} + , + ); + + if (loading) { + return card(

Checking your session…

); + } + + if (!isAuthenticated) { + return card( + <> +

Authorize Commonly CLI

+

Sign in to approve this device. The code stays tied to this browser tab.

+ + + , + ); + } + + if (screen === 'code') { + return card( +
+

Authorize Commonly CLI

+

Enter the code shown in your terminal.

+ + +
, + ); + } + + if (screen === 'confirm' && request) { + return card( + <> +

+ Authorize {request.hostname} as @{user?.username || 'you'}? +

+

This device is asking to use your Commonly account.

+
+
Client
{request.clientName}{request.clientVersion ? ` ${request.clientVersion}` : ''}
+
Instance
{currentInstance()}
+
Requested
{requestAge(request.createdAt)}
+
+
+ + Only approve a code you requested from your own terminal. +
+
+ + +
+ , + ); + } + + if (screen === 'done') return outcome('Device authorized', 'You can return to your terminal.'); + if (screen === 'denied') return outcome('Authorization denied', 'No token was issued for this device.'); + if (screen === 'expired') { + return outcome('Code expired', <>Return to your terminal and run commonly login again.); + } + if (screen === 'used') { + return outcome('Code already used', 'This code was already approved or completed. Return to your terminal.'); + } + if (screen === 'error') { + return outcome('Couldn’t authorize this device', 'Check the code and try again from your terminal.', 'alert'); + } + return card(null); }; export default V2CliAuthorize; From 47264e6649c699ac277d26c5983afc8d2b4c5ed7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:54:36 -0800 Subject: [PATCH 3/4] fix(cli-authorize): claim the v2 page canvas instead of out-ranking App.css The page mounts outside V2App, so App.css's legacy input, button and form rules reached it. Those rules are already gated on body:not(.v2-canvas); the page now claims v2-canvas while mounted (and index.tsx stamps it at boot for this path), so the restated key and input overrides go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- frontend/src/index.tsx | 5 +- .../src/v2/__tests__/signalAccent.test.ts | 15 ++-- frontend/src/v2/components/V2CliAuthorize.css | 69 +++---------------- frontend/src/v2/components/V2CliAuthorize.tsx | 10 +++ 4 files changed, 29 insertions(+), 70 deletions(-) diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index f7b32a4ae..b1320c4b6 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -15,11 +15,12 @@ if (process.env.REACT_APP_SENTRY_DSN) { // CssBaseline's palette.background.default #0b1220 — injected at render), // and a class added in V2App's useEffect wins only AFTER first paint, so // every v2 load still flashed dark for a frame (Sam, 2026-08-24). '/' and -// '/v2*' are the v2 front door; V2App's effect keeps the class in sync for +// '/v2*' are the v2 front door (and /cli/authorize, a v2 page mounted outside +// V2App that keeps the class itself); V2App's effect keeps the class in sync for // SPA navigation after boot. Legacy deep links that redirect into /v2 may // still flash once — the exception, not the entry path. const bootPath = window.location.pathname; -if (bootPath === '/' || bootPath.startsWith('/v2')) { +if (bootPath === '/' || bootPath.startsWith('/v2') || bootPath === '/cli/authorize') { document.body.classList.add('v2-canvas'); } if (bootPath === '/guides' || bootPath.startsWith('/guides/')) { diff --git a/frontend/src/v2/__tests__/signalAccent.test.ts b/frontend/src/v2/__tests__/signalAccent.test.ts index ddee0ae96..98f4c12e6 100644 --- a/frontend/src/v2/__tests__/signalAccent.test.ts +++ b/frontend/src/v2/__tests__/signalAccent.test.ts @@ -119,13 +119,14 @@ describe('Signal accent on standalone and bridged surfaces', () => { expect(rules).not.toMatch(/var\(--v2-[a-z0-9-]+,/); expect(rules).not.toContain('.v2-cli-authorize__card'); expect(rules).not.toContain('--v2-warning-ink'); - // The page mounts without body.v2-canvas, so App.css's legacy input (0,2,2) - // and button (0,3,2) / :hover (0,4,2) rules reach it: the overrides must - // out-specify both, and the input must set its own ground for dark mode. - expect(rules).toMatch(/\.v2-root\.v2-cli-authorize \.v2-login__card input\.v2-cli-authorize__code \{[^}]*background: var\(--v2-bg-subtle\)/); - expect(rules).toContain('.v2-root.v2-cli-authorize .v2-login__card button.v2-login__submit {'); - expect(rules).toContain('.v2-root.v2-cli-authorize .v2-login__card button.v2-login__submit:hover:not(:disabled) {'); - expect(rules).toContain('.v2-root.v2-cli-authorize .v2-login__card button.v2-login__submit:active {'); + // The page mounts outside V2App, so it claims body.v2-canvas itself (at boot + // and while mounted). That gate is what turns off App.css's legacy + // `body.modern-ui:not(.v2-canvas)` input, button and form rules, so the page + // restates none of them. + expect(tsx).toMatch(/document\.body\.classList\.add\('v2-canvas'\)/); + expect(tsx).toMatch(/document\.body\.classList\.remove\('v2-canvas'\)/); + expect(read('src/index.tsx')).toContain("bootPath === '/cli/authorize'"); + expect(rules).not.toContain('button.v2-login__submit'); }); test.each([ diff --git a/frontend/src/v2/components/V2CliAuthorize.css b/frontend/src/v2/components/V2CliAuthorize.css index 03b74c2ca..fe25731e8 100644 --- a/frontend/src/v2/components/V2CliAuthorize.css +++ b/frontend/src/v2/components/V2CliAuthorize.css @@ -1,72 +1,28 @@ /* CLI sign-in, the d215 s-cli-authorize frames: the shared auth card (.v2-login, v2.css) plus the three things only this page draws, the code field, the facts list and the two-key row. Every value is a --v2-* token, - so the dark theme follows. + so the dark theme follows. The page mounts outside V2App, so it claims + body.v2-canvas itself while mounted, which turns off App.css's legacy + input, button and form rules exactly as it does for every /v2 page. - The page mounts outside V2App, so carries `modern-ui` without - `v2-canvas` and App.css's legacy V1 rules reach it: - `body.modern-ui:not(.v2-canvas) input` (0,2,2), its :focus (0,3,2), - `...:not(.guide-canvas) button` (0,3,2), its :hover/:active (0,4,2). The - family's own rules sit at (0,1,0)-(0,4,1), so the overrides below restate - exactly the properties those legacy rules set, scoped through - .v2-root.v2-cli-authorize .v2-login__card to win on specificity. The card is - a
and the code form inside it is display: contents (v2.css), so the - legacy `form` rule paints no box. */ - -/* .v2-root is the app shell: height 100vh, overflow hidden, flex column. + .v2-root is the app shell: height 100vh, overflow hidden, flex column. .v2-login (flex: 1, overflow-y: auto) is the scroller inside it, and the card centres with margin: auto, so a short viewport scrolls instead of clipping Deny / Authorize. Nothing to add here; the invariant test pins it. */ -/* The code reads against the terminal at a glance: mono, 18px, spaced. */ -.v2-root.v2-cli-authorize .v2-login__card input.v2-cli-authorize__code { - margin: 0; - padding: 0 12px; - border: 0; - border-radius: var(--v2-r-10); - background: var(--v2-bg-subtle); - color: var(--v2-text-primary); +/* The code reads against the terminal at a glance: mono, 18px, spaced, at + every width (it outranks the phone rule's 16px). */ +.v2-cli-authorize .v2-login__input.v2-cli-authorize__code { font: 600 18px/1 var(--v2-font-mono); letter-spacing: 0.08em; } -.v2-root.v2-cli-authorize .v2-login__card input.v2-cli-authorize__code:focus { - border: 0; - box-shadow: inset 0 0 0 1px var(--v2-accent), var(--v2-focus-ring); -} /* Signed out the field is read-only: full ink, not the browser's grey. */ -.v2-root.v2-cli-authorize .v2-login__card input.v2-cli-authorize__code:disabled { +.v2-cli-authorize .v2-login__input.v2-cli-authorize__code:disabled { opacity: 1; -webkit-text-fill-color: var(--v2-text-primary); cursor: default; } -/* The keys: the family's ink fill and ghost key, restated over App.css. */ -.v2-root.v2-cli-authorize .v2-login__card button.v2-login__submit { - padding: 0 14px; - border: 1px solid var(--v2-fill-edge); - border-radius: var(--v2-r-10); - background-color: var(--v2-fill-b); - color: var(--v2-on-fill); - font-size: 14px; - font-weight: 600; - transform: none; -} -.v2-root.v2-cli-authorize .v2-login__card button.v2-login__submit--ghost { - border-color: var(--v2-key-edge); - background-color: var(--v2-key-b); - color: var(--v2-text-primary); -} -.v2-root.v2-cli-authorize .v2-login__card button.v2-login__submit:hover:not(:disabled) { - background-color: var(--v2-ink-hover); - transform: none; -} -.v2-root.v2-cli-authorize .v2-login__card button.v2-login__submit--ghost:hover:not(:disabled) { - background-color: var(--v2-key-b); -} -.v2-root.v2-cli-authorize .v2-login__card button.v2-login__submit:active { - transform: none; -} - /* The hostname never breaks at its hyphen. */ .v2-cli-authorize__host { white-space: nowrap; @@ -110,12 +66,3 @@ .v2-cli-authorize .v2-login__notice code { font: 500 12.5px/1 var(--v2-font-mono); } - -/* The 44px touch floor and 16px type the family holds at phone width, - restated because the key rule above outranks v2.css's. */ -@media (max-width: 480px) { - .v2-root.v2-cli-authorize .v2-login__card button.v2-login__submit { - min-height: 44px; - font-size: 16px; - } -} diff --git a/frontend/src/v2/components/V2CliAuthorize.tsx b/frontend/src/v2/components/V2CliAuthorize.tsx index eeb4a61c5..5ab99aa97 100644 --- a/frontend/src/v2/components/V2CliAuthorize.tsx +++ b/frontend/src/v2/components/V2CliAuthorize.tsx @@ -52,6 +52,16 @@ const V2CliAuthorize: React.FC = () => { useEffect(() => setCode(queryCode), [queryCode]); + // The page mounts outside V2App, so it claims the v2 page canvas itself + // while mounted (index.tsx stamps it at boot for this path, too). Without + // it App.css's legacy V1 input, button and form rules reach the card. + useEffect(() => { + document.body.classList.add('v2-canvas'); + return () => { + document.body.classList.remove('v2-canvas'); + }; + }, []); + const lookup = async (event?: React.FormEvent) => { event?.preventDefault(); if (code.replace('-', '').length !== 8) return; From cdae9b7f5f241c70a5c6ee2738821befa518c8ba Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:02:02 -0800 Subject: [PATCH 4/4] test(cli-authorize): the canvas claim is rendered, not grepped; boot stamp takes a trailing slash Also drops a duplicated .authcard .sub rule from the d215 artboard. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- docs/design/artboards/d215/index.html | 1 - frontend/src/index.tsx | 2 +- frontend/src/v2/__tests__/V2CliAuthorize.test.tsx | 10 ++++++++++ frontend/src/v2/__tests__/signalAccent.test.ts | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/design/artboards/d215/index.html b/docs/design/artboards/d215/index.html index de137fd19..db47617ff 100644 --- a/docs/design/artboards/d215/index.html +++ b/docs/design/artboards/d215/index.html @@ -705,7 +705,6 @@ .authcard .alt { text-align: center; font-size: 13.5px; color: var(--se); } .authcard a { text-decoration: none; } .authcard .alt a { color: var(--c-text); font-weight: 600; } -.authcard .sub { margin: 0; font-size: 13.5px; line-height: 1.5; color: var(--se); } .authcard .field.code { font: 600 18px/1 var(--f-mono); letter-spacing: 0.08em; } .authcard .facts { margin: 0; border-radius: 10px; box-shadow: inset 0 0 0 1px var(--d); } .authcard .facts div { display: flex; justify-content: space-between; gap: 16px; padding: 10px 14px; border-top: 1px solid var(--d); font-size: 13px; } diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index b1320c4b6..83a1c3d3d 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -20,7 +20,7 @@ if (process.env.REACT_APP_SENTRY_DSN) { // SPA navigation after boot. Legacy deep links that redirect into /v2 may // still flash once — the exception, not the entry path. const bootPath = window.location.pathname; -if (bootPath === '/' || bootPath.startsWith('/v2') || bootPath === '/cli/authorize') { +if (bootPath === '/' || bootPath.startsWith('/v2') || /^\/cli\/authorize\/?$/.test(bootPath)) { document.body.classList.add('v2-canvas'); } if (bootPath === '/guides' || bootPath.startsWith('/guides/')) { diff --git a/frontend/src/v2/__tests__/V2CliAuthorize.test.tsx b/frontend/src/v2/__tests__/V2CliAuthorize.test.tsx index 28a153812..4834cb649 100644 --- a/frontend/src/v2/__tests__/V2CliAuthorize.test.tsx +++ b/frontend/src/v2/__tests__/V2CliAuthorize.test.tsx @@ -58,6 +58,16 @@ const expectAuthCard = (container) => { afterEach(() => jest.clearAllMocks()); +test('claims the v2 page canvas while mounted, and gives it back on unmount', () => { + // Outside V2App, body.v2-canvas is what switches off App.css's legacy + // input, button and form rules (all gated on body.modern-ui:not(.v2-canvas)). + document.body.classList.remove('v2-canvas'); + const { unmount } = renderPage(); + expect(document.body).toHaveClass('v2-canvas'); + unmount(); + expect(document.body).not.toHaveClass('v2-canvas'); +}); + describe('V2CliAuthorize', () => { test('prefills a terminal code, confirms the device facts, then completes approval', async () => { axios.post diff --git a/frontend/src/v2/__tests__/signalAccent.test.ts b/frontend/src/v2/__tests__/signalAccent.test.ts index 98f4c12e6..1b0cbc609 100644 --- a/frontend/src/v2/__tests__/signalAccent.test.ts +++ b/frontend/src/v2/__tests__/signalAccent.test.ts @@ -125,7 +125,7 @@ describe('Signal accent on standalone and bridged surfaces', () => { // restates none of them. expect(tsx).toMatch(/document\.body\.classList\.add\('v2-canvas'\)/); expect(tsx).toMatch(/document\.body\.classList\.remove\('v2-canvas'\)/); - expect(read('src/index.tsx')).toContain("bootPath === '/cli/authorize'"); + expect(read('src/index.tsx')).toContain("/^\\/cli\\/authorize\\/?$/.test(bootPath)"); expect(rules).not.toContain('button.v2-login__submit'); });