---
name: ply-ui-design
description: Design and implement Angular 22 + Tailwind v4 UI that matches Ply's primitives (ply-button, ply-card, ply-input-group, DialogService, ply-data-table, ply-skeleton, ply-empty-state, ToastService). Covers aesthetic direction, structural foundations, accessibility states, motion, and full operational-state coverage (loading, empty, error, disabled). Use when the project has ply-ui.json and you are designing, building, styling, polishing, or reviewing a component, page, layout, dashboard, form, table, dialog, or overlay, and when the user mentions UI design, design system, visual polish, skeletons, empty states, error states, or motion.
---

# Ply UI Design — Angular + Tailwind v4

Read `ply-ui.json` first. Components live at `aliases.components` (default `src/app/components/<name>/`). Import from that folder, never from `base-ui` or `projects/base`. The Tailwind entry is `tailwind.config`; the global stylesheet is `tailwind.css`. Icon sprites are the files `init` downloaded, usually `public/assets/icons.svg` and `public/assets/icons-filled.svg`, or `src/assets/` when that folder is what Angular serves. Never fork or restyle a primitive from the inside; extend it through its `class` input (merged with `cn()`).

## Workflow (every UI task)

```
- [ ] 1. Reuse first: `npx ply-ui-cli list`, or look in the `aliases.components` folder from `ply-ui.json`. Names and APIs: https://ply-ui.com/docs/ai/components.md
- [ ] 2. Verify every `ply-icon` name exists in the outline sprite (`icons.svg`, 392 symbols). Filled artwork is only `star` and `heart` in `icons-filled.svg`.
- [ ] 3. Build the resting state from the recipes in §0 — no new colors, radii, or shadows.
- [ ] 4. Add loading, empty, error, and disabled states (§4) in the same frame as the content.
- [ ] 5. Check hover / active / focus-visible / dark mode / RTL / reduced motion.
- [ ] 6. Run the Delivery checklist at the end of this file.
```

## 0. Ground truth — tokens and primitive recipes

### Tokens (`ply-ui.css`, next to the global stylesheet from `ply-ui.json`)

| Token | Light | Dark | Use |
|---|---|---|---|
| `--ply-background` / `--ply-foreground` | `#ffffff` / `#0f172a` | `#0f172a` / `#f8fafc` | page canvas, ring offset |
| `--ply-primary` (`-hover`, `-active`, `-foreground`) | `#2563eb` / `#1d4ed8` / `#1e40af` / white | same | the one accent |
| `--ply-primary-soft` | 14% primary mix | 22% | selected rows, active options |
| `--ply-muted` / `--ply-muted-foreground` | `#e2e8f0` / `#334155` | `#1e293b` / `#e2e8f0` | default buttons, quiet fills |
| `--ply-destructive` (`-hover`, `-foreground`) | `#dc2626` / `#b91c1c` / white | same | danger buttons only |
| `--ply-border` | `#cbd5e1` | `#334155` | stroked buttons, `ply-table` |
| `--ply-ring` | `= --ply-primary` | same | focus rings |
| `--ply-radius` | `0.5rem` | same | every control |

`--color-blue-50…950` are aliased to the brand scale, so `bg-blue-600` / `text-blue-600` follow the theme. Theme Studio overrides `--ply-brand` and `--ply-scale-*`; never hardcode hex.

### Primitive recipes (the exact classes the library uses — match them)

