Tasks 019 — Design-system components
For agentic workers: REQUIRED SUB-SKILL —
superpowers:subagent-driven-development(one implementer per task, then a two-stage review: spec compliance, then code quality).superpowers:test-driven-developmentapplies inside every task: no production code before a failing test that demands it. Reach forsuperpowers:systematic-debuggingon any surprise rather than guessing. Steps use- [ ]for tracking.
Execution skill: superpowers:subagent-driven-development, with superpowers:test-driven-development inside every task and superpowers:systematic-debugging on any surprise.
Derived from plan.md (approved, 2026-08-17). Each task is small, independently verifiable, and reviewed as its own diff. A task is done only when it satisfies the Definition of Done in CLAUDE.md: typecheck clean, tests pass, the named test traces to its criterion, no any / no unexplained escape hatches, the human has reviewed the diff.
Global Constraints live in plan.md (copied verbatim from the architecture docs) and apply to every task below — not repeated per task. The load-bearing ones here: no any, no non-null !, tokens never literals (conventions.test.ts rejects a literal colour in src/), no hand-written memoization, no new dependency (Base UI + Panda are already installed), and the closed boundary — @base-ui/react and styled-system are imported only inside shared/ui/.
Conventions (from the existing suite): tests import { describe, it, expect, vi } from vitest (no test.globals); render/screen/fireEvent from @testing-library/react; jest-dom matchers via the existing testSetup.ts. @testing-library/user-event is not installed — use fireEvent. After editing panda.config.ts, regenerate recipes with pnpm --filter @gw2priory/web exec panda codegen before running a component test.
Build order follows plan.md §Approach. Tasks are drawn so a reviewer can accept one without the next.
T1 — Button (pure Panda, and the shared/ui public surface)
Satisfies: R1, R2, R3, R5, R9, P1 #2, SC6 (and P1 #1 / SC1 in part — the barrel consumers import).
Files:
- Modify:
apps/web/panda.config.ts(addrecipes.button) - Create:
apps/web/src/shared/ui/Button.tsx - Create:
apps/web/src/shared/ui/index.ts - Test:
apps/web/src/shared/ui/__tests__/Button.test.tsx
Interfaces produced:
Button(props: { variant?: 'solid' | 'outline' | 'ghost'; size?: 'sm' | 'md' } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className'>)— defaultsvariant: 'solid',size: 'md'.apps/web/src/shared/ui/index.ts— the barrel later tasks add to.[ ] RED: write
apps/web/src/shared/ui/__tests__/Button.test.tsx:
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Button } from '../index';
describe('T1 — Button', () => {
it('P1 #2: renders each variant with an accessible name', () => {
for (const variant of ['solid', 'outline', 'ghost'] as const) {
const { unmount } = render(<Button variant={variant}>Save {variant}</Button>);
expect(
screen.getByRole('button', { name: `Save ${variant}` }),
).toBeInTheDocument();
unmount();
}
});
it('P1 #2: disabled blocks onClick', () => {
const onClick = vi.fn();
render(
<Button disabled onClick={onClick}>
Save
</Button>,
);
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onClick).not.toHaveBeenCalled();
});
});- [ ] Run it, watch it fail.
pnpm --filter @gw2priory/web exec vitest run src/shared/ui/__tests__/Button.test.tsxExpected: FAIL —../indexhas noButton. - [ ] Add the recipe to
apps/web/panda.config.ts: importdefineRecipefrom@pandacss/dev, and add undertheme.extend:
recipes: {
button: defineRecipe({
className: 'button',
base: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: '2',
borderRadius: 'md',
fontWeight: 'medium',
cursor: 'pointer',
_focusVisible: { outline: '2px solid', outlineColor: 'primary', outlineOffset: '2px' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
},
variants: {
variant: {
solid: { bg: 'primary', color: 'white', _hover: { opacity: 0.9 } },
outline: { borderWidth: '1px', borderColor: 'border', color: 'text.strong', _hover: { bg: 'muted' } },
ghost: { color: 'text.strong', _hover: { bg: 'muted' } },
},
size: {
sm: { h: '8', px: '3', fontSize: 'sm' },
md: { h: '10', px: '4', fontSize: 'md' },
},
},
defaultVariants: { variant: 'solid', size: 'md' },
}),
},- [ ] Regenerate recipes.
pnpm --filter @gw2priory/web exec panda codegen—styled-system/recipesnow exportsbutton. - [ ] GREEN: write
apps/web/src/shared/ui/Button.tsx:
import type { ButtonHTMLAttributes } from 'react';
import { button } from '../../../styled-system/recipes';
export interface ButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className'> {
variant?: 'solid' | 'outline' | 'ghost';
size?: 'sm' | 'md';
}
// Closed component: the `button` recipe and its tokens live here and never leave. `className` is
// omitted from the prop surface so a consumer cannot reach in and restyle (spec R1/R2).
export function Button({ variant, size, type = 'button', ...props }: ButtonProps) {
return <button type={type} className={button({ variant, size })} {...props} />;
}- [ ] Write
apps/web/src/shared/ui/index.ts:
export { Button, type ButtonProps } from './Button';- [ ] Run the test, watch it pass. Same command as the RED step.
- [ ] Confirm the test has teeth. Temporarily change
disabledhandling (e.g. render a plain<button>ignoringdisabled) and watch thedisabled blocks onClicktest fail; restore. - [ ] Guards green:
pnpm --filter @gw2priory/web exec vitest run src/__tests__/conventions.test.ts(no literal colour introduced) andpnpm --filter @gw2priory/web build(compiler clean). - [ ] Commit (
web: closed Button component + shared/ui barrel (019 T1)).
Verified by: T1 — Button › P1 #2: renders each variant with an accessible name and › P1 #2: disabled blocks onClick.
T2 — Input (Base UI Field)
Satisfies: R1, R2, R3, R6, P2 #1, P2 #2, P2 #3.
Files:
- Modify:
apps/web/panda.config.ts(addslotRecipes.input) - Create:
apps/web/src/shared/ui/Input.tsx - Modify:
apps/web/src/shared/ui/index.ts - Test:
apps/web/src/shared/ui/__tests__/Input.test.tsx
Interfaces produced:
Input(props: { label: string; error?: string } & Omit<InputHTMLAttributes<HTMLInputElement>, 'className' | 'aria-invalid'>).[ ] RED: write
apps/web/src/shared/ui/__tests__/Input.test.tsx:
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Input } from '../index';
describe('T2 — Input', () => {
it('P2 #1: the label is associated with the control', () => {
render(<Input label="Email" />);
// getByLabelText resolves the label→control association (aria-labelledby / htmlFor).
expect(screen.getByLabelText('Email')).toBeInstanceOf(HTMLInputElement);
});
it('P2 #2: error is exposed via aria-describedby and marks the field invalid', () => {
render(<Input label="Email" error="Required" />);
const control = screen.getByLabelText('Email');
expect(control).toHaveAttribute('aria-invalid', 'true');
expect(control).toHaveAccessibleDescription('Required');
});
it('P2 #3: onChange fires with the typed value', () => {
const onChange = vi.fn();
render(<Input label="Email" onChange={onChange} />);
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'a@b.co' } });
expect(onChange).toHaveBeenCalled();
});
});- [ ] Run it, watch it fail.
pnpm --filter @gw2priory/web exec vitest run src/shared/ui/__tests__/Input.test.tsxExpected: FAIL — noInputexport. - [ ] Add the slot recipe to
apps/web/panda.config.ts(importdefineSlotRecipefrom@pandacss/dev), undertheme.extend:
slotRecipes: {
input: defineSlotRecipe({
className: 'input',
slots: ['root', 'label', 'control', 'error'],
base: {
root: { display: 'flex', flexDirection: 'column', gap: '1.5' },
label: { fontSize: 'sm', fontWeight: 'medium', color: 'text.strong' },
control: {
h: '10',
px: '3',
borderRadius: 'md',
borderWidth: '1px',
borderColor: 'border',
bg: 'card',
color: 'text.strong',
_focusVisible: { outline: '2px solid', outlineColor: 'primary', outlineOffset: '1px' },
},
// No dedicated danger colour: a `danger` token is palette work, which spec R9 puts out of
// scope. The error is conveyed by aria-invalid + the text; colour is deferred (see Notes).
error: { fontSize: 'sm', color: 'text.strong' },
},
}),
},- [ ] Regenerate recipes.
pnpm --filter @gw2priory/web exec panda codegen. - [ ] GREEN: write
apps/web/src/shared/ui/Input.tsx:
import { Field } from '@base-ui/react/field';
import type { InputHTMLAttributes } from 'react';
import { input } from '../../../styled-system/recipes';
export interface InputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className' | 'aria-invalid'> {
label: string;
error?: string;
}
// Base UI Field owns label association, aria-describedby, and aria-invalid (research V1). A plain
// `error: string` drives `invalid` and a rendered Field.Error; none of it leaks to the caller.
export function Input({ label, error, ...props }: InputProps) {
const classes = input();
return (
<Field.Root invalid={Boolean(error)} className={classes.root}>
<Field.Label className={classes.label}>{label}</Field.Label>
<Field.Control className={classes.control} {...props} />
{error ? (
<Field.Error match className={classes.error}>
{error}
</Field.Error>
) : null}
</Field.Root>
);
}- [ ] Add to
apps/web/src/shared/ui/index.ts:
export { Input, type InputProps } from './Input';- [ ] Run the test, watch it pass. If
getByLabelTextfails to find the control, the association may not be wired — read Base UI'sFieldparts before changing the test (systematic-debugging), do not weaken the assertion. - [ ] Confirm the test has teeth. Drop the
errorrendering and watchP2 #2fail; restore. - [ ] Guards + build green (same two commands as T1).
- [ ] Commit (
web: closed Input over Base UI Field (019 T2)).
Verified by: T2 — Input › P2 #1, › P2 #2, › P2 #3.
T3 — Dialog (Base UI Dialog)
Satisfies: R1, R2, R3, R7, P3 #1, P3 #2, P3 #3.
Files:
- Modify:
apps/web/panda.config.ts(addslotRecipes.dialog) - Create:
apps/web/src/shared/ui/Dialog.tsx - Modify:
apps/web/src/shared/ui/index.ts - Modify:
apps/web/src/testSetup.ts(jsdom shims for Base UI overlays — see step) - Test:
apps/web/src/shared/ui/__tests__/Dialog.test.tsx
Interfaces produced:
Dialog(props: { open: boolean; onOpenChange: (open: boolean) => void; title: string; description?: string; children: ReactNode }).[ ] RED: write
apps/web/src/shared/ui/__tests__/Dialog.test.tsx:
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Dialog } from '../index';
describe('T3 — Dialog', () => {
it('P3 #1: hidden when closed, shown with focus inside when open', () => {
const { rerender } = render(
<Dialog open={false} onOpenChange={() => {}} title="Connect key">
<p>body</p>
</Dialog>,
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
rerender(
<Dialog open onOpenChange={() => {}} title="Connect key">
<p>body</p>
</Dialog>,
);
const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
// Base UI moves focus to the first tabbable element inside the popup (modal default true).
expect(dialog).toContainElement(document.activeElement);
});
it('P3 #2: Escape requests close via onOpenChange(false)', () => {
const onOpenChange = vi.fn();
render(
<Dialog open onOpenChange={onOpenChange} title="Connect key">
<p>body</p>
</Dialog>,
);
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('P3 #3: title is the accessible name', () => {
render(
<Dialog open onOpenChange={() => {}} title="Connect key">
<p>body</p>
</Dialog>,
);
expect(screen.getByRole('dialog', { name: 'Connect key' })).toBeInTheDocument();
});
});- [ ] Run it, watch it fail.
pnpm --filter @gw2priory/web exec vitest run src/shared/ui/__tests__/Dialog.test.tsxIf it fails instead withResizeObserver is not defined/matchMedia is not a function, that is the jsdom gap — add the shims (next step) and re-run; the test should then fail for the real reason (noDialog). - [ ] Add jsdom shims to
apps/web/src/testSetup.ts(Base UI overlays reference these; jsdom lacks them):
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
globalThis.ResizeObserver ??= ResizeObserverStub;
if (typeof globalThis.matchMedia !== 'function') {
Object.defineProperty(globalThis, 'matchMedia', {
writable: true,
value: (query: string): MediaQueryList => ({
matches: false,
media: query,
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}),
});
}- [ ] Add the slot recipe to
apps/web/panda.config.ts, undertheme.extend.slotRecipes:
dialog: defineSlotRecipe({
className: 'dialog',
slots: ['backdrop', 'popup', 'title', 'description'],
base: {
backdrop: { position: 'fixed', inset: '0', bg: 'black/50' },
popup: {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
display: 'flex',
flexDirection: 'column',
gap: '4',
w: '90vw',
maxW: 'md',
p: '6',
borderRadius: 'lg',
bg: 'card',
color: 'text.strong',
boxShadow: 'lg',
},
title: { fontSize: 'lg', fontWeight: 'bold' },
description: { fontSize: 'sm', color: 'text.muted' },
},
}),- [ ] Regenerate recipes.
pnpm --filter @gw2priory/web exec panda codegen. - [ ] GREEN: write
apps/web/src/shared/ui/Dialog.tsx:
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import type { ReactNode } from 'react';
import { dialog } from '../../../styled-system/recipes';
export interface DialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
children: ReactNode;
}
// Closed, controlled. Base UI's modal default gives focus trap, scroll lock, Escape and outside-press.
// The Close lives inside the Popup (research F3). onOpenChange forwards only the boolean.
export function Dialog({ open, onOpenChange, title, description, children }: DialogProps) {
const classes = dialog();
return (
<BaseDialog.Root open={open} onOpenChange={(next) => onOpenChange(next)}>
<BaseDialog.Portal>
<BaseDialog.Backdrop className={classes.backdrop} />
<BaseDialog.Popup className={classes.popup}>
<BaseDialog.Title className={classes.title}>{title}</BaseDialog.Title>
{description ? (
<BaseDialog.Description className={classes.description}>
{description}
</BaseDialog.Description>
) : null}
{children}
<BaseDialog.Close>Close</BaseDialog.Close>
</BaseDialog.Popup>
</BaseDialog.Portal>
</BaseDialog.Root>
);
}- [ ] Add to
apps/web/src/shared/ui/index.ts:
export { Dialog, type DialogProps } from './Dialog';- [ ] Run the test, watch it pass. The focus-trap cycle is Base UI's guarantee and is not re-tested;
P3 #1asserts focus lands inside on open, which is the observable proxy. - [ ] Confirm the test has teeth. Pass a wrong
titleand watchP3 #3fail; restore. - [ ] Guards + build green.
- [ ] Commit (
web: closed Dialog over Base UI Dialog (019 T3)).
Verified by: T3 — Dialog › P3 #1, › P3 #2, › P3 #3.
T4 — Tooltip (Base UI Tooltip)
Satisfies: R1, R2, R3, R8, P4 #1, P4 #2.
Files:
- Modify:
apps/web/panda.config.ts(addslotRecipes.tooltip) - Create:
apps/web/src/shared/ui/Tooltip.tsx - Modify:
apps/web/src/shared/ui/index.ts - Test:
apps/web/src/shared/ui/__tests__/Tooltip.test.tsx
Interfaces produced:
Tooltip(props: { content: ReactNode; label?: string; side?: 'top' | 'right' | 'bottom' | 'left'; children: ReactElement })—labeldefaults tocontentwhen it is a string; applied as the trigger'saria-label(research V3).[ ] RED: write
apps/web/src/shared/ui/__tests__/Tooltip.test.tsx(jsdom shims from T3 already present):
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Tooltip } from '../index';
describe('T4 — Tooltip', () => {
it('P4 #2: the trigger carries an accessible name matching the content', () => {
render(
<Tooltip content="Copy to clipboard">
<button type="button">icon</button>
</Tooltip>,
);
// content is a string → it becomes the trigger's aria-label, so the button is named by it.
expect(
screen.getByRole('button', { name: 'Copy to clipboard' }),
).toBeInTheDocument();
});
it('P4 #1: content appears on hover and on keyboard focus', async () => {
render(
<Tooltip content="Copy to clipboard">
<button type="button">icon</button>
</Tooltip>,
);
const trigger = screen.getByRole('button', { name: 'Copy to clipboard' });
fireEvent.pointerEnter(trigger);
// Base UI's default open delay is 600ms; findBy polls (2s) rather than using fake timers.
expect(
await screen.findByText('Copy to clipboard', {}, { timeout: 2000 }),
).toBeInTheDocument();
fireEvent.pointerLeave(trigger);
fireEvent.focus(trigger);
expect(
await screen.findByText('Copy to clipboard', {}, { timeout: 2000 }),
).toBeInTheDocument();
});
});- [ ] Run it, watch it fail.
pnpm --filter @gw2priory/web exec vitest run src/shared/ui/__tests__/Tooltip.test.tsxExpected: FAIL — noTooltip. (If the hover half proves flaky under jsdom, the event may need to bemouseEnterrather thanpointerEnter— resolve by reading Base UI's trigger source, not by deleting the assertion.) - [ ] Add the slot recipe to
apps/web/panda.config.ts, undertheme.extend.slotRecipes:
tooltip: defineSlotRecipe({
className: 'tooltip',
slots: ['popup'],
base: {
popup: {
maxW: '16rem',
px: '2.5',
py: '1.5',
borderRadius: 'md',
fontSize: 'sm',
bg: 'text.strong',
color: 'surface',
boxShadow: 'md',
},
},
}),- [ ] Regenerate recipes.
pnpm --filter @gw2priory/web exec panda codegen. - [ ] GREEN: write
apps/web/src/shared/ui/Tooltip.tsx:
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip';
import type { ReactElement, ReactNode } from 'react';
import { tooltip } from '../../../styled-system/recipes';
export interface TooltipProps {
content: ReactNode;
label?: string;
side?: 'top' | 'right' | 'bottom' | 'left';
children: ReactElement;
}
// Base UI's Tooltip does NO aria wiring (research V3): the popup is visual-only, and accessibility
// is the trigger's accessible name. This closed component enforces that — `label` (or a string
// `content`) becomes the trigger's aria-label. `children` renders AS the trigger via `render`.
export function Tooltip({ content, label, side = 'top', children }: TooltipProps) {
const classes = tooltip();
const name = label ?? (typeof content === 'string' ? content : undefined);
return (
<BaseTooltip.Root>
<BaseTooltip.Trigger render={children} aria-label={name} />
<BaseTooltip.Portal>
<BaseTooltip.Positioner side={side} sideOffset={6}>
<BaseTooltip.Popup className={classes.popup}>{content}</BaseTooltip.Popup>
</BaseTooltip.Positioner>
</BaseTooltip.Portal>
</BaseTooltip.Root>
);
}- [ ] Add to
apps/web/src/shared/ui/index.ts:
export { Tooltip, type TooltipProps } from './Tooltip';- [ ] Run the test, watch it pass.
- [ ] Confirm the test has teeth. Remove the
aria-label={name}and watchP4 #2fail; restore. - [ ] Guards + build green.
- [ ] Commit (
web: closed Tooltip over Base UI Tooltip, enforced accessible name (019 T4)).
Verified by: T4 — Tooltip › P4 #2, › P4 #1.
T5 — The /ui gallery
Satisfies: R10, R11, P1 #4, P2 #4, P3 #4, P4 #3, SC3 (and SC1 as the consumer that imports only from shared/ui).
Files:
- Create:
apps/web/src/features/ui-gallery/UiGalleryPage.tsx - Create:
apps/web/src/features/ui-gallery/routes.tsx - Create:
apps/web/src/features/ui-gallery/styles.ts - Modify:
apps/web/src/main.tsx(mount the route table; add NONavLinktoApp.tsx) - Test:
apps/web/src/features/ui-gallery/__tests__/UiGalleryPage.test.tsx
Interfaces produced: UiGalleryPage() route component; routes: RouteObject[] mounting /ui.
- [ ] RED: write
apps/web/src/features/ui-gallery/__tests__/UiGalleryPage.test.tsx:
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { UiGalleryPage } from '../UiGalleryPage';
describe('T5 — UiGalleryPage', () => {
it('P1 #4 / P2 #4: renders Button variants and an Input in both states', () => {
render(<UiGalleryPage />);
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByText('Required')).toBeInTheDocument(); // the error-state Input
});
it('P3 #4: a trigger opens the Dialog', () => {
render(<UiGalleryPage />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Open dialog' }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('P4 #3: a tooltip trigger is present with an accessible name', () => {
render(<UiGalleryPage />);
expect(
screen.getByRole('button', { name: 'Copy to clipboard' }),
).toBeInTheDocument();
});
});- [ ] Run it, watch it fail.
pnpm --filter @gw2priory/web exec vitest run src/features/ui-gallery/__tests__/UiGalleryPage.test.tsx - [ ] GREEN: write
apps/web/src/features/ui-gallery/styles.ts(layout viacss()) andapps/web/src/features/ui-gallery/UiGalleryPage.tsx. It imports only from../../shared/ui(SC1) and usesuseStatefor the dialog's open flag (ephemeral UI state, react.md). Header-comment it as a Storybook stopgap (R11):
// ponytail: interim design-system gallery. Storybook is the intended home (spec 019 R11); this page
// is the stopgap until it lands. Not linked from the nav — reachable at /ui by URL only.
import { useState } from 'react';
import { Button, Dialog, Input, Tooltip } from '../../shared/ui';
import { row, section } from './styles';
export function UiGalleryPage() {
const [open, setOpen] = useState(false);
return (
<div>
<section className={section}>
<div className={row}>
<Button variant="solid">Solid</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="solid" size="sm">Small</Button>
<Button disabled>Disabled</Button>
</div>
</section>
<section className={section}>
<div className={row}>
<Input label="Email" placeholder="you@example.com" />
<Input label="Email" error="Required" />
</div>
</section>
<section className={section}>
<div className={row}>
<Button variant="solid" onClick={() => setOpen(true)}>Open dialog</Button>
<Dialog
open={open}
onOpenChange={setOpen}
title="Connect key"
description="Paste a GW2 API key."
>
<p>Dialog body.</p>
</Dialog>
<Tooltip content="Copy to clipboard">
<Button variant="ghost">Copy</Button>
</Tooltip>
</div>
</section>
</div>
);
}// apps/web/src/features/ui-gallery/styles.ts
import { css } from '../../../styled-system/css';
export const section = css({ mb: '8' });
export const row = css({ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '4' });- [ ] Write
apps/web/src/features/ui-gallery/routes.tsx:
import type { RouteObject } from 'react-router';
import { UiGalleryPage } from './UiGalleryPage';
// Dev/design-system surface, mounted by main.tsx like any feature route table (R10). No NavLink is
// added in App.tsx — reachable at /ui by URL only.
export const routes: RouteObject[] = [{ path: '/ui', element: <UiGalleryPage /> }];- [ ] Mount it in
apps/web/src/main.tsx: import{ routes as uiGalleryRoutes } from './features/ui-gallery/routes'and add...uiGalleryRoutesto the layout route'schildren. Do not touchApp.tsx's nav. - [ ] Run the test, watch it pass.
- [ ] Confirm teeth. Remove the error-state
<Input>and watchP2 #4fail; restore. - [ ] Run the app to see them:
pnpm --filter @gw2priory/web dev, open/ui, confirm the four render and the dialog opens/closes and the tooltip shows on hover. - [ ] Guards + build green.
- [ ] Commit (
web: /ui design-system gallery, mounted no-nav (019 T5)).
Verified by: T5 — UiGalleryPage › P1 #4 / P2 #4, › P3 #4, › P4 #3; plus the manual /ui run for SC3/SC5.
T6 — Documentation, and close the traceability
Satisfies: R4, R12, R13, P1 #3, SC4 (and records the SC1/SC2 review evidence).
Files:
Modify:
docs/architecture/design-system.mdModify:
docs/architecture/react.mdModify:
specs/019-design-system-components/spec.md(fill the traceability table)[ ]
design-system.md— add a Closed components section stating: eachshared/uicomponent is one props-driven export owning its Base UI primitive and Panda styling internally; only the finished component escapesshared/ui(documented convention, not a guard test); wrap Base UI only where a native element falls short (research F2 — Button is native); Base UI is headless, Panda is the only styling,CSPProvideris the escape hatch if a strict CSP is added later (F1); the Tooltip popup is visual-only so the trigger carries the accessible name (V3); Storybook is the intended home, the/uigallery is the stopgap (R11).[ ]
design-system.md— amend the Colocated until promoted section (R4): foundation/DS components are promoted toshared/uias config recipes deliberately, ahead of a second consumer; feature components still colocate acvauntil a second feature needs them. NameButton/Input/Dialog/Tooltipas the first application.[ ]
react.md— add a row to the enforcement/ownership table: "Base UI imported only insideshared/ui" — owner: convention (documented, not machine-checked), mirroring how R19 states the honestly-unenforced rules.[ ] Validate docs compile:
pnpm docs:build(VitePress rendersdocs/**andspecs/**).[ ] Fill
spec.md's traceability table with the test names from T1–T5 (transcription, since eachVerified byalready names them), and the honest non-test rows: P1 #1 / SC1 → gallery + human review of imports; P1 #3 / SC4 → human review + the react.md enforcement row; P3 #4 "no nav item" / SC3 → App.tsx nav unchanged (review) + manual/uirun; SC5 → the full command set at Step 5.[ ] Confirm no
docs/superpowers/files and the workflow invariants pass:pnpm exec vitest run tests/workflow/repo-invariants.test.ts.[ ] Commit (
docs: 019 design-system closed-component pattern; react.md boundary row; traceability).
Verified by: pnpm docs:build green; human review that design-system.md/react.md describe the repository (SC4/SC8); the SC5 traceability row filled at Step 5.
Notes
Staging area for decisions and surprises found during implementation. Move each into spec.md, research.md, or docs/ before closing the feature — this section is not a home.
Amendment (2026-08-17) — recipes route through a
styles.ts, not a direct.tsximport. The task code blocks import each recipe directly fromstyled-system/recipesin the component.tsx. That violates the pre-existing Biome rulenoRestrictedImports(react.md: "Styles live in a styles.ts beside the component"), which forbids**/styled-system/**imports outside astyles.ts— sopnpm lintwent red at T1 and the per-task guard (conventions.test.ts + build) did not catch it. As built: oneapps/web/src/shared/ui/styles.tsre-exports the recipes (export { button, dialog, input, tooltip } from '../../../styled-system/recipes') and each component imports its recipe from./styles. This preserves the closed model and matches the App-shellstyles.ts. T4 (Tooltip) follows this from the start; T6 documents it. Per-task guards now includepnpm lint. (Fix commit tagged019 fix.)Deferred: a
danger/error colour token. T2'sInputerror is conveyed byaria-invalid+ the error text, styled withtext.strong, because a dedicated danger colour is a palette change and spec R9 puts palette work out of scope. When a future spec does the palette, give the error its own token and update theinputrecipe. (Candidate forresearch.mdgraduation / a follow-up spec.)jsdom shims (T3):
ResizeObserver+matchMediawere added totestSetup.tsfor Base UI's overlay/positioner components. If a later test needsIntersectionObserverorDOMRect, add it the same way.If the tooltip hover event (
pointerEntervsmouseEnter) or the 600ms delay makesP4 #1flaky in CI, record the resolution here before moving it intoresearch.md.