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/v2/V2App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { applyV2Theme, getV2Theme } from './utils/theme';
// whole route table, so a chunk in flight shows the same <V2Boot/> 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'));
Expand Down Expand Up @@ -217,6 +219,7 @@ const V2App: React.FC = () => {
<Route path="agent/:agentName/:instanceId" element={<V2AgentProfile />} />
<Route path="use-cases/:useCaseId" element={<V2UseCaseRedirect />} />
<Route path="login" element={<V2Login />} />
<Route path="_primitives" element={<V2PrimitivesPage />} />
<Route path="register" element={<V2Register />} />
<Route path="register/invite-required" element={<RegistrationInviteRequired />} />
<Route path="oauth/complete" element={<V2OAuthComplete />} />
Expand Down
40 changes: 40 additions & 0 deletions frontend/src/v2/__tests__/v2Primitives.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string>();
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(<V2PrimitivesPage />);
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();
});
});
76 changes: 76 additions & 0 deletions frontend/src/v2/primitives/V2PrimitivesPage.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<div className="v2p-root" data-testid="v2p-root">
<div className="v2p-sh v2p-sh--page" style={{ height: '100vh' }}>
<div className="v2p-rail" aria-hidden="true" />
<section className="v2p-pane" aria-label="Primitives">
<header className="v2p-ph">
<h1>Primitives</h1>
<span className="meta">D223 · TASK-730</span>
<span className="sp" />
<div className="v2p-seg" role="group" aria-label="View">
<button type="button" className="on">Light</button>
<button type="button">Dark</button>
</div>
</header>

<div className="v2p-body">
<div className="v2p-gtitle">buttons</div>
<div className="v2p-list">
<div className="v2p-lrow">
<div className="lb">Keys</div>
<div className="vl">
<button type="button" className="v2p-b v2p-b-p">Primary</button>
<button type="button" className="v2p-b v2p-b-c">Cobalt</button>
<button type="button" className="v2p-b v2p-b-s">Secondary</button>
<button type="button" className="v2p-b v2p-b-g">Ghost</button>
<button type="button" className="v2p-b v2p-b-s v2p-b-sm">Small</button>
<button type="button" className="v2p-b v2p-b-s v2p-ib" aria-label="Icon">+</button>
</div>
<div className="ac" />
</div>
</div>

<div className="v2p-gtitle">chips, badges, dots</div>
<div className="v2p-list">
<div className="v2p-lrow">
<div className="lb">Tags</div>
<div className="vl">
<span className="v2p-chip">pod</span>
<span className="v2p-chip on">selected</span>
<span className="v2p-badge">3</span>
<span className="v2p-dot" aria-label="active" />
<span className="v2p-dot v2p-dot--idle" aria-label="idle" />
<span className="v2p-dot v2p-dot--off" aria-label="off" />
<span className="v2p-tg on" aria-label="on" />
</div>
<div className="ac" />
</div>
</div>

<div className="v2p-gtitle">card</div>
<div className="v2p-tc">
<div className="top1"><span>task-730</span><span className="sp" /><span>P0</span></div>
<div className="tt">Build the shared primitives from the artboard frame</div>
</div>
</div>

<aside className="v2p-drawer v2p-drawer--inline" aria-label="Drawer example">
<div className="dh"><span className="v2p-chip">drawer</span></div>
<div className="db">
<h2>Drawer title</h2>
<p className="v2p-body" style={{ padding: 0 }}>Shown in flow here. On the live pages it sits over the pane at 400px.</p>
</div>
</aside>
</section>
</div>
</div>
);

export default V2PrimitivesPage;
255 changes: 255 additions & 0 deletions frontend/src/v2/primitives/primitives.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
/* 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-7);
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-7);
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;
}
/* The showcase shows the drawer in flow, so it never covers the rows above it.
The live drawer keeps the absolute overlay above. */
.v2p-drawer--inline { position: static; width: auto; border-radius: var(--v2-r-14); margin: 0 28px 24px; box-shadow: var(--v2-shadow-pane); }
.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; }

/* ---- 390 (the artboard's phone frame): the rail goes, rows stack, the drawer
becomes a full sheet, and the header wraps instead of overflowing ---- */
@media (max-width: 480px) {
.v2p-sh--page { grid-template-columns: minmax(0, 1fr); padding: 0; gap: 0; }
.v2p-rail { display: none; }
.v2p-pane { border-radius: 0; box-shadow: none; overflow: auto; }
.v2p-ph { height: auto; min-height: 52px; flex-wrap: wrap; padding: 10px 14px; row-gap: 8px; }
.v2p-ph h1 { font-size: 20px; white-space: normal; }
.v2p-body { padding: 16px 14px; }
.v2p-lrow { grid-template-columns: minmax(0, 1fr); min-height: 48px; padding: 8px 12px; }
.v2p-drawer:not(.v2p-drawer--inline) { width: 100%; border-radius: 0; }
}
Loading
Loading