| Element | Classes |
|---|---|
| Control radius | `rounded-[var(--ply-radius)]` (buttons, inputs, selects) |
| Surface radius | `rounded-xl` (card, dialog, alert, toast, popover, table wrapper); `rounded-md` (dropdown menu, tooltip, kbd, rectangular badge); `rounded-3xl` empty-state; `rounded-t-2xl` bottom sheet; `rounded-full` chips, avatars, pills, dots only |
| Card | `bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700`, no shadow — `ply-card-header` is a fixed `h-14 px-4 font-semibold border-b` bar (pass `class="h-auto py-4"` for stacked titles), `ply-card-body` defaults to `p-4` (use `class="p-6"` for content cards), `ply-card-footer` `flex p-4 border-t` |
| Overlay panels | dropdown `bg-white dark:bg-slate-800 rounded-md shadow-xl border border-slate-200 dark:border-slate-700 min-w-40` with items `h-12 px-6 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors duration-200` · popover `rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4 shadow-2xl` · tooltip `rounded-md bg-slate-900 text-white text-sm px-2 py-1 max-w-xs shadow-lg` · option lists (`ply-custom-select`, `ply-combobox`) `rounded-[var(--ply-radius)] border border-[var(--ply-border)] bg-[var(--ply-background)] shadow-lg max-h-60 overflow-auto`; `ply-multi-select` / `ply-select-tree` use `rounded-xl border-slate-200 dark:border-slate-700 shadow-lg`. `ply-select` is a native `<select>` — no panel |
| Page / well | `bg-slate-50 dark:bg-slate-900` |
| Raised / hover / skeleton fill | `bg-slate-100 dark:bg-slate-700` · hover rows `hover:bg-slate-50 dark:hover:bg-slate-800` |
| Hairline | `border-slate-200 dark:border-slate-700` or `border-[var(--ply-border)]` |
| Button base | `flex items-center gap-2 rounded-[var(--ply-radius)] font-medium tracking-wide transition-all duration-200 disabled:opacity-50 disabled:pointer-events-none` + `FOCUS_RING` |
| Button / control heights | `sm` h-7 · `default` h-9 · `lg` h-10 · `xl` h-11 · `xxl` h-14 — bare `<input>` without `ply-input` must set `h-9`. Buttons in one row share a single `size`: all `default` or all `lg` (or all `sm`). Never put `size="lg"` next to a default-size sibling |
| Input | `[ply-input]` `h-9 px-4 text-sm text-slate-700 dark:text-slate-200 bg-transparent`; the `ply-input-group` shell owns the chrome: `min-h-9 border border-[var(--ply-border)] rounded-[var(--ply-radius)] bg-[var(--ply-background)] shadow-sm` + `FOCUS_RING_WITHIN`, turning `border-red-500!` when the projected control is touched and invalid |
| Label / error / hint | `ply-label` `text-sm font-medium text-slate-700 dark:text-slate-300` (auto-linked `for`) · `ply-error` `text-xs text-red-500 mt-1` (rendered by the group only when invalid; `aria-invalid` / `aria-describedby` set automatically) · `ply-info-text` `text-xs text-slate-500 dark:text-slate-400 mt-1` |
| Typography directive | `[plyTypography]` on any element: `display` `text-4xl font-bold tracking-tight sm:text-5xl` · `title` `text-3xl font-bold tracking-tight` · `heading` `text-2xl font-semibold tracking-tight` · `subheading` `text-lg font-medium` · `body` `text-base text-slate-600 dark:text-slate-300` · `caption` `text-sm text-slate-500 dark:text-slate-400` · `overline` `text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400` |
| Dialog | backdrop `bg-slate-900/50 dark:bg-slate-900/80 backdrop-blur-sm` · box `bg-white dark:bg-slate-800 rounded-xl shadow` |
| Toast | `rounded-xl shadow-xl border bg-white dark:bg-slate-800 text-sm font-medium` |
| Table header | `py-3 px-4 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400` on `bg-slate-50 dark:bg-slate-800/50` |
| Table row / cell | row `transition-colors duration-150 hover:bg-slate-50 dark:hover:bg-slate-800/30` · cell `py-3 px-4 text-sm text-slate-700 dark:text-slate-300` |
| Eyebrow / metadata | `text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400` |
| Metric | `text-[30px] font-black leading-none tracking-tight tabular-nums text-slate-900 dark:text-white` |
| Selected / active option | `PRIMARY_SOFT` → `bg-[var(--ply-primary-soft)] text-[var(--ply-primary)]` |
| Focus | import `FOCUS_RING`, `FOCUS_RING_INSET`, `FOCUS_RING_WITHIN` from `utils/tw-merge` — never hand-roll rings |
| Layering | `Z_SCALE` from `utils/z-index`: sticky 50 · overlay 1000 · popover 1100 · toast 1200 |

