Skip to content

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-development applies inside every task: no production code before a failing test that demands it. Reach for superpowers:systematic-debugging on 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 (add recipes.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'>) — defaults variant: '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:

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.tsx Expected: FAIL — ../index has no Button.
  • [ ] Add the recipe to apps/web/panda.config.ts: import defineRecipe from @pandacss/dev, and add under theme.extend:
ts
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/recipes now exports button.
  • [ ] GREEN: write apps/web/src/shared/ui/Button.tsx:
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:
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 disabled handling (e.g. render a plain <button> ignoring disabled) and watch the disabled blocks onClick test fail; restore.
  • [ ] Guards green: pnpm --filter @gw2priory/web exec vitest run src/__tests__/conventions.test.ts (no literal colour introduced) and pnpm --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 (add slotRecipes.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:

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.tsx Expected: FAIL — no Input export.
  • [ ] Add the slot recipe to apps/web/panda.config.ts (import defineSlotRecipe from @pandacss/dev), under theme.extend:
ts
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:
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:
ts
export { Input, type InputProps } from './Input';
  • [ ] Run the test, watch it pass. If getByLabelText fails to find the control, the association may not be wired — read Base UI's Field parts before changing the test (systematic-debugging), do not weaken the assertion.
  • [ ] Confirm the test has teeth. Drop the error rendering and watch P2 #2 fail; 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 (add slotRecipes.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:

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.tsx If it fails instead with ResizeObserver 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 (no Dialog).
  • [ ] Add jsdom shims to apps/web/src/testSetup.ts (Base UI overlays reference these; jsdom lacks them):
ts
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, under theme.extend.slotRecipes:
ts
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:
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:
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 #1 asserts focus lands inside on open, which is the observable proxy.
  • [ ] Confirm the test has teeth. Pass a wrong title and watch P3 #3 fail; 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 (add slotRecipes.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 }) — label defaults to content when it is a string; applied as the trigger's aria-label (research V3).

  • [ ] RED: write apps/web/src/shared/ui/__tests__/Tooltip.test.tsx (jsdom shims from T3 already present):

tsx
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.tsx Expected: FAIL — no Tooltip. (If the hover half proves flaky under jsdom, the event may need to be mouseEnter rather than pointerEnter — resolve by reading Base UI's trigger source, not by deleting the assertion.)
  • [ ] Add the slot recipe to apps/web/panda.config.ts, under theme.extend.slotRecipes:
ts
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:
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:
ts
export { Tooltip, type TooltipProps } from './Tooltip';
  • [ ] Run the test, watch it pass.
  • [ ] Confirm the test has teeth. Remove the aria-label={name} and watch P4 #2 fail; 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.


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 NO NavLink to App.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:
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 via css()) and apps/web/src/features/ui-gallery/UiGalleryPage.tsx. It imports only from ../../shared/ui (SC1) and uses useState for the dialog's open flag (ephemeral UI state, react.md). Header-comment it as a Storybook stopgap (R11):
tsx
// 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>
  );
}
ts
// 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:
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 ...uiGalleryRoutes to the layout route's children. Do not touch App.tsx's nav.
  • [ ] Run the test, watch it pass.
  • [ ] Confirm teeth. Remove the error-state <Input> and watch P2 #4 fail; 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.md

  • Modify: docs/architecture/react.md

  • Modify: specs/019-design-system-components/spec.md (fill the traceability table)

  • [ ] design-system.md — add a Closed components section stating: each shared/ui component is one props-driven export owning its Base UI primitive and Panda styling internally; only the finished component escapes shared/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, CSPProvider is 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 /ui gallery is the stopgap (R11).

  • [ ] design-system.md — amend the Colocated until promoted section (R4): foundation/DS components are promoted to shared/ui as config recipes deliberately, ahead of a second consumer; feature components still colocate a cva until a second feature needs them. Name Button/Input/Dialog/ Tooltip as the first application.

  • [ ] react.md — add a row to the enforcement/ownership table: "Base UI imported only inside shared/ui" — owner: convention (documented, not machine-checked), mirroring how R19 states the honestly-unenforced rules.

  • [ ] Validate docs compile: pnpm docs:build (VitePress renders docs/** and specs/**).

  • [ ] Fill spec.md's traceability table with the test names from T1–T5 (transcription, since each Verified by already 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 /ui run; 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 .tsx import. The task code blocks import each recipe directly from styled-system/recipes in the component .tsx. That violates the pre-existing Biome rule noRestrictedImports (react.md: "Styles live in a styles.ts beside the component"), which forbids **/styled-system/** imports outside a styles.ts — so pnpm lint went red at T1 and the per-task guard (conventions.test.ts + build) did not catch it. As built: one apps/web/src/shared/ui/styles.ts re-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-shell styles.ts. T4 (Tooltip) follows this from the start; T6 documents it. Per-task guards now include pnpm lint. (Fix commit tagged 019 fix.)

  • Deferred: a danger/error colour token. T2's Input error is conveyed by aria-invalid + the error text, styled with text.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 the input recipe. (Candidate for research.md graduation / a follow-up spec.)

  • jsdom shims (T3): ResizeObserver + matchMedia were added to testSetup.ts for Base UI's overlay/positioner components. If a later test needs IntersectionObserver or DOMRect, add it the same way.

  • If the tooltip hover event (pointerEnter vs mouseEnter) or the 600ms delay makes P4 #1 flaky in CI, record the resolution here before moving it into research.md.