From b35693d41d1836cdcaa8a8133b38b5456d9c795a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:30:42 -0700 Subject: [PATCH 1/4] feat(v2): D223 shared primitives and /v2/_primitives route (TASK-730 PR2) Adds the shell, page header, keys (primary, cobalt, secondary, ghost, small, icon), segment, chip, badge, dot, toggle, grouped row list, card and drawer, lifted from the d215 artboard's product CSS. Classes carry a v2p- prefix so they do not collide with the existing v2 chip, dot and pane rules, and every colour and radius reads a --v2-* token from v2.css. Served at /v2/_primitives as a static showcase, with no data and no auth. Also removes a duplicate --v2-font-display that PR1 added to the light set: it overrode the loaded Bricolage Grotesque Variable face defined earlier in the same block. primitives.css reads only --v2-* tokens; v2Primitives.test.tsx pins that, checks every v2p- class the page uses is defined, and checks the page renders. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: wisp Agent-Model: claude/claude-haiku-5-5/default --- frontend/src/v2/V2App.tsx | 3 + .../src/v2/__tests__/v2Primitives.test.tsx | 40 +++ .../src/v2/primitives/V2PrimitivesPage.tsx | 76 ++++++ frontend/src/v2/primitives/primitives.css | 239 ++++++++++++++++++ frontend/src/v2/v2.css | 1 - 5 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 frontend/src/v2/__tests__/v2Primitives.test.tsx create mode 100644 frontend/src/v2/primitives/V2PrimitivesPage.tsx create mode 100644 frontend/src/v2/primitives/primitives.css diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx index a3a5119d81..228ef28a4f 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -25,6 +25,8 @@ import { applyV2Theme, getV2Theme } from './utils/theme'; // whole route table, so a chunk in flight shows the same the auth // check already shows. const V2Login = React.lazy(() => import('./components/V2Login')); +// TASK-730 (D223): the shared primitives rendered alone, for frame checks. +const V2PrimitivesPage = React.lazy(() => import('./primitives/V2PrimitivesPage')); const V2Register = React.lazy(() => import('./components/V2Register')); const V2OAuthComplete = React.lazy(() => import('./components/V2OAuthComplete')); const V2ForgotPassword = React.lazy(() => import('./components/V2ForgotPassword')); @@ -217,6 +219,7 @@ const V2App: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/v2/__tests__/v2Primitives.test.tsx b/frontend/src/v2/__tests__/v2Primitives.test.tsx new file mode 100644 index 0000000000..fc037f79c9 --- /dev/null +++ b/frontend/src/v2/__tests__/v2Primitives.test.tsx @@ -0,0 +1,40 @@ +import fs from 'fs'; +import path from 'path'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import V2PrimitivesPage from '../primitives/V2PrimitivesPage'; + +// TASK-730 (D223) PR2: the shared primitives must take every colour and radius +// from the --v2-* tokens (the artboard's short names are mapped in +// primitives.css's header, not copied), and every v2p- class the page uses +// must be defined in the stylesheet. + +const css = fs.readFileSync(path.join(__dirname, '../primitives/primitives.css'), 'utf8'); +const pageSrc = fs.readFileSync(path.join(__dirname, '../primitives/V2PrimitivesPage.tsx'), 'utf8'); + +describe('D223 primitives', () => { + test('primitives.css reads only --v2-* tokens, never the artboard short names', () => { + const varRefs = Array.from(css.matchAll(/var\(--([a-z0-9-]+)\)/g), (m) => m[1]); + const foreign = varRefs.filter((name) => !name.startsWith('v2-')); + expect(foreign).toEqual([]); + }); + + test('every v2p- class the page uses is defined in primitives.css', () => { + const used = new Set(); + Array.from(pageSrc.matchAll(/className="([^"]+)"/g)).forEach((m) => { + m[1].split(/\s+/).filter((c) => c.startsWith('v2p-')).forEach((c) => used.add(c)); + }); + expect(used.size).toBeGreaterThan(10); + const undefinedClasses = Array.from(used).filter((c) => !css.includes(`.${c}`)); + expect(undefinedClasses).toEqual([]); + }); + + test('the page renders the shell, header, keys, segment, chips and drawer', () => { + render(); + expect(screen.getByRole('heading', { name: 'Primitives' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Primary' })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: 'View' })).toBeInTheDocument(); + expect(screen.getByLabelText('Drawer example')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Drawer title' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/v2/primitives/V2PrimitivesPage.tsx b/frontend/src/v2/primitives/V2PrimitivesPage.tsx new file mode 100644 index 0000000000..1cf9b598d9 --- /dev/null +++ b/frontend/src/v2/primitives/V2PrimitivesPage.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import './primitives.css'; + +// TASK-730 (D223): the shared primitives rendered alone, so a surface can be +// checked against the artboard frame without the shell around it. Served at +// /v2/_primitives. Light and dark follow the page's data-v2-theme, the same as +// every other v2 surface. +const V2PrimitivesPage: React.FC = () => ( +
+
+ +
+); + +export default V2PrimitivesPage; diff --git a/frontend/src/v2/primitives/primitives.css b/frontend/src/v2/primitives/primitives.css new file mode 100644 index 0000000000..2ad786bb9a --- /dev/null +++ b/frontend/src/v2/primitives/primitives.css @@ -0,0 +1,239 @@ +/* TASK-730 (D223) shared primitives, lifted from the d215 artboard's product + CSS (docs/design/artboards/d215/index.html, `.app` block). Class names carry + a v2p- prefix so they cannot collide with the existing v2 chip, dot, pane and + button rules. Colours and shape come only from the --v2-* tokens in v2.css; + the artboard's short names map as: --sf surface, --t surface-tint, --d + border-soft, --b border, --mu text-muted, --se text-secondary, --ph + text-placeholder, --ink text-primary, --ink2 ink-2, --g page-bg, --c accent. + Pinned by __tests__/v2Primitives.test.tsx. */ + +/* ---- page wrapper for the showcase route ---- */ +.v2p-root { height: 100%; min-height: 0; display: flex; flex-direction: column; } + +/* ---- keys (buttons) ---- */ +.v2p-b { + all: unset; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + height: 34px; + padding: 0 13px; + border-radius: var(--v2-r-10); + font: 600 13px/1 var(--v2-font); + cursor: pointer; + white-space: nowrap; + transition: transform 120ms, background 160ms, box-shadow 140ms; +} +.v2p-b svg.i { width: 16px; height: 16px; } +.v2p-b:active { transform: translateY(1px); } +.v2p-b:focus-visible { box-shadow: 0 0 0 3px var(--v2-accent-soft), 0 0 0 1px var(--v2-accent); } +.v2p-b-p { + color: var(--v2-on-fill); + background: linear-gradient(180deg, var(--v2-fill-a), var(--v2-fill-b)); + border: 1px solid var(--v2-fill-edge); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16), 0 1px 2px rgba(16, 24, 40, 0.18), 0 4px 10px -3px rgba(16, 24, 40, 0.28); +} +.v2p-b-c { + color: var(--v2-on-accent); + background: linear-gradient(180deg, var(--v2-accent-fill-a), var(--v2-accent-fill-b)); + border: 1px solid var(--v2-accent-fill-b); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 4px 10px -3px rgba(29, 63, 209, 0.32); +} +.v2p-b-s { + color: var(--v2-text-primary); + background: linear-gradient(180deg, var(--v2-key-a), var(--v2-key-b)); + border: 1px solid var(--v2-key-edge); + box-shadow: var(--v2-key-inset); +} +.v2p-b-g { color: var(--v2-text-secondary); background: transparent; border: 1px solid transparent; } +.v2p-b-g:hover { background: var(--v2-surface-tint); color: var(--v2-text-primary); } +.v2p-b-sm { height: 28px; padding: 0 10px; border-radius: var(--v2-r-8); font-size: 12.5px; } +.v2p-ib { width: 32px; height: 32px; padding: 0; border-radius: var(--v2-r-8); } +.v2p-ib.v2p-b-sm { width: 28px; height: 28px; } +.v2p-ib svg.i { width: 17px; height: 17px; } + +/* ---- segmented control ---- */ +.v2p-seg { + display: inline-flex; + gap: 2px; + padding: 2px; + border-radius: var(--v2-r-9); + background: var(--v2-surface-tint); + box-shadow: inset 0 0 0 1px var(--v2-border-soft); + flex: none; +} +.v2p-seg button { + all: unset; + box-sizing: border-box; + cursor: pointer; + height: 28px; + padding: 0 10px; + border-radius: var(--v2-r-8); + display: inline-flex; + align-items: center; + gap: 6px; + font: 500 12.5px/1 var(--v2-font); + color: var(--v2-text-secondary); + white-space: nowrap; +} +.v2p-seg button .mo { font-size: 11px; color: var(--v2-text-muted); } +.v2p-seg button.on { + background: var(--v2-surface); + color: var(--v2-text-primary); + font-weight: 600; + box-shadow: 0 0 0 1px var(--v2-key-edge), 0 1px 2px rgba(16, 24, 40, 0.08); +} +.v2p-seg--sm button { height: 24px; padding: 0 8px; font-size: 12px; } + +/* ---- chips, badges, dots, toggles ---- */ +.v2p-chip { + display: inline-flex; + align-items: center; + gap: 5px; + height: 24px; + padding: 0 8px; + border-radius: var(--v2-r-8); + font: 500 11.5px/1 var(--v2-font-mono); + color: var(--v2-text-secondary); + background: var(--v2-surface); + box-shadow: inset 0 0 0 1px var(--v2-border-soft); + white-space: nowrap; +} +.v2p-chip svg.i { width: 13px; height: 13px; } +.v2p-chip.on { color: var(--v2-accent-text); box-shadow: inset 0 0 0 1px var(--v2-accent); background: var(--v2-accent-soft); } +.v2p-chip--btn { cursor: pointer; } +.v2p-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 15px; + padding: 0 4px; + border-radius: var(--v2-r-8); + background: var(--v2-accent); + color: #fff; + font: 600 9.5px/15px var(--v2-font-mono); +} +.v2p-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--v2-accent); flex: none; display: inline-block; } +.v2p-dot--idle { background: var(--v2-text-placeholder); } +.v2p-dot--off { background: transparent; box-shadow: inset 0 0 0 1.5px var(--v2-text-placeholder); } +.v2p-tg { width: 30px; height: 18px; border-radius: 9px; background: var(--v2-border); position: relative; flex: none; } +.v2p-tg::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--v2-surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} +.v2p-tg.on { background: var(--v2-accent); } +.v2p-tg.on::after { left: 14px; background: #fff; } + +/* ---- shell and header ---- */ +.v2p-sh { display: grid; gap: 12px; padding: 12px; height: 100%; background: var(--v2-page-bg); } +.v2p-sh--page { grid-template-columns: 56px minmax(0, 1fr); } +.v2p-pane { + background: var(--v2-surface); + border-radius: var(--v2-r-14); + box-shadow: var(--v2-shadow-pane); + min-width: 0; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + position: relative; +} +.v2p-ph { + flex: none; + height: 64px; + display: flex; + align-items: center; + gap: 10px; + padding: 0 20px 0 28px; + border-bottom: 1px solid var(--v2-border-soft); +} +.v2p-ph h1 { + font: 800 24px/1 var(--v2-font-display); + letter-spacing: -0.03em; + margin: 0; + white-space: nowrap; +} +.v2p-ph .meta { font: 500 12px/1 var(--v2-font-mono); color: var(--v2-text-muted); white-space: nowrap; } +.v2p-ph .sp { flex: 1; } +.v2p-body { padding: 24px 28px; display: grid; gap: 14px; align-content: start; } + +/* ---- grouped rows ---- */ +.v2p-list { + border-radius: var(--v2-r-12); + box-shadow: inset 0 0 0 1px var(--v2-border-soft); + background: var(--v2-surface); +} +.v2p-lrow { + display: grid; + grid-template-columns: 180px minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + min-height: 52px; + padding: 8px 14px 8px 16px; + border-top: 1px solid var(--v2-border-soft); +} +.v2p-lrow:first-child { border-top: 0; } +.v2p-lrow .lb { font-size: 13px; font-weight: 600; color: var(--v2-text-primary); display: flex; align-items: center; gap: 8px; } +.v2p-lrow .vl { font-size: 13.5px; color: var(--v2-ink-2); min-width: 0; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.v2p-lrow .vl .mo { font-size: 12px; color: var(--v2-text-muted); } +.v2p-lrow .ac { display: flex; gap: 6px; align-items: center; justify-content: flex-end; } +.v2p-gtitle { + font: 500 11.5px/1 var(--v2-font-mono); + color: var(--v2-text-muted); + margin: 18px 4px 8px; + display: flex; + align-items: center; + gap: 8px; +} +.v2p-gtitle:first-child { margin-top: 0; } + +/* ---- card ---- */ +.v2p-tc { + background: var(--v2-surface); + border-radius: var(--v2-r-9); + box-shadow: 0 0 0 1px var(--v2-border-soft), 0 1px 2px rgba(16, 24, 40, 0.04); + padding: 8px 10px 9px; + display: grid; + gap: 4px; + position: relative; +} +.v2p-tc .top1 { display: flex; align-items: center; gap: 6px; font: 500 11px/1 var(--v2-font-mono); color: var(--v2-text-muted); height: 16px; } +.v2p-tc .top1 .sp { flex: 1; } +.v2p-tc .tt { + font-size: 13px; + line-height: 18px; + color: var(--v2-text-primary); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.v2p-tc.picked { box-shadow: 0 0 0 2px var(--v2-accent); } + +/* ---- drawer ---- */ +.v2p-drawer { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 400px; + background: var(--v2-surface); + box-shadow: var(--v2-shadow-pop); + border-radius: var(--v2-r-14) 0 0 var(--v2-r-14); + display: flex; + flex-direction: column; + z-index: 5; +} +.v2p-drawer .dh { height: 64px; display: flex; align-items: center; gap: 8px; padding: 0 14px 0 20px; border-bottom: 1px solid var(--v2-border-soft); } +.v2p-drawer .db { padding: 18px 20px; display: grid; gap: 14px; align-content: start; } +.v2p-drawer h2 { font: 700 19px/1.25 var(--v2-font-display); letter-spacing: -0.02em; margin: 0; } diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 8b06382fbe..940126a965 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -189,7 +189,6 @@ body.modern-ui.v2-canvas { --v2-shadow-pop: 0 0 0 1px rgba(16, 24, 40, 0.08), 0 2px 4px rgba(16, 24, 40, 0.04), 0 18px 36px -14px rgba(16, 24, 40, 0.28); --v2-av-ring: rgba(16, 24, 40, 0.07); --v2-scrim: rgba(16, 24, 40, 0.18); - --v2-font-display: "Bricolage Grotesque", "Helvetica Neue", Arial, sans-serif; --v2-r-6: 6px; --v2-r-8: 8px; --v2-r-9: 9px; From 19b0093e2da97a1add7a29ca72f892690b5f4b02 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:32:08 -0700 Subject: [PATCH 2/4] fix(v2): primitives at 390 and the 7px seg/chip radius (TASK-730 PR2 review) Review on #3144 found the showcase broke at 390: the row label column kept 180px, the drawer was a fixed 400px inside a 298px pane, and the header overflowed. A max-width 480px block now stacks the rows, makes the drawer a full sheet, wraps the header, and hides the rail. The artboard's seg and chip radius is 7px, not 8px. Adds --v2-r-7 to the light set and uses it on those two primitives. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: wisp Agent-Model: claude/claude-haiku-5-5/default --- frontend/src/v2/primitives/V2PrimitivesPage.tsx | 2 +- frontend/src/v2/primitives/primitives.css | 17 +++++++++++++++-- frontend/src/v2/v2.css | 1 + 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/frontend/src/v2/primitives/V2PrimitivesPage.tsx b/frontend/src/v2/primitives/V2PrimitivesPage.tsx index 1cf9b598d9..e29d495edb 100644 --- a/frontend/src/v2/primitives/V2PrimitivesPage.tsx +++ b/frontend/src/v2/primitives/V2PrimitivesPage.tsx @@ -8,7 +8,7 @@ import './primitives.css'; const V2PrimitivesPage: React.FC = () => (
- -