# Foundation: Design System — Implementation Plan

**Spec:** docs/specs/2026-05-30-foundation-design-system.md  ·  **Slug:** foundation-design-system  ·  **Wave:** 1
**Depends on:** foundation-monorepo

## Goal
Build `packages/ui` — the shared React component library, design tokens, and the living design-system catalogue page — that every UI surface in `zync-www` and `zync-app` consumes. All product UI must be composed exclusively from these primitives: no raw HTML in pages, no hardcoded colors, no off-grid spacing, a single radius. This spec owns the non-color token primitives (`--radius`, `--shadow`, `--font-*`), the Tailwind preset registry, the component API surface, and the four enforcing ESLint rules.

## Architecture
This is a **UI-foundation** spec with **zero database tables and zero API routes**. It produces a publishable package (`packages/ui`, barrel `@zync/ui`) plus config artifacts in `packages/config`, and one dev-only page in `apps/zync-app`.

Primitives are built on **Radix UI** via the shadcn/ui copy-paste approach: components are inlined and owned by `packages/ui` (no runtime upstream lock-in), styled with Tailwind utilities that map to CSS custom properties. The shadcn CLI is a dev-time scaffold tool only.

**Critical token-ownership boundary:** the canonical **color** tokens (`--bg`, `--surface`, `--ink*`, `--accent*`, `--line*`, `--success/--warning/--info/--danger` and variants) for BOTH light (`:root`) and dark (`.dark`) modes are the single source of truth of the `dark-light-theme` spec (spec 114), which depends on THIS spec. This plan does **not** define any color token. The `tailwind.preset.ts` here maps Tailwind color utilities onto `var(--bg)`-style references whose values are supplied later by spec 114 — at this stage those CSS vars resolve empty, which is expected and not a bug. This file owns only the non-color primitives and the registry that maps onto spec 114's tokens.

Upstream consumed from `foundation-monorepo`: the existing `packages/ui`, `packages/config`, and `apps/zync-app` scaffolds, the shared `packages/config/tsconfig.base.json`, the flat `eslint.config.mjs`, and the pnpm/Turborepo workspace.

## Tech Stack
- **Package:** `packages/ui` (barrel export `@zync/ui`), `packages/config` (Tailwind preset + ESLint rules).
- **App touched:** `apps/zync-app` (design-system page only).
- **Libraries:** React 18+, Radix UI primitives, Tailwind CSS, `react-hook-form` + `zod` (forms), `cmdk` (command palette), `sonner` (toast), `@tanstack/react-table` v8 (DataTable), `@tanstack/react-virtual` v3 (VirtualList), `class-variance-authority` + `clsx` + `tailwind-merge` (variant/class composition).
- **Fonts:** WOFF2 self-hosted (Fraunces, IBM Plex Sans, IBM Plex Mono, Heebo Variable).
- **Cloudflare bindings:** none (pure client-side library + dev page).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A. Foundation | 1, 2, 3 | `packages/ui/package.json`, `packages/ui/tsconfig.json`, `packages/ui/src/lib/cn.ts`, `packages/ui/src/tokens/index.css`, `packages/config/tailwind.preset.ts` | Tasks 2 & 3 parallel after 1 |
| B. Fonts | 4 | `packages/ui/src/tokens/fonts.css`, `apps/zync-app` head | After 3 |
| C. Primitives | 5, 6, 7, 8, 9, 10, 11 | `packages/ui/src/{primitives,layout,forms,overlays,navigation,feedback,data-display}/**` | All parallel after A+B |
| D. Barrel | 12 | `packages/ui/src/index.ts` | After C |
| E. Enforcement | 13 | `packages/config/eslint-rules/**`, `eslint.config.mjs` | Parallel with C |
| F. Catalogue | 14 | `apps/zync-app/src/pages/design-system.tsx`, route guard | After D |

## Design Guardrails (review-gated — apply to every component Task 5–11 and the catalogue Task 14)
These are spec mandates enforced by code review, NOT by ESLint (the only lint rules are the four in Task 13). Every component and page must obey them; treat a violation as build-rejecting.

**Interaction states — Allowed:**
- Opacity shift: `transition-opacity duration-150` (150ms), wrapped in the `prefers-reduced-motion` guard.
- 1px underline reveal on hover: `hover:underline underline-offset-2`.