### Reuse map — do not rebuild these

| Need | Use |
|---|---|
| Modal | `inject(DialogService).open(MyComponent, data?, className?, options?)` with `ply-dialog` + `ply-dialog-header/body/footer`, `[ply-dialog-close]`. The second argument is dialog data. `options` is only `{ hideOnBackdropClick?, containerType? }` |
| Confirm / destructive | `DialogService.confirm({ title, description, destructive: true })` |
| Notification | `ToastService.success/error/warning/info/promise()` — default position `top-end` (follows writing direction). Never place `ply-toast-container` in a template |
| Side panel / mobile sheet | `ply-drawer` (`[ply-drawer]` trigger, needs `provideAnimationsAsync()`) · `ply-bottom-sheet` |
| Table | `ply-data-table` (`loading`, `emptyMessage`, `plyTableCell`, `plyTableEmpty`) or `table[ply-table]` primitives |
| Placeholder while loading | `ply-skeleton` · `ply-data-table [loading]` · `ply-loading-overlay` · `ply-progress-button [loading]` |
| Nothing to show | `ply-empty-state` + projected CTA · `@for … @empty` |
| Inline status | `ply-alert` with `color` = `danger` / `warning` / `success` / `primary`, actions in `ply-alert-actions` |
| KPI / number | `ply-stat-card` (`variant="metric"`), `ply-animated-counter`, `ply-progress`, `ply-meter-group` |
| Form field | `ply-input-group` > `ply-label` + `<input ply-input>` + `ply-error` / `ply-info-text`; composite controls (`ply-custom-select`, `ply-multi-select`, `ply-phone-input`, `ply-currency-input`, …) sit as siblings of `ply-label`, never inside the group |
| Gallery slides | `slider`. Numeric ranges are `range-slider` and `dual-range-slider` |

### Non-negotiable project rules (summary of `AGENTS.md` / `CLAUDE.md`)

- Standalone, `ChangeDetectionStrategy.OnPush`, `input()` / `output()` / `model()`, `@if` / `@for` / `@switch` / `@empty`. Library selectors `ply-*`; demo app selectors `app-*`.
- Zoneless: any state written outside a template event handler (timers, observables, resize, IntersectionObserver) must be a `signal`. Timers via `injectTimers()`.
- Always pair light and dark: `bg-white dark:bg-slate-800`, `text-slate-900 dark:text-white`.
- `ply-icon` outlines use `stroke-*`. Filled icons (`[filled]="true"`, only `star` and `heart`) use `text-*` or `fill-*`. Verify the name in the outline sprite.
- Reset heading / paragraph margins with `m-0!` / `mt-0!` (Tailwind v4 suffix, not `!m-0`).
- Spacing: standard scale only (`p-4`, `gap-2`, `mb-6`). No `[Npx]` for padding, margin, or gap. A fixed control size that is not on the scale may stay a pixel value.
- RTL: logical utilities for new layout (`ms-*`, `me-*`, `ps-*`, `pe-*`, `start-*`, `end-*`, `text-start`, `text-end`).
- Overlays trap focus (CDK `FocusTrapFactory` or `cdkTrapFocus cdkTrapFocusAutoCapture`), close on Escape, and are SSR-safe (`isPlatformBrowser`).

## 1. Aesthetic Direction & Uniqueness (The Look)

- **Anti-Patterns (Forbidden):** No generic rounded-full buttons, random multi-color glowing blobs, or monotonous card grids. Every element must feel intentional.
  - Ply: buttons keep `rounded-[var(--ply-radius)]`; `rounded-full` is reserved for `ply-chip`, avatars, status dots, and delta pills. Break card grids with a `ply-stat-card variant="metric"` row, a full-width `ply-data-table`, or a 2:1 `grid-cols-[2fr_1fr]` split — not more identical cards.
- **Surface & Depth:** Use layered tonal contrasts and razor-thin borders (`border-white/[0.08]`) rather than heavy drop shadows or default gray lines.
  - Ply: depth is tonal — page `bg-slate-50 dark:bg-slate-900` → surface `bg-white dark:bg-slate-800` → raised `bg-slate-100 dark:bg-slate-700`. The 1px hairline is `border-slate-200 dark:border-slate-700`; use `border-white/[0.08]`–`/10` only on ink, image, or `bg-slate-900` surfaces. Cards have no shadow; inputs carry `shadow-sm`. The shadow ladder is reserved for floating layers: `shadow-lg` (tooltip, option lists), `shadow-xl` (dropdown menu, toast), `shadow-2xl` (popover, bottom sheet).
- **Color Discipline:** Strict monochromatic foundation with **one** high-intent, high-saturation accent color used exclusively for key interactive states or active data points.
  - Ply: the neutral is `slate` — never mix in `gray`, `zinc`, or `neutral`. The accent is `--ply-primary` (`bg-[var(--ply-primary)]`, `text-blue-600 dark:text-blue-400`, `PRIMARY_SOFT`). Do not introduce indigo, violet, sky, or gradients as accents. `green` / `orange` / `red` (`--ply-destructive`) / `purple` are semantic (success / warning / danger / accent) and appear only where they carry meaning — status badges, alerts, deltas, destructive actions.
- **Typographic Hierarchy:** High weight contrast, tight tracking on headers (`tracking-tight`), and microscopic uppercase tracking for metadata/tags (`text-[10px] uppercase tracking-widest`).
  - Ply: headings `font-bold tracking-tight text-slate-900 dark:text-white` (page `text-3xl`, card `text-xl`, dialog header `text-lg`) or the `plyTypography` variants (`title`, `heading`, `subheading`); body `text-sm text-slate-700 dark:text-slate-300`; muted `text-slate-500 dark:text-slate-400`. Eyebrows: `plyTypography="overline"` for section labels, `text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400` in data widgets, `<ply-label class="text-[10px]! uppercase font-light! tracking-[2px]!">` in form blocks, `text-xs font-semibold uppercase tracking-wider` in table heads. Pick one eyebrow recipe per screen. Big numbers: `text-[30px] font-black leading-none tracking-tight tabular-nums`.

## 2. Technical & Structural Foundations (The Code)

- **Mobile-First Responsiveness:** Always design compact layouts first, scaling up cleanly using explicit breakpoint modifiers (`sm:`, `md:`, `lg:`).
  - Ply: page container `mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8`; stack → `sm:grid-cols-2 lg:grid-cols-4`; dialogs are already capped at `max-w-[calc(100%-32px)]`; drawers cap at `max-w-[92vw]`. Use `CurrentScreenSizeService` only when layout cannot be expressed in classes.
- **Layout Architecture:** Prefer CSS Grid for macro-layout structures and Flexbox for fine-grained component alignment. Avoid arbitrary pixel values—stick to a strict spacing rhythm (e.g., Tailwind 4, 8, 12, 16, 24, 32).
  - Ply: `gap-1` inline, `gap-2` control groups, `gap-3` list rows, `gap-4` card grids, `gap-6` sections, `space-y-6` / `mb-8` between page blocks. Card body `p-6`, dense panels `p-4`, table cells `py-3 px-4`. Fixed sizes come from the scale (`w-64`, `h-12`, `size-10`), never `[Npx]`.