**Interaction states — Banned:**
- Glassmorphism (`backdrop-blur` + transparent bg).
- Drop-shadow lift on hover (`hover:shadow-*`).
- Color gradients (except black/white text-legibility masks over images).
- Scale transforms on hover (`hover:scale-*`).

**Layout grid:**
- 12-column grid. Mandate asymmetry — headlines span odd column counts (e.g. `col-start-2 col-end-9`). Never `col-span-12` centered.
- Negative space is a layout element; never fill space to avoid emptiness.

**Typography scale rule:**
- Use the seven-token table as the only scale. In one component/content hierarchy, adjacent sizes are banned — skip at least one token between levels. Valid: `display-2`→`title-1`→`body-1`→`meta`. Invalid: `title-1`→`title-2` or `body-1`→`body-2` in the same local hierarchy.

**Anti-Patterns (Instant Failure — any output containing these is rejected):**
1. Centered hero text + 2 stacked CTAs over a gradient background.
2. "Trusted by" / client-logo strip under the hero.
3. Bento-box grids (3–4 icon/heading/paragraph cards in a row).
4. Emojis as bullets, section markers, or UI icons.
5. Decorative icon usage (icons placed to fill empty space).
6. Floating cards with heavy drop shadows.
7. Any hex/rgb/hsl color in source (also lint-blocked).
8. Spacing not on the 8px grid — 12px, 20px gaps etc. (also lint-blocked).
9. Multiple radius values / radius ladder (also lint-blocked).
10. Glassmorphism / backdrop-blur tricks.
11. Shadow-on-hover lift effects.
12. More than one accent-colored element per visual section.

## Tasks

### Task 1: Scaffold `packages/ui` package + class-merge helper
**Blocks:** 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12  ·  **Blocked by:** —
**Files:**
- Modify: `packages/ui/package.json`
- Modify: `packages/ui/tsconfig.json`
- Create: `packages/ui/src/lib/cn.ts`
**Steps:**
- [ ] Set `packages/ui` `name` to `@zync/ui`, `type: "module"`, `main`/`exports` pointing to `./src/index.ts`, `sideEffects` including `*.css`.
- [ ] Add dependencies: `react`, `react-dom` (peer), `@radix-ui/react-*` (slot, dialog, dropdown-menu, context-menu, popover, tooltip, tabs, checkbox, radio-group, switch, select, separator, avatar, progress), `cmdk`, `sonner`, `@tanstack/react-table`, `@tanstack/react-virtual`, `react-hook-form`, `zod`, `@hookform/resolvers`, `class-variance-authority`, `clsx`, `tailwind-merge`, `lucide-react`.
- [ ] Extend `tsconfig.json` from `packages/config/tsconfig.base.json`; enable `jsx: react-jsx`, strict mode.
- [ ] Implement `cn.ts` exporting `cn(...inputs: ClassValue[])` = `twMerge(clsx(inputs))`.
**Schema / Interfaces:**
```ts
// packages/ui/src/lib/cn.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs))
}
```
**Acceptance:**
- [ ] `pnpm --filter @zync/ui typecheck` passes on an empty barrel.
- [ ] `cn('p-2', 'p-4')` resolves to `'p-4'` (tailwind-merge dedupe).

### Task 2: Non-color design tokens (`tokens/index.css`)
**Blocks:** 5–11, 14  ·  **Blocked by:** 1
**Files:**
- Create: `packages/ui/src/tokens/index.css`
**Steps:**
- [ ] Emit `:root` with **only** non-color tokens: `--radius`, structural `--shadow`, and the four `--font-*` families. Do NOT emit any color token (`--bg`, `--ink`, `--accent`, `--line`, `--success`, etc.) — those are owned by spec 114 for both `:root` and `.dark`.
- [ ] Emit the `html[lang='he']` override remapping `--font-sans` to `--font-hebrew`.
- [ ] Add a header comment stating color tokens are owned by `dark-light-theme` (spec 114) and intentionally absent here.
- [ ] `@import` `./fonts.css` (created in Task 4) at the top.
**Schema / Interfaces:**
```css
/* packages/ui/src/tokens/index.css
   Color tokens (--bg, --surface, --ink*, --accent*, --line*, --success/
   --warning/--info/--danger and -bg/-soft variants) are owned by
   dark-light-theme (spec 114) for BOTH :root (light) and .dark (dark).
   They are intentionally NOT defined here. */
@import './fonts.css';

:root {
  /* ── Border radius — ONE value system-wide. No ladder. ── */
  --radius: 4px;

  /* ── Shadow — light-mode structural default; spec 114 supplies the
        per-mode --shadow-sm/md/lg ramp ── */
  --shadow: 0 1px 4px oklch(12% 0.04 240 / 0.08);

  /* ── Typography ── */
  --font-display: 'Fraunces', 'PP Editorial New', 'Newsreader', Georgia, serif;
  --font-sans: 'IBM Plex Sans', Arial, sans-serif;
  --font-hebrew: 'Heebo Variable', 'Heebo', Arial, sans-serif;
  --font-mono: 'IBM Plex Mono', 'Courier New', monospace;
}

html[lang='he'] {
  --font-sans: var(--font-hebrew);
}
```
**Acceptance:**
- [ ] File contains `--radius`, `--shadow`, four `--font-*` vars, and the `html[lang='he']` block — and NO color tokens.
- [ ] `--radius` is the only radius value; `oklch(...)` is the only color function present (in `--shadow`), no hex/rgb/hsl.