- **Accessibility & States:**
  - Every interactive element (buttons, links, toggles) must feature explicit hover, active, focus, and disabled states.
  - Maintain WCAG AA compliant text-to-background contrast ratios.
  - Ply: hover `hover:bg-slate-50 dark:hover:bg-slate-800`, active `active:bg-slate-100 dark:active:bg-slate-700`, focus `FOCUS_RING` (`focus-visible:` only — no rings on mouse click), disabled `disabled:opacity-50 disabled:pointer-events-none disabled:cursor-not-allowed`. Icon-only buttons need `aria-label`. `text-slate-400` on white is about 2.6:1 — allowed only for icons, placeholders, eyebrows, and decorative text; readable muted copy is `text-slate-500 dark:text-slate-400` (4.8:1 / 5.7:1) or darker.
- **Forms & Feedback:** Forms require inline validation styling, explicit error text, and smooth loading states for asynchronous actions.
  - Ply: `ply-input-group` + `ply-label` + `<input ply-input formControlName="…">` + `<ply-error>` + optional `ply-info-text`. The group reads the projected `NgControl` / `[formField]`: after the field is touched and invalid it shows the red shell, renders `ply-error`, and sets `aria-invalid` + `aria-describedby` — do not wrap `ply-error` in `@if` or add those attributes by hand; switch messages inside one `ply-error` (`@if (ctrl.hasError('required')) { … } @else { … }`). Composite CVA hosts bind `[invalid]` on the group. Submit through `<ply-progress-button type="submit" [loading]="saving()">` (sets `aria-busy`, blocks re-submit). Long operations: `ToastService.promise(op, { loading, success, error })`.

## 3. Micro-Interactions & Transitions

- Keep motion fast, weighted, and natural: `transition-all duration-200 ease-out`.
  - Ply: feedback (hover, press, color) `transition-colors duration-150` – `duration-200`; enter / expand / height `duration-300` (`openClose`, drawer, bottom sheet); exits are shorter than entries and use `ease-in`. Prefer `transition-colors` / `transition-opacity` / `transition-transform` over `transition-all` when one property changes.
- Implement subtle state shifts (e.g., `hover:border-slate-400/40` or microscopic `active:scale-[0.99]`).
  - Ply: cards and tiles `hover:border-slate-300 dark:hover:border-slate-600` (or `hover:border-slate-400/40` on dark surfaces) plus optional `active:scale-[0.99]`. `ply-button` already steps its background on hover / active — do not add scale to it. Reuse `animations.ts` triggers (`dropdown`, `openClose`, `rotate180`, `slideLeft/Right/Top/Bottom`) instead of writing new ones.

## 4. Operational States & Resilience (The Reality)

- **State Coverage:** Every component must gracefully handle Loading (skeleton screens matching exact dimensions), Empty (actionable empty states with copy and CTAs), and Error states.
- **Data & Text Handling:** Apply explicit line clamping (`line-clamp-2`) or truncation for unpredictable text lengths. Use tabular numerals (`font-tabular-nums`) for all data grids, financial figures, and dashboards.
- **Motion Orchestration:** Never use jarring transitions. Use swift, purposeful curves (`transition-all duration-200 ease-out`). Modals and overlays must feature subtle backdrop blurs and scale-in entries.

Resolve states in this order and render exactly one branch: `loading` → `error` → `empty` → `data`. Every branch lives inside the same container (`ply-card`, same padding, same `min-h-*`) so nothing jumps when the state changes.

### Loading

- `ply-skeleton` is pulse-only (`bg-slate-200 dark:bg-slate-700 animate-pulse`). Variants: `text` (h-3), `circular` (40px), `rectangular`, `card`, `table-row`; `count`, `width` / `height` take CSS strings (`width="100%" height="16rem"`).
- Match the final layout 1:1 — same card, same padding, same row count, same column widths. A list of 5 rows loads as `count="5"`; a `16rem` chart loads as a `16rem` rectangle. Never a lone spinner for a region with more than one element.
- `ply-data-table [loading]="true"` renders its own skeleton rows (`min(pageSize, 5)`); do not wrap it in another skeleton.
- Lazy chunks: `@defer (on viewport; prefetch on idle) { … } @placeholder { <ply-skeleton …> } @loading (minimum 300ms) { <ply-skeleton …> }` — the `minimum` prevents a flash.
- Refreshing existing data: keep the content and overlay `<ply-loading-overlay [visible]="refreshing()" message="Updating…">` (`aria-busy`, `role="status"`, `bg-white/80 dark:bg-slate-900/80`, no blur — reserve blur for modals).
- Async buttons: `<ply-progress-button [loading]="saving()">` — `loading` is a plain boolean input, so bind it (`[loading]="…"`), not as a bare attribute. Keep the button width stable; the label fades to `opacity-0` under the spinner.
- `ply-spinner` has no ARIA of its own — wrap it in `role="status" aria-label="Loading"` or use `ply-loading-overlay`.

### Empty

- Lists: `@for (item of items(); track item.id) { … } @empty { <ply-empty-state …> }`.
- `ply-empty-state` takes `iconName`, `title`, `description` and projects the CTA: `<button ply-button color="primary">New project</button>`. Two flavors — first-run (primary CTA that creates the thing) and filtered / no-results (`iconName="search"`, secondary `ply-stroked-button` "Clear filters"). Never leave an empty state without an action or an explanation.
- Copy: title names what is missing in ≤ 5 words ("No invoices yet"); description says why or what to do in one sentence; CTA is a verb ("Create invoice", "Clear filters", "Import CSV").
- Tables: `<ply-data-table emptyMessage="No results for this filter">` or a custom `<ng-template plyTableEmpty>` containing a compact empty state. Chrome strings localize through `provideBaseUiI18n`.
- Icons that exist for empty states: `inbox`, `search`, `folder`, `folder-open`, `file-text`, `images`, `users`, `calendar`, `cart`, `bell-off`, `filter`.

### Error

- Field: `<ply-error>` inside `ply-input-group` — the group shows it after touch, paints the shell `border-red-500!`, and wires `aria-invalid` / `aria-describedby`. Write a specific message, never rely on the red border alone, and keep the message under the field (not in a toast).
- Section / data region: `<ply-alert color="danger" icon="alert-triangle" [close]="true">` with `<ply-alert-actions><button ply-stroked-button color="danger" size="sm" (click)="retry()">Retry</button></ply-alert-actions>`. The host already has `role="alert"`.
- Whole region failed: `<ply-empty-state iconName="alert-triangle" title="Couldn't load orders" description="Check your connection and try again.">` + Retry button. Other error icons: `x-circle`, `alert-circle`, `shield-alert`, `wifi-off`, `file-x`, `folder-x`.
- Background operations: `ToastService.error(message, { action: { label: 'Retry', onClick } })`; long jobs: `ToastService.promise()`. Danger / warning toasts announce as `role="alert"` automatically.
- Destructive confirmation: `DialogService.confirm({ title: 'Delete file?', description: '…', destructive: true })` — never a one-off confirm dialog.
- Keep the frame: an error replaces the content inside the same container and `min-h-*`; do not collapse the region or shake / bounce it. Errors are never console-only.

### Data and text

- Tailwind's utility is `tabular-nums` (`font-tabular-nums` is not a class). Ply already applies it in `ply-stat-card` (metric), `ply-progress`, `ply-countdown`, `ply-animated-counter`, `ply-meter-group`, and `ply-time-picker` — apply it to every numeric cell, KPI, price, and axis label you add.
- Numeric table columns: `align="right"` on the column (renders `text-end`) plus `<ng-template plyTableCell="amount" let-value="value"><span class="tabular-nums whitespace-nowrap">{{ value | currency }}</span></ng-template>`.
- Single-line text in flex or grid: `min-w-0` on the child, then `truncate`, and `[title]` when the full value matters (IDs, emails, file names).
- Multi-line descriptions: `line-clamp-2` (cards, list rows) or `line-clamp-3` (article teasers). Titles: `line-clamp-1` / `truncate`.
- `font-mono` only for IDs, hashes, code, and tokens — not for money. Dates and currency get `whitespace-nowrap`.
- `ply-chip` and `ply-tags-input` wrap instead of truncating — cap visible tags in the template (`+3 more`) when space is fixed.