### Task 3: Tailwind preset (`packages/config/tailwind.preset.ts`)
**Blocks:** 5–11, 14  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/config/tailwind.preset.ts`
- Modify: `packages/config/package.json` (export `./tailwind.preset`)
**Steps:**
- [ ] Transcribe the preset verbatim, mapping color utilities to `var(--…)` references. Add a comment: these color vars are **defined by spec 114 (dark-light-theme)**, which depends on this spec; they resolve empty until spec 114 lands — this is expected, the file is not broken.
- [ ] Add the typography scale (`display-1`…`meta`) as `fontSize` entries with `[size, { lineHeight, letterSpacing }]` tuples mapped to the correct family via component classes.
- [ ] Restrict `borderRadius` to a single `DEFAULT: 'var(--radius)'` (enables only the `rounded` class) and `boxShadow` to `DEFAULT: 'var(--shadow)'` + `none`.
- [ ] Restrict the spacing scale to the 8px grid: keys `2,4,6,8,12,16,24` → `8/16/24/32/48/64/96px`, plus `0.5` → `2px` (tight-primitive exception only).
- [ ] Apps' `tailwind.config` files extend this preset via `presets: [zyncPreset]`.
**Schema / Interfaces:**
```ts
// packages/config/tailwind.preset.ts
// NOTE: color CSS vars below (--bg, --surface, …) are owned by dark-light-theme
// (spec 114). They resolve empty until spec 114 is implemented — expected, not a bug.
export default {
  theme: {
    colors: {
      bg: 'var(--bg)',
      surface: 'var(--surface)',
      ink: 'var(--ink)',
      'ink-soft': 'var(--ink-soft)',
      'ink-faint': 'var(--ink-faint)',
      'ink-on-accent': 'var(--ink-on-accent)',
      line: 'var(--line)',
      'line-subtle': 'var(--line-subtle)',
      hover: 'var(--hover)',
      accent: 'var(--accent)',
      'accent-soft': 'var(--accent-soft)',
      'accent-mid': 'var(--accent-mid)',
      'accent-border': 'var(--accent-border)',
      danger: 'var(--danger)',
      success: 'var(--success)',
      warning: 'var(--warning)',
      info: 'var(--info)',
    },
    borderRadius: {
      DEFAULT: 'var(--radius)', // `rounded` — the only radius class
    },
    boxShadow: {
      DEFAULT: 'var(--shadow)', // `shadow` — the only shadow class
      none: 'none',
    },
    fontFamily: {
      display: ['var(--font-display)'],
      sans: ['var(--font-sans)'],
      hebrew: ['var(--font-hebrew)'],
      mono: ['var(--font-mono)'],
    },
    fontSize: {
      'display-1': ['56px', { lineHeight: '60px', letterSpacing: '-0.01em' }],
      'display-2': ['40px', { lineHeight: '46px', letterSpacing: '-0.005em' }],
      'title-1':   ['28px', { lineHeight: '34px', letterSpacing: '0' }],
      'title-2':   ['20px', { lineHeight: '28px', letterSpacing: '0' }],
      'body-1':    ['16px', { lineHeight: '26px', letterSpacing: '0' }],
      'body-2':    ['14px', { lineHeight: '22px', letterSpacing: '0.005em' }],
      'meta':      ['12px', { lineHeight: '18px', letterSpacing: '0.04em' }],
    },
    spacing: {
      '0.5': '2px',  // tight-primitive exception (badge px, input border) — comment required at use site
      '2': '8px', '4': '16px', '6': '24px',
      '8': '32px', '12': '48px', '16': '64px', '24': '96px',
    },
  },
}
```
**Acceptance:**
- [ ] `borderRadius` has exactly one non-trivial key (`DEFAULT`); no `sm/md/lg/xl`.
- [ ] Spacing scale contains only `{0.5,2,4,6,8,12,16,24}`; 12px/20px/28px/36px/40px absent.
- [ ] Typography scale matches the seven-token table exactly (sizes, line heights, tracking).

### Task 4: Font loading (`@font-face`, preload, subset)
**Blocks:** 14  ·  **Blocked by:** 3
**Files:**
- Create: `packages/ui/src/tokens/fonts.css`
- Create: `apps/zync-app/public/fonts/` (place WOFF2 assets: `ibm-plex-sans-variable.woff2`, `ibm-plex-mono-variable.woff2`, `heebo-variable.woff2`, `fraunces-variable.woff2`)
- Modify: `apps/zync-app/index.html` (add preload links in `<head>`)
**Steps:**
- [ ] Write `@font-face` for IBM Plex Sans (`font-display: swap`, `font-weight: 100 700`), Heebo Variable (`swap`, `font-weight: 100 900`), IBM Plex Mono (`swap`), and Fraunces (display-only, `font-display: optional`). All sources WOFF2 only.
- [ ] Subset Hebrew + Latin only (full Hebrew block U+0590–U+05FF + Latin Extended-A) using `pyftsubset`/`glyphhanger` as part of asset prep; document target ~40KB WOFF2 for the Hebrew variable font.
- [ ] Preload the two critical body fonts (IBM Plex Sans + Heebo Variable) in `<head>` before any stylesheet, with `as="font" type="font/woff2" crossorigin`.
**Schema / Interfaces:**
```css
/* packages/ui/src/tokens/fonts.css */
@font-face {
  font-family: 'IBM Plex Sans';
  src: url('/fonts/ibm-plex-sans-variable.woff2') format('woff2');
  font-display: swap;
  font-weight: 100 700;
}
@font-face {
  font-family: 'Heebo Variable';
  src: url('/fonts/heebo-variable.woff2') format('woff2');
  font-display: swap;
  font-weight: 100 900;
}
@font-face {
  font-family: 'IBM Plex Mono';
  src: url('/fonts/ibm-plex-mono-variable.woff2') format('woff2');
  font-display: swap;
  font-weight: 100 700;
}
@font-face {
  font-family: 'Fraunces';
  src: url('/fonts/fraunces-variable.woff2') format('woff2');
  font-display: optional; /* display-only; a missed load is acceptable */
  font-weight: 100 900;
}
```
```html
<!-- apps/zync-app/index.html <head>, before any stylesheet -->
<link rel="preload" href="/fonts/ibm-plex-sans-variable.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/heebo-variable.woff2" as="font" type="font/woff2" crossorigin>
```
**Acceptance:**
- [ ] All `@font-face` declarations are WOFF2-only; Fraunces uses `font-display: optional`, all others `swap`.
- [ ] Exactly the two critical body fonts are preloaded; banned families (Inter, Geist, system-ui, etc.) absent.

### Task 5: Primitives group (`primitives/**`)
**Blocks:** 12, 14  ·  **Blocked by:** 1, 2, 3
**Files:**
- Create: `packages/ui/src/primitives/button.tsx`, `input.tsx`, `badge.tsx`, `avatar.tsx`, `card.tsx`, `checkbox.tsx`, `radio.tsx`, `select.tsx`, `switch.tsx`, `textarea.tsx`, `tooltip.tsx`, `separator.tsx`, `skeleton.tsx`
**Steps:**
- [ ] `Button` — wrap Radix `Slot` for `asChild`; `cva` variants/sizes; `loading` renders `Spinner` and disables interaction; only token classes (no hardcoded color/spacing).
- [ ] `Input` — forward `ref`; render `prefix`/`suffix` slots via logical-property insets (`ps-*`/`pe-*`); render `error` text (full a11y wiring lives in FormField, Task 7).
- [ ] `Badge` — `cva` variants/sizes; tight padding may use `p-0.5` (2px) with a required comment.
- [ ] `Card` — compound `Card`, `Card.Header`, `Card.Body`, `Card.Footer`; `padding`/`shadow`/`border` props mapping to `rounded`/`shadow`/`border-line`.
- [ ] `Avatar` (Radix Avatar with sizes), `Checkbox`/`Radio`/`Switch` (Radix), `Select` (Radix Select with `options`), `Textarea`, `Tooltip` (Radix), `Separator` (Radix, logical), `Skeleton` (token-driven shimmer respecting reduced-motion — see Task 11 note).
**Schema / Interfaces:**
```ts
interface ButtonProps {
  variant: 'default' | 'secondary' | 'ghost' | 'destructive' | 'outline' | 'link'
  size: 'sm' | 'md' | 'lg' | 'icon'
  loading?: boolean
  disabled?: boolean
  asChild?: boolean // Radix Slot composition
}
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  error?: string
  prefix?: React.ReactNode // icon/text inline-start of input
  suffix?: React.ReactNode // icon/text inline-end of input
}
interface BadgeProps {
  variant: 'default' | 'secondary' | 'success' | 'warning' | 'error' | 'outline'
  size: 'sm' | 'md'
}
interface CardProps {
  padding?: 'none' | 'sm' | 'md' | 'lg'
  shadow?: 'none' | 'sm' | 'md'
  border?: boolean
} // Compound: Card, Card.Header, Card.Body, Card.Footer
```
**Acceptance:**
- [ ] No hex/rgb/hsl literal and no off-grid spacing in any file (passes Task 13 lint rules).
- [ ] `Button loading` renders a spinner and sets `disabled`/`aria-disabled`.
- [ ] `asChild` delegates rendering to the child element (link-as-button works).
- [ ] No banned interaction states present: no `hover:scale-*`, `hover:shadow-*`, `backdrop-blur`, or gradient (see Design Guardrails).

### Task 6: Layout group (`layout/**`)
**Blocks:** 12, 14  ·  **Blocked by:** 1, 2, 3
**Files:**
- Create: `packages/ui/src/layout/stack.tsx`, `container.tsx`, `divider.tsx`
**Steps:**
- [ ] `Stack` — flex container with `direction` (`vertical`/`horizontal`) and `gap` restricted to the 8px-grid tokens; uses logical gap.
- [ ] `Container` — max-width wrapper participating in the 12-column grid; never `col-span-12` centered by default (mandate asymmetry).
- [ ] `Divider` — hairline using `border-line` / `border-line-subtle`; hairline stays within 0.04 L of its surface (border-proximity rule — satisfied by spec 114's `--line*` tokens).
**Acceptance:**
- [ ] `Stack` gap accepts only grid tokens; arbitrary px rejected by lint.
- [ ] `Divider` uses a `line`/`line-subtle` token, never a raw color.

### Task 7: Forms group (`forms/**`) with WCAG error wiring
**Blocks:** 12, 14  ·  **Blocked by:** 1, 2, 3, 5
**Files:**
- Create: `packages/ui/src/forms/form.tsx`, `form-field.tsx`, `form-label.tsx`, `form-error.tsx`
**Steps:**
- [ ] `Form` — wraps `react-hook-form` `FormProvider`; props `form` (the `useForm` return) and `onSubmit`; validates via a `zod` resolver supplied by the consumer.
- [ ] `FormField` — composes label + child input + error; **automatically wires `aria-invalid` and `aria-describedby`** on the wrapped input when an error is present (WCAG 2.1 SC 1.3.1, SC 3.3.1).
- [ ] Render the error in `<p id="{name}-error" role="alert">` so screen readers announce it immediately; `aria-describedby` points to that id and persists after blur.
- [ ] Never indicate an error by color alone — border-color change must accompany the error text/icon.
**Schema / Interfaces:**
```tsx
// Usage pattern
<Form form={form} onSubmit={handleSubmit}>
  <FormField name="email" label="Email" required>
    <Input type="email" />
  </FormField>
  <FormField name="role" label="Role">
    <Select options={roleOptions} />
  </FormField>
</Form>

// packages/ui/src/forms/form-field.tsx — error a11y wiring (verbatim)
const errorId = `${name}-error`
<input
  {...field}
  aria-invalid={!!error}
  aria-describedby={error ? errorId : undefined}
/>
{error && (
  <p id={errorId} role="alert" className="text-danger text-body-2 mt-2">
    {error.message}
  </p>
)}
```
**Acceptance:**
- [ ] When a field has an error: input has `aria-invalid="true"` and `aria-describedby="{name}-error"`; the error `<p>` has matching `id` and `role="alert"`.
- [ ] Removing the error clears both `aria-invalid` and `aria-describedby`.
- [ ] Error state is conveyed by text (not color alone).

### Task 8: Overlays group (`overlays/**`)
**Blocks:** 12, 14  ·  **Blocked by:** 1, 2, 3
**Files:**
- Create: `packages/ui/src/overlays/dialog.tsx`, `sheet.tsx`, `dropdown-menu.tsx`, `context-menu.tsx`, `popover.tsx`, `command.tsx`
**Steps:**
- [ ] `Dialog` — Radix Dialog; compound `Dialog`, `Dialog.Trigger`, `Dialog.Content`, `Dialog.Footer`; controlled via `open`/`onOpenChange`; `size` width mapping; `title` required, `description` optional (wired to `aria-describedby`).
- [ ] `Sheet` — Radix Dialog styled as slide-in drawer; `side` (`left`/`right`/`bottom`) using logical insets so RTL flips correctly; `size` mapping.
- [ ] `DropdownMenu`, `ContextMenu`, `Popover` — Radix wrappers with token styling and keyboard nav inherited from Radix.
- [ ] `Command` — wrap `cmdk`; `groups` of selectable items, optional async `onSearch`; used by global search/command palette.
**Schema / Interfaces:**
```ts
interface DialogProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  title: string
  description?: string
  size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
} // Compound: Dialog, Dialog.Trigger, Dialog.Content, Dialog.Footer
interface SheetProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  side?: 'left' | 'right' | 'bottom'
  size?: 'sm' | 'md' | 'lg' | 'xl'
}
interface CommandProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  placeholder?: string
  groups: Array<{
    label: string
    items: Array<{ id: string; label: string; icon?: React.ReactNode; onSelect: () => void }>
  }>
  onSearch?: (query: string) => void // async search
}
```
**Acceptance:**
- [ ] `Dialog`/`Sheet`/`Popover` trap focus and close on Esc (Radix-provided); `Sheet side` flips under `dir="rtl"` via logical properties.
- [ ] `Command` renders grouped items and fires `onSelect`/`onSearch`.

### Task 9: Navigation group (`navigation/**`)
**Blocks:** 12, 14  ·  **Blocked by:** 1, 2, 3
**Files:**
- Create: `packages/ui/src/navigation/tabs.tsx`, `breadcrumb.tsx`, `pagination.tsx`
**Steps:**
- [ ] `Tabs` — Radix Tabs with token styling and roving-tabindex keyboard nav.
- [ ] `Breadcrumb` — semantic `<nav aria-label>` + ordered list; separators use logical direction so RTL renders correctly.
- [ ] `Pagination` — page controls with `aria-current="page"` on the active page; prev/next use logical-direction icons.
**Acceptance:**
- [ ] `Tabs` is keyboard-navigable (arrow keys) via Radix.
- [ ] `Pagination` active page exposes `aria-current="page"`; controls flip under RTL.

### Task 10: Data-display group (`data-display/**`)
**Blocks:** 12, 14  ·  **Blocked by:** 1, 2, 3
**Files:**
- Create: `packages/ui/src/data-display/table.tsx`, `data-table.tsx`, `stat-card.tsx`, `empty-state.tsx`, `virtual-list.tsx`
**Steps:**
- [ ] `Table` — base token-styled table primitives (`Table`, `Thead`, `Tbody`, `Tr`, `Th`, `Td`).
- [ ] `DataTable` — TanStack Table v8 wrapper; `pagination` (paginates, does NOT virtualize), `sorting`, `filtering`, `loading` (skeleton rows), `emptyState`, `rowActions`, `onRowClick`.
- [ ] `StatCard` — `label`/`value`/optional `trend`/`loading`; **no `icon` prop** — decorative icons on KPI cards are banned.
- [ ] `EmptyState` — `heading` (one conversational sentence, no exclamation) + at most one `action`; **no icon/illustration/description props**; canonical copy comes from `error-empty-states` spec.
- [ ] `VirtualList` — `@tanstack/react-virtual` v3; renders only visible rows; for infinite-scroll patterns (notification inbox, audit log, chat). Threshold: virtualize when a list can plausibly exceed ~200 rows.
**Schema / Interfaces:**
```ts
interface DataTableProps<TData> {
  columns: ColumnDef<TData>[]
  data: TData[]
  pagination?: boolean
  sorting?: boolean
  filtering?: boolean | { column: string }
  loading?: boolean
  emptyState?: React.ReactNode
  rowActions?: (row: TData) => React.ReactNode
  onRowClick?: (row: TData) => void
}
interface StatCardProps {
  label: string
  value: string | number
  trend?: { value: number; direction: 'up' | 'down' | 'flat'; label?: string }
  loading?: boolean
  // No `icon` prop — decorative icons on KPI cards are banned
}
interface EmptyStateProps {
  heading: string // one sentence, conversational, no exclamation marks
  action?: { label: string; href?: string; onClick?: () => void }
  className?: string
  // No icon, illustration, or description props — by design
}
interface VirtualListProps<T> {
  items: T[]
  estimateSize: number       // estimated row height in px
  renderItem: (item: T, index: number) => React.ReactNode
  overscan?: number          // default: 5
}
```
**Acceptance:**
- [ ] `DataTable` paginates (no virtualization) and renders `emptyState` when `data` is empty.
- [ ] `StatCardProps` has no `icon` member; `EmptyStateProps` has no icon/illustration/description member.
- [ ] `VirtualList` renders only visible rows (DOM node count ≪ `items.length` for large lists).

### Task 11: Feedback group (`feedback/**`) + reduced-motion guard
**Blocks:** 12, 14  ·  **Blocked by:** 1, 2, 3
**Files:**
- Create: `packages/ui/src/feedback/alert.tsx`, `toast.tsx`, `spinner.tsx`, `progress.tsx`
**Steps:**
- [ ] `Alert` — variants (default/success/warning/error/info) using semantic tokens; icon + text (never color alone).
- [ ] `Toast` — integrate `sonner`; export a `Toaster` mount + a typed `toast` helper.
- [ ] `Spinner` — sizes (`sm`/`md`/`lg`); animated rotation.
- [ ] `Progress` — Radix Progress; determinate/indeterminate; `aria-valuenow/min/max`.
- [ ] Wrap all motion (spinner spin, skeleton shimmer, progress indeterminate sweep, opacity transitions) in a `@media (prefers-reduced-motion: reduce)` guard that disables/reduces animation — honoring cross-cutting accessibility authority over the spec's `transition-opacity 150ms` interaction state.
**Acceptance:**
- [ ] `Spinner` and `Progress` animations are suppressed under `prefers-reduced-motion: reduce`.
- [ ] `Alert` conveys state via icon + text, not color alone.

### Task 12: Barrel export (`packages/ui/src/index.ts`)
**Blocks:** 14  ·  **Blocked by:** 5, 6, 7, 8, 9, 10, 11
**Files:**
- Create: `packages/ui/src/index.ts`
**Steps:**
- [ ] Re-export every component and its public Props type from all groups, plus `cn`. Import `./tokens/index.css` for side-effect token registration is done by app entry, not the barrel (keep barrel tree-shakeable).
- [ ] Verify the consumer import surface: `import { Button, DataTable, FormField, … } from '@zync/ui'`.
**Schema / Interfaces:**
```ts
// packages/ui/src/index.ts (illustrative — re-export ALL of the below)
export { cn } from './lib/cn'
export * from './primitives/button'   // Button, ButtonProps
export * from './primitives/input'    // Input, InputProps
export * from './primitives/badge'    // Badge, BadgeProps
export * from './primitives/avatar'   // Avatar
export * from './primitives/card'     // Card, CardProps
export * from './primitives/checkbox' // Checkbox
export * from './primitives/radio'    // Radio
export * from './primitives/select'   // Select
export * from './primitives/switch'   // Switch
export * from './primitives/textarea' // Textarea
export * from './primitives/tooltip'  // Tooltip
export * from './primitives/separator'// Separator
export * from './primitives/skeleton' // Skeleton
export * from './layout/stack'        // Stack
export * from './layout/container'    // Container
export * from './layout/divider'      // Divider
export * from './forms/form'          // Form
export * from './forms/form-field'    // FormField
export * from './forms/form-label'    // FormLabel
export * from './forms/form-error'    // FormError
export * from './overlays/dialog'     // Dialog, DialogProps
export * from './overlays/sheet'      // Sheet, SheetProps
export * from './overlays/dropdown-menu' // DropdownMenu
export * from './overlays/context-menu'  // ContextMenu
export * from './overlays/popover'    // Popover
export * from './overlays/command'    // Command, CommandProps
export * from './navigation/tabs'     // Tabs
export * from './navigation/breadcrumb' // Breadcrumb
export * from './navigation/pagination' // Pagination
export * from './feedback/alert'      // Alert
export * from './feedback/toast'      // Toast, Toaster, toast
export * from './feedback/spinner'    // Spinner
export * from './feedback/progress'   // Progress
export * from './data-display/table'      // Table
export * from './data-display/data-table' // DataTable, DataTableProps
export * from './data-display/stat-card'  // StatCard, StatCardProps
export * from './data-display/empty-state'// EmptyState, EmptyStateProps
export * from './data-display/virtual-list' // VirtualList, VirtualListProps
```
**Acceptance:**
- [ ] Every primitive listed in the spec architecture is re-exported and importable from `@zync/ui`.
- [ ] `pnpm --filter @zync/ui typecheck` passes.

### Task 13: Enforcement ESLint rules (`packages/config`)
**Blocks:** —  ·  **Blocked by:** 1
**Files:**
- Create: `packages/config/eslint-rules/no-hardcoded-colors.js`, `no-hardcoded-spacing.js`, `no-radius-ladder.js`, `no-raw-html-in-pages.js`
- Modify: `packages/config/eslint.config.mjs` (register the local rules + file globs)
**Steps:**
- [ ] `no-hardcoded-colors` — block hex/rgb/rgba/hsl/hsla literals in `apps/**` and `packages/**`; only `oklch(...)` permitted. (OKLCH-exclusive mandate.)
- [ ] `no-hardcoded-spacing` — block inline px gap/padding/margin values not matching the 8px grid `{8,16,24,32,48,64,96}` (+`2px` tight exception with comment).
- [ ] `no-radius-ladder` — block `rounded-sm/md/lg/xl/2xl/full`; only bare `rounded` allowed.
- [ ] `no-raw-html-in-pages` — block raw `<div>/<span>/<p>/<h1-6>` in `apps/*/pages/**` (and `apps/*/src/pages/**`); pages must compose primitives.
- [ ] Register all four in the flat config and confirm they run under `turbo lint`.
**Acceptance:**
- [ ] A hex literal anywhere in `apps/**`/`packages/**` fails lint; `oklch(...)` passes.
- [ ] `gap-3` (12px equivalent) / a raw `style={{ gap: 12 }}` fails; `gap-2/4/6/8/12/16/24` pass.
- [ ] `rounded-lg` fails; `rounded` passes.
- [ ] A raw `<div>` in `apps/zync-app/src/pages/**` fails lint.

### Task 14: Design-system catalogue page (`/design-system`)
**Blocks:** —  ·  **Blocked by:** 12
**Files:**
- Create: `apps/zync-app/src/pages/design-system.tsx`
- Modify: `apps/zync-app/src/routes/index.tsx` (register env-guarded route)
**Steps:**
- [ ] Build the page composing ONLY `@zync/ui` primitives (no raw HTML), with the 11 mandated sections: (1) Colors — all token swatches with CSS-var names; (2) Typography — scale/weights/families; (3) Spacing — scale visualization; (4) Primitives — Button variants/sizes, Input states, Badge variants, Checkbox, Radio, Switch, Avatar sizes, Skeleton; (5) Layout — Stack, Container, Card; (6) Overlays — Dialog, Sheet, DropdownMenu, Popover, Tooltip, Command; (7) Forms — Form composition with validation; (8) Navigation — Tabs, Breadcrumb, Pagination; (9) Feedback — Alert variants, Toast trigger, Spinner sizes, Progress; (10) Data Display — DataTable with example data, StatCard, EmptyState; (11) RTL preview — Hebrew text sample verifying layout symmetry (mandatory visual check).
- [ ] Guard the route behind an env flag so it is reachable only in dev / non-production (`app.zync.is/design-system`); 404 in production.
- [ ] Document the registry rule: when a new primitive is created it must be added to this page in the same PR (code-review gate).
**Acceptance:**
- [ ] All 11 sections render, composed only from `@zync/ui` primitives (no raw HTML — passes `no-raw-html-in-pages`).
- [ ] Route returns 404 in production and renders in dev (env-flag guard verified).
- [ ] The RTL section renders a Hebrew sample with `dir="rtl"` and layout does not break.
- [ ] The page contains no Anti-Pattern (Instant Failure) from Design Guardrails: no centered-hero+stacked-CTAs over gradient, no logo strip, no bento grid, no emoji/decorative icons, no heavy-shadow floating cards, at most one accent element per section.