### Motion orchestration

- Modals: always `DialogService`. The container ships `bg-slate-900/50 dark:bg-slate-900/80 backdrop-blur-sm`, a `scale(0.5→1.05→1)` entry over 300ms `ease-out`, a mirrored `ease-in` exit, focus trap, Escape, and `aria-modal`. Do not add a second backdrop, animation, or z-index.
- Sheets and drawers: `ply-bottom-sheet` (`backdrop-blur-sm`, backdrop fades in 200ms `ease-out` / out 150ms `ease-in`, panel slides 300ms) and `ply-drawer` (`backdrop-blur-[8px]`, 300ms slide). Register `provideAnimationsAsync()`.
- Custom popovers, menus, tooltips: use the `dropdown` trigger from `animations.ts` or CSS `transition-[opacity,transform] duration-200 ease-out` from `opacity-0 scale-95` to `opacity-100 scale-100`; exit `duration-150 ease-in`. Position with `overlayPositions()` / `dropdownConnectedPositions()` from `utils/overlay-position`; layer with `Z_SCALE`.
- Toasts: the service owns entry, stacking, swipe, and reduced motion — never animate toasts yourself.
- Reduced motion is handled globally (`prefers-reduced-motion` block in `index.scss` neutralises CSS animations and transitions). Do not add `motion-reduce:` utilities per element; JS-driven motion checks `window.matchMedia('(prefers-reduced-motion: reduce)')` like `ToastService`.
- Interactive feedback never exceeds `duration-300`. `duration-500` is only for ambient decoration (the empty-state hover glow).

## Delivery checklist

```
- [ ] Reused a Ply primitive for every modal, table, toast, empty, loading, and form-field need
- [ ] Only slate neutrals + --ply-primary accent; semantic colors carry meaning
- [ ] Control radius rounded-[var(--ply-radius)]; surfaces rounded-xl; rounded-full only on chips/avatars/pills
- [ ] Standard spacing scale; no [Npx]
- [ ] Light + dark pair on every color class; icons via stroke-*
- [ ] Hover, active, focus-visible (FOCUS_RING), and disabled on every interactive element; aria-label on icon-only buttons
- [ ] Buttons side by side share one size (`default`, `lg`, or `sm`) — never mix `lg` with default
- [ ] Loading, empty, error, and data branches render in the same container with matching dimensions
- [ ] Skeleton row count and sizes mirror the final content; ply-data-table uses [loading]
- [ ] Empty states have a title, one-sentence description, and a verb CTA
- [ ] Field errors are ply-error inside ply-input-group (aria wiring is automatic); region errors use ply-alert or ply-empty-state + Retry
- [ ] tabular-nums + text-end on every numeric column and KPI; truncate/min-w-0 or line-clamp-* on unbounded text
- [ ] Motion: transition-colors/transition-all 150–200ms feedback, ≤300ms entries, exits faster; modals via DialogService
- [ ] Heading/paragraph margins reset with m-0! where needed; logical RTL utilities for new layout
- [ ] Signals for any state written outside template events (zoneless)
```

## Additional resources

- Exact inputs, outputs, and class strings for every primitive referenced here: [reference.md](reference.md)
- Complete copy-paste templates (four-state data card, dashboard KPI grid, dialog form, data table with formatted columns): [examples.md](examples.md)
- Full component catalog with all APIs: `docs/ai/components.md`
