# Ply UI Design — primitive reference

Selectors, inputs, outputs, and class strings for components installed under `aliases.components` in `ply-ui.json`. Import each component from that folder. Inputs are signal inputs (`input()`); bind with `[name]="…"`. Inputs marked *attr* use `booleanAttribute`, so the bare attribute (`<ply-data-table loading>`) works.

## Shared constants — `utils/tw-merge.ts`

| Export | Value | Use |
|---|---|---|
| `cn(...inputs)` | `twMerge(clsx(inputs))` | merge a component's `class` input over its defaults |
| `FOCUS_RING` | `outline-none focus-visible:ring-2 focus-visible:ring-[var(--ply-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ply-background)]` | buttons, links, tiles |
| `FOCUS_RING_INSET` | `outline-none! focus-visible:ring-2! focus-visible:ring-inset! focus-visible:ring-[var(--ply-ring)]!` | compact controls (calendar cells, chips) |
| `FOCUS_RING_WITHIN` | `focus-within:ring-2! focus-within:ring-inset! focus-within:ring-[var(--ply-ring)]!` | input-group wrappers with addons |
| `PRIMARY_SOFT` | `bg-[var(--ply-primary-soft)] text-[var(--ply-primary)]` | selected / highlighted rows and options |

`utils/z-index.ts` — `Z_SCALE`: sticky `50`, overlay `1000`, popover `1100`, toast `1200`.
`utils/safe-timer.ts` — `injectTimers()` returns destroy-scoped `setTimeout` / `setInterval` / `clear` / `clearAll`.
`utils/overlay-position.ts` — `overlayPositions(placement, gap, dir)`, `dropdownConnectedPositions()`, `overlayDropUpMetrics()`, `tooltipAbsolutePosition()`.

## Buttons — `directives/button/`

`[ply-button]` (`BaseButtonDirective`), `[ply-stroked-button]` (`StrokedButtonDirective`), `[ply-icon-button]`, `[ply-icon-stroked-button]`, `[ply-link]`. Inputs: `class`, `color`, `size`, `width`.

Base (filled): `flex items-center gap-2 relative rounded-[var(--ply-radius)] text-center justify-center tracking-wide font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [&_ply-icon]:stroke-current [&_ply-icon]:fill-current` + `FOCUS_RING`. Stroked adds `border` and drops `font-medium`.

| `color` | filled `[ply-button]` | stroked `[ply-stroked-button]` |
|---|---|---|
| `primary` | `text-[var(--ply-primary-foreground)]! bg-[var(--ply-primary)] hover:bg-[var(--ply-primary-hover)] active:bg-[var(--ply-primary-active)]` | `text-[var(--ply-primary)]! border-[var(--ply-primary)] hover:bg-blue-50 hover:text-[var(--ply-primary-hover)]! active:bg-blue-100` |
| `default` | `text-[var(--ply-muted-foreground)]! bg-[var(--ply-muted)] hover:bg-slate-300 dark:hover:bg-slate-700 active:bg-slate-400 dark:active:bg-slate-600` | `text-[var(--ply-muted-foreground)]! border-[var(--ply-border)] hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-100 dark:active:bg-slate-900` |
| `danger` | `text-[var(--ply-destructive-foreground)]! bg-[var(--ply-destructive)] hover:bg-[var(--ply-destructive-hover)] active:bg-red-800` | `text-[var(--ply-destructive)]! border-[var(--ply-destructive)] hover:bg-red-50 active:bg-red-100` |
| `success` / `warning` / `accent` | `bg-green-500` / `bg-orange-500` / `bg-purple-500` (+ `-600` hover, `-700` active, `-300` disabled) | `text-*-500! border-*-500 hover:bg-*-50 active:bg-*-100` |
| `transparent` | `bg-transparent hover:bg-slate-200/50 dark:hover:bg-slate-700/50` | `border-transparent hover:bg-slate-100 dark:hover:bg-slate-800` |
| `black` / `dark` | `bg-slate-900 dark:bg-black hover:bg-slate-800` | `border-slate-900 dark:border-white hover:bg-slate-900/10 dark:hover:bg-white/10` |
| `white` | `text-slate-900! bg-white hover:bg-slate-50` | `text-white! border-white hover:bg-white/10` |

| `size` | classes |
|---|---|
| `sm` | `h-7 text-xs px-4 [&_ply-icon]:w-3 [&_ply-icon]:h-3` |
| `default` / `md` | `h-9 text-sm px-6 [&_ply-icon]:w-5 [&_ply-icon]:h-5` |
| `lg` | `h-10 text-base px-7 [&_ply-icon]:w-6 [&_ply-icon]:h-6` |
| `xl` | `h-11 text-base px-8 [&_ply-icon]:w-6 [&_ply-icon]:h-6` |
| `xxl` | `h-14 text-lg px-10 [&_ply-icon]:w-7 [&_ply-icon]:h-7` |

Overrides go through `class` (`class="rounded-full w-full"`); text colors use `!` so they win on `<a>`.

`[ply-icon-button]` — square, icon-only. Base as filled minus `gap-2 font-medium`; extra colors `secondary` (`bg-slate-500`), `inverted` (`text-white! bg-black/40 hover:bg-black/60 backdrop-blur-sm` — for buttons over images), `transparent` (`text-[var(--ply-muted-foreground)]! hover:bg-slate-200/50 dark:hover:bg-slate-700/50`). Always give it `aria-label`.

| icon `size` | classes |
|---|---|
| `sm` | `h-7 w-7 [&_ply-icon]:w-3 [&_ply-icon]:h-3` |
| `md` | `h-8 w-8 [&_ply-icon]:w-4 [&_ply-icon]:h-4` |
| `default` | `h-9 w-9 [&_ply-icon]:w-5 [&_ply-icon]:h-5` |
| `lg` / `xl` / `xxl` | `h-10 w-10` / `h-11 w-11` (`[&_ply-icon]:w-6`) / `h-14 w-14` (`[&_ply-icon]:w-7`) |

`[ply-link]` — `inline-flex items-center tracking-wide transition-colors duration-200 [&_ply-icon]:stroke-current`; `color` `default` / `primary` = `text-blue-500 hover:text-blue-700 active:text-blue-900`, `secondary` = `text-slate-500 hover:text-slate-700`, plus `success` / `danger` / `warning` / `accent` on the `-500 → -700 → -900` ramp; `size` `sm` `text-xs gap-2`, `default` / `md` `text-sm gap-3`, `lg` `text-base gap-4`, `xl` `text-lg gap-5`. Underline is not included — add `hover:underline` when a link sits in running text.

## Form fields — `components/input-group/`, `directives/input-group/`

| Selector | Classes / notes |
|---|---|
| `ply-input-group` | host `block w-full`; shell `flex items-center w-full min-h-9 border border-solid border-[var(--ply-border)] rounded-[var(--ply-radius)] relative bg-[var(--ply-background)] shadow-sm focus-within:border-[var(--ply-ring)]!` + `FOCUS_RING_WITHIN`, plus `border-red-500!` while `showErrors()`; projects `ply-label`, `[ply-input]`, `[ply-textarea]`, `ply-mention-input`, `datalist`, `[ply-addon-start]`, `[ply-addon-end]`, `ply-error`, `ply-info-text` |
| `[ply-input]` | `w-full h-9 px-4 text-sm text-slate-700 dark:text-slate-200 bg-transparent rounded-[var(--ply-radius)] transition-all duration-200 outline-none focus:ring-0 disabled:cursor-not-allowed disabled:text-slate-400 read-only:bg-slate-50 dark:read-only:bg-slate-700` |
| `ply-label` | `block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1`; `for` is linked to the projected control automatically |
| `ply-error` | `block text-xs text-red-500 mt-1`; rendered by the group only while `showErrors()` |
| `ply-info-text` | `block text-xs text-slate-500 dark:text-slate-400 mt-1`; always rendered |

`showErrors()` is `invalid` input **or** (`[formField]` state `invalid && touched`) **or** (`NgControl.touched && errors`). When true the group also sets `aria-invalid="true"` and `aria-describedby="<errorId>"` on the projected input / textarea and removes them when it clears. Consequences: never wrap `ply-error` in `@if`, never set `aria-invalid` yourself inside a group, and use one `ply-error` whose content switches on `hasError(...)`.

Composite CVA hosts (`ply-custom-select`, `ply-multi-select`, `ply-combobox`, `ply-tags-input`, `ply-phone-input`, `ply-currency-input`, `ply-password-input`, `ply-file-upload`, `ply-toggle`, `ply-select`) bind `[(ngModel)]` / `formControlName` / `[formField]` on the host and are placed as siblings of `ply-label`, never inside `ply-input-group`. `ply-select` is a native `<select>` wrapper (`size` `sm`–`xl`) and has no floating panel.

## Card — `components/card/`

| Part | Classes |
|---|---|
| `ply-card` | `block bg-white dark:bg-slate-800 overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700 not-prose`; input `horizontal` (*attr*). No shadow |
| `ply-card-header` | `flex h-14 font-semibold justify-between items-center border-b border-slate-300 dark:border-slate-700 dark:text-slate-300 dark:bg-slate-800 px-4 not-prose` — fixed height; pass `class="h-auto py-4"` for a title + subtitle stack, `class="h-auto flex-col items-start gap-1 py-4"` for stacked content |
| `ply-card-body` | `block p-4 relative overflow-y-auto overflow-x-hidden dark:!text-slate-300` — content cards pass `class="p-6"`, tables pass `class="p-0"` |
| `ply-card-footer` | `flex border-t border-slate-300 dark:border-slate-700 p-4 dark:text-slate-300` — action rows pass `class="justify-end gap-2"` |

All parts accept `class` merged with `cn()`. Note the header/footer dividers are `slate-300`, one step darker than the card's own `slate-200` border.

## Skeleton — `ply-skeleton`

| Input | Type | Default |
|---|---|---|
| `variant` | `'text' \| 'circular' \| 'rectangular' \| 'card' \| 'table-row'` | `'text'` |
| `count` | `number` | `1` |
| `width` / `height` | CSS string (`"100%"`, `"16rem"`, `"40px"`) | unset; circular defaults to 40px |
| `animated` | boolean *attr* | `true` |

Fill `bg-slate-200 dark:bg-slate-700` + `animate-pulse` (pulse only, no shimmer). Variant shapes: `text` → `h-3 rounded w-full`; `circular` → `rounded-full`; `rectangular` → `rounded-lg`; `card` → full card preset (`rounded-xl border border-slate-200 dark:border-slate-700 p-4`, avatar + 2 lines + 3 body lines); `table-row` → `flex items-center gap-4` (avatar, flexible bar, `w-6` bar). Items stack with `space-y-2` (`space-y-3` for cards). Host `block` + `class`.

## Empty state — `ply-empty-state`

Inputs `iconName`, `title`, `description` (all optional strings). CTA via default `<ng-content>`. No size input; wrap in a narrower container to shrink.

Root `relative overflow-hidden group rounded-3xl p-2 transition-all duration-500 hover:shadow-2xl hover:shadow-slate-500/10 border border-dashed border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900/50 backdrop-blur-xl`. Icon box `w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 text-slate-400 dark:text-slate-500` with `w-8 h-8 stroke-current` glyph. Title `<h3 class="text-2xl font-bold text-slate-900 dark:text-white">`; description `<p class="max-w-80 mx-auto text-sm text-slate-500 dark:text-slate-400 leading-relaxed">`.

## Loading overlay — `ply-loading-overlay`

| Input | Type | Default |
|---|---|---|
| `visible` | boolean *attr* | `false` |
| `fullscreen` | boolean *attr* | `false` — host becomes `contents`, children are not projected |
| `lockScroll` | boolean *attr* | `false` (auto when fullscreen) |
| `message` | string | `''` |
| `size` | `SpinnerSize` | `'lg'` |
| `color` | `SpinnerColor` | `'primary'` |

Host `relative block` + `aria-busy="true"` while visible. Overlay `absolute inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-slate-900/80` (fullscreen: `fixed inset-0 z-[10000]`), `role="status" aria-live="polite"`, message `text-sm font-medium text-slate-700 dark:text-slate-200`. No backdrop blur by design.

## Spinner — `ply-spinner`

`size`: `sm` `w-4 h-4 border` · `md`/`default` `w-6 h-6 border-2` · `lg` `w-10 h-10 border-4` · `xl` `w-12 h-12 border-[5px]`. `color`: `primary` `border-blue-500 border-t-blue-100 border-e-blue-100 border-b-blue-100`, `success`, `danger`, `warning`, `accent`, `inverted` (`border-white border-t-white/20 …`). Base `block rounded-full border-solid animate-spin`. No ARIA — add `role="status"` + `aria-label` on a wrapper.

## Progress button — `ply-progress-button`

| Input | Type | Default |
|---|---|---|
| `loading` | boolean (plain — bind `[loading]`) | `false` |
| `color` / `size` | `ButtonColor` / `ButtonSize` | `'primary'` / `'default'` |
| `type` | `'button' \| 'submit' \| 'reset'` | `'button'` |
| `disabled` | boolean | `false` |
| `width` | string | `''` |
| `spinnerColor` | `SpinnerColor` | derived from `color` |

Output `clicked` (suppressed while loading or disabled; `preventDefault` blocks submit). While loading: `aria-busy="true"`, `aria-disabled="true"`, `pointer-events-none`, label `opacity-0` under a centered inline spinner (16 / 20 / 22 / 24 / 32 px by size). Native `disabled` is not set during loading, so the button keeps full opacity.

## Alert — `ply-alert`

| Input | Type | Default |
|---|---|---|
| `color` | `'' \| 'primary' \| 'success' \| 'danger' \| 'accent' \| 'warning'` | `''` |
| `variant` | `'soft' \| 'solid' \| 'outline'` | `'soft'` |
| `icon` | icon name | none |
| `close` | boolean *attr* | `false` |
| `duration` | ms; `> 0` auto-dismisses | `0` |

Output `closed`. Host `role="alert"`. Slots: default body, `ply-alert-actions` (`min-w-full flex justify-end gap-2`). Shell `flex flex-col rounded-xl text-sm p-4 gap-3 border transition-all duration-200`. Danger soft: `bg-red-50 text-red-800 border-red-100 dark:bg-red-900/50 dark:text-red-200 dark:border-red-800/70`, icon `w-6 h-6 min-w-6 stroke-red-600 dark:stroke-red-400`; solid `bg-red-600 text-white border-red-700`; outline `bg-transparent text-red-600 border-red-300 dark:text-red-400 dark:border-red-800`. Close button `w-7 h-7 rounded-md -me-2 -mt-2 hover:bg-red-100 dark:hover:bg-red-900/40` with `aria-label="Close alert"`.

## Toast — `ToastService` (`components/toast/toast.service.ts`)

```ts
show(message, config?): number      success(message, config?)   // icon 'check'
error(message, config?)             // color 'danger', icon 'alert-triangle', role="alert"
warning(message, config?)           // color 'warning', icon 'alert-circle', role="alert"
info(message, config?)              // color 'primary', icon 'info-circle'
promise<T>(promiseOrFn, { loading, success, error }, config?): Promise<T>   // loading icon 'loader', sticky until settled
dismiss(id)   clearAll()
```

`ToastConfig`: `color?: ToastColor`, `duration?: number` (default `4000`; `0` = sticky), `icon?`, `position?: ToastPosition` (default `'top-end'`), `action?: { label, onClick, dismiss? }`. Card `pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-xl shadow-xl border max-w-sm w-full`; danger `bg-white text-slate-800 border-red-200 dark:bg-slate-800 dark:text-slate-200 dark:border-red-800`. Stack: 3 visible, `14px` peek, `0.05` scale step, spring `400ms cubic-bezier(0.22, 1.12, 0.36, 1)`, exit `280ms`. Reduced motion (`matchMedia`) switches to a static list with no tweens. The service owns `ply-toast-container` — never place it in a template. Host `aria-live="polite"`.

## Dialog — `DialogService` (`components/dialog/dialog.service.ts`)

```ts
open<TData, TResult>(type, data?, className?, options?: { hideOnBackdropClick?: boolean; containerType? }): Observable<TResult | undefined>
confirm({ title, description?, confirmLabel?, cancelLabel?, destructive? }): Observable<boolean>
```

Inside the opened component: `inject(DialogContext<TData, TResult>)` → `context.data`, `context.close(result?)`. Parts: `ply-dialog [width]="480"` (number → px, or any CSS width) `[height]`, `ply-dialog-header` (`flex justify-between items-center p-4 text-lg font-bold border-b border-slate-200 dark:border-slate-800`), `ply-dialog-body` (`p-4 text-sm overflow-y-auto max-h-[calc(100vh-160px)]`), `ply-dialog-footer` (`flex justify-end gap-4 p-4 border-t`), `[ply-dialog-close]` on any button.

Container: backdrop `absolute inset-0 bg-slate-900/50 dark:bg-slate-900/80 backdrop-blur-sm` (fade 230ms); box `relative bg-white dark:bg-slate-800 rounded-xl shadow overflow-hidden w-fit max-w-[calc(100%-32px)] max-h-[calc(100%-32px)] flex flex-col`, `role="dialog" aria-modal="true"`, enter `scale(0.5) → 1.05 (200ms ease-out) → 1 (100ms)`, leave mirrored with `ease-in`. CDK focus trap, focus restore, Escape, `aria-labelledby` from the first `h1–h3` / `ply-dialog-header`. SSR: `open()` returns `of(undefined)`, `confirm()` `of(false)`. `confirm()` renders `ply-alert-dialog` at width 400 with `role="alertdialog"`.

Quirks: `className` is stored but not bound (no effect). `hideOnBackdropClick: false` also disables Escape (no `context` on the container) — provide an explicit close button.

## Drawer and bottom sheet

`ply-drawer` — inputs `size: 'sm' | 'md' | 'lg' | 'xl'` (`w-[200px]` / `w-[400px]` / `w-[40vw]` / `w-[56vw]`; top/bottom `h-40` / `h-[280px]` / `h-[400px]` / `h-[560px]`), `position: DrawerPosition` (default `'end'`), `close` (shows X), `drawerLabel`; output `closed`. Trigger: `[ply-drawer]` directive on a button. Panel `bg-white dark:bg-slate-900 p-4 fixed … max-w-[92vw] shadow-lg`, `role="dialog" aria-modal="true"`, `cdkTrapFocus cdkTrapFocusAutoCapture`. Backdrop on the overlay: `bg-slate-300/50 dark:bg-slate-800/80 backdrop-blur-[8px]`. Slide `.3s` via `animations.ts`. Requires `provideAnimationsAsync()`.

`ply-bottom-sheet` — `open` (model), `height: 'auto' | 'half' | 'full' | string` (`max-h-[85vh]` / `h-[50vh]` / `h-[94vh]`), `showHandle` (default true), `showClose`, `sheetLabel`, `dismissible` (default true); output `closed`. Backdrop `fixed inset-0 z-1000 bg-slate-900/50 dark:bg-slate-900/80 backdrop-blur-sm` (`[@fade]` 200ms ease-out / 150ms ease-in). Panel `fixed bottom-0 start-0 end-0 z-1001 mx-auto w-full sm:max-w-140 bg-white dark:bg-slate-900 rounded-t-2xl shadow-2xl`; handle `w-10 h-1 rounded-full bg-slate-300 dark:bg-slate-600`; swipe-down dismiss.

## Data table — `ply-data-table<T>`

| Input | Type | Default |
|---|---|---|
| `columns` | `TableColumn[]` (`key`, `label`, `sortable?`, `width?`, `minWidth?`, `resizable?`, `filterable?`, `align?: 'left' \| 'center' \| 'right'`) | `[]` |
| `data` | `T[]` | `[]` |
| `loading` | boolean *attr* — renders `min(pageSize, 5)` pulse rows | `false` |
| `emptyMessage` | string (default i18n "No data available") | unset |
| `sortable`, `filterable`, `pageable`, `striped`, `bordered`, `hoverable`, `selectable`, `resizable`, `serverSide`, `virtualize` | boolean *attr* | `false` (`hoverable` `true`) |
| `pageSize` / `currentPage` (model) / `totalItems` | number | `10` / `1` / `0` |
| `rowKey` | string | `'id'` |
| `selected` (model) | `(string \| number)[]` | `[]` |
| `columnFilters` (model) | `Record<string, string>` | `{}` |
| `rowHeight` / `viewportHeight` | px (virtualize) | `44` / `400` |

Outputs: `rowClick`, `sortChange`, `filterChange`, `pageChange`, `selectionChange`. Templates: `<ng-template plyTableCell="key" let-row let-value="value">`, `<ng-template plyTableEmpty>`. No `error` input — render `ply-alert` above the table or swap the table for an error state.

Classes: wrapper `overflow-x-auto rounded-xl`; header `border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50`, head cell `py-3 px-4 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400`; body cell `py-3 px-4 text-sm text-slate-700 dark:text-slate-300` + `text-start` / `text-center` / `text-end`; row `transition-colors duration-150 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/30`, striped `bg-slate-50/50 dark:bg-slate-800/20`, selected `bg-blue-50 dark:bg-blue-950/30`; empty row `py-8 px-4 text-center text-sm text-slate-500 dark:text-slate-400`; skeleton bars `h-3 bg-slate-200 dark:bg-slate-700 rounded animate-pulse`. Header is not sticky; cells do not truncate or use `tabular-nums` unless your `plyTableCell` template adds them.

Primitive table: `table[ply-table]` `w-full caption-bottom text-sm`; `thead[ply-table-header]`; `tr[ply-table-row]` `border-b border-[var(--ply-border)] transition-colors hover:bg-[var(--ply-muted)]/50`; `th[ply-table-head]` `h-10 px-4 text-start align-middle text-xs font-medium text-[var(--ply-muted-foreground)]`; `td[ply-table-cell]` `p-4 align-middle`.

## Numbers

- `ply-stat-card` — `variant: 'classic' | 'metric'`, `value`, `label`, `trend?`, `trendUp?`, `icon?`, `color: StatCardColor`, `caption?`, `delta?: number`, `series?: readonly number[]`. Metric label `text-[11px] font-bold uppercase tracking-[0.18em] text-slate-400`; value `truncate text-[30px] font-black leading-none tracking-tight text-slate-900 tabular-nums dark:text-white`; delta pill `rounded-full border px-2 py-0.5 text-[11px] font-semibold tabular-nums`. Classic tile `rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-800`, value `text-2xl font-bold` (no `tabular-nums` — add via `class` if needed).
- `ply-animated-counter` — `value`, `from`, `duration` (1500), `decimals`, `prefix`, `suffix`, `separator`; renders `<span class="tabular-nums">`; jumps to the final value under SSR.
- `ply-countdown` — `targetDate` (required); digits `text-3xl font-bold tabular-nums`, labels `text-xs font-medium text-slate-400 uppercase tracking-wider`.
- `ply-progress` — `role="progressbar"` with `aria-valuenow`; track `rounded-full bg-slate-200 dark:bg-slate-700`, fill `transition-all duration-300`, value bubble `text-xs font-semibold tabular-nums`.
- `ply-meter-group` — `role="meter"`; legend values `tabular-nums text-slate-500 dark:text-slate-400`.

## Avatar, badge, chip

- `ply-avatar` — `avatarUrl`, `initials`, `shape: 'circle' | 'square'` (default square), `size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full'` (`w-6` … `w-14`), `status: 'active' | 'inactive'`. Fallback order: image → initials (`uppercase font-medium text-slate-700 dark:text-slate-200`) → `user` icon. `role="img"` with `aria-label`.
- `ply-badge` — base `h-6 flex gap-1 border border-transparent justify-center items-center px-3 text-xs! whitespace-nowrap text-white bg-slate-400`; `shape="rectangular"` → `rounded-md`, every other shape → `rounded-full`. Solid colors use 600/700 shades so white text passes AA: `primary` `bg-blue-600!`, `danger` `bg-red-600!`, `success` `bg-green-700!`, `accent` `bg-purple-600!`, `warning` `bg-orange-700!`. Soft colors: `default` `bg-slate-100! border-slate-300! text-slate-700!`, `transparent` `bg-transparent! border-slate-300! text-slate-700!`, `slate-light` `bg-slate-200! border-slate-500! text-slate-800!`, `*-light` (`primary`, `danger`, `success`, `accent`, `warning`) `bg-*-100! border-*-400! text-*-800!`. Sizes: `sm` `h-5! text-xs! px-2!`, `md` / `default` `h-6! text-sm!`, `lg` `h-7! text-base! px-4!`, `xl` `h-8! text-xl! px-4!`. Status badges in tables use `size="sm" shape="rounded"` and a `*-light` color; count badges use solid.
- `ply-chip` — `color: '' | 'primary' | 'success' | 'danger' | 'warning' | 'accent'`, `size: 'sm' | 'md' | 'default' | 'lg'`; `sm` is `text-xs px-2 py-0.5 rounded-full`. Chips and `ply-tags-input` wrap; they never truncate.

## Selection controls — `ply-checkbox`, `ply-toggle`, `ply-radio-group` / `ply-radio-button`

| Control | Box / track | Checked | Notes |
|---|---|---|---|
| `ply-checkbox` | `w-4 h-4 appearance-none bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-600 hover:border-slate-400 rounded me-2 mt-1 peer` | `checked:bg-blue-500 checked:border-blue-500 checked:hover:border-blue-600` (per `color`: blue / red / green / purple / orange `-500`) | `indeterminate` input mirrors checked colors; `[ariaLabel]` for label-less boxes; label is projected content |
| `ply-toggle` | container `h-5 w-9` (`sm`) / `h-6 w-11` (`md`) / `h-7 w-[52px]` (`lg`); track `bg-slate-200 dark:bg-slate-700 border border-slate-300 dark:border-slate-700 transition-all duration-300`, shape `smooth` `rounded-lg` / `rounded` `rounded-full` / `square` `rounded-md` | `peer-checked:bg-blue-500 peer-checked:border-blue-500` (per `color`) | thumb `bg-white start-0.5 top-0.5 transition-all duration-300`, `h-4` / `h-5` / `h-6`, slides `translate-x-4` / `-5` / `-6` with RTL mirror; `[ariaLabel]` for standalone switches |
| `ply-radio-button` | `w-4 h-4 appearance-none bg-slate-50 dark:bg-slate-600 border border-slate-300 dark:border-slate-700 rounded-full me-2 peer` + its own `focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2` | `checked:bg-blue-500! checked:border-blue-500!` (per `color`; `warning` uses `orange-400`) | inputs `value`, `label`, `name`, `color`, `disabled`; place inside `ply-radio-group` which owns the value |

All three are CVAs (`ngModel`, `formControlName`, `[formField]`) with `disabled` input + forms-disabled merging. Selection controls are the one place the library uses raw `blue-500` instead of `--ply-primary`; match that when hand-styling a custom control next to them.

## Overlays — dropdown, popover, tooltip

| Primitive | Usage | Panel classes |
|---|---|---|
| `ply-dropdown-menu` + `[ply-dropdown-menu-trigger]="menu"` | `<ply-dropdown-menu #menu size="200px"><ply-dropdown-menu-item>…</ply-dropdown-menu-item></ply-dropdown-menu>`; `placement` `start` / `end` / `left` / `right` / `top-start` / `top-end` on the trigger; `(closed)` output | `bg-white dark:bg-slate-800 rounded-md shadow-xl border border-slate-200 dark:border-slate-700 overflow-hidden min-w-40 outline-none`; enters with the `dropdown` animation (`.3s .1s` from `translateY(15px)`) |
| `ply-dropdown-menu-item` | `[(checked)]` turns it into `menuitemcheckbox` and keeps the menu open; `stayOpen` attr; `class` merges | `w-full px-6 h-12 flex items-center text-sm transition-colors duration-200 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-300 whitespace-nowrap not-prose outline-none focus-visible:bg-slate-100 dark:focus-visible:bg-slate-700`; keyboard: arrows, Home / End, typeahead, Escape, submenu via `aria-haspopup="menu"` |
| `ply-popover` | `<ply-popover placement="bottom-start" minWidth="280px"><button popover-trigger ply-button>Open</button><div>panel</div></ply-popover>`; or an external `[ply-popover-trigger]="pop"`; `PopoverPlacement` = `top` / `bottom` / `left` / `right` / `*-start` / `*-end` | CDK overlay with transparent backdrop, `8px` gap, `reposition` scroll strategy; panel `rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4 shadow-2xl`; focuses first focusable element on open, restores focus on close, Escape closes |
| `[ply-tooltip]="text"` | `tooltipPlacement` (`top` default / `bottom` / `left` / `right` — never bare `placement`, it collides with dropdown / drawer), `type="dark"` (default) or `"light"`, `delay`, `tooltipClass`, `canShow` | `text-sm rounded-md text-center px-2 py-1 max-w-xs pointer-events-none transition-all duration-300 shadow-lg block relative` + `bg-slate-900 text-white` (dark) or `bg-white text-slate-700 border border-slate-200` (light), 4px arrow; slides in from `translate-y-2` / `-translate-x-2`; sets `aria-describedby` on the host while visible; hides on leave, blur, Escape, scroll. Text only — no interactive content |

Layering: overlays are CDK overlays (viewport-attached), so they escape `overflow: hidden` parents. Custom floating panels use `Z_SCALE` from `utils/z-index.ts` and `overlayPositions()` / `dropdownConnectedPositions()` / `tooltipAbsolutePosition()` from `utils/overlay-position.ts`.

## Tabs — `ply-tabs`

`<ply-tabs type="underline" [defaultTab]="0" (tabChanged)="…">` with `<ply-tab label="…">` (or `<ply-tab-label>` for icon + text) and `<ply-tab-body>`. Inputs `type` (`underline` / `pills` / `folder` / unset), `position` (`left` / `center` / `right` / `full-width`), `ariaLabel`, `labelledBy`, `scrollRestricted`. Roving-tabindex tablist with arrow / Home / End keys and scroll arrows on overflow.

| Piece | Classes |
|---|---|
| tab (all types) | `px-8 h-10 flex items-center text-sm bg-transparent whitespace-nowrap dark:text-slate-400 border-0` + `FOCUS_RING` |
| `underline` list / active | list `border-b border-slate-300 dark:border-slate-700`; active `text-[var(--ply-primary)]! shadow-[0_1px_0_var(--ply-primary)]`; inactive hover `hover:shadow-tab` |
| `pills` | `bg-slate-100 dark:bg-slate-600 dark:text-slate-200 rounded-md! me-2 transition-color duration-300`; active `text-[var(--ply-primary-foreground)]! bg-[var(--ply-primary)]!` |
| `folder` list / active | list `bg-slate-100 dark:bg-slate-900 rounded-t-lg pt-1 px-1`; active `bg-white dark:bg-slate-800 text-[var(--ply-primary)] rounded-t-md` |

Use `underline` for page sections, `pills` for view switches inside a card, `folder` for editor-like surfaces. Tab panels are `role="tabpanel" tabindex="0"`.

## Divider, kbd, code, typography

- `ply-divider` — host `block`; renders `w-full h-px bg-slate-300 dark:bg-slate-600` (or `w-px h-full` with `vertical`). One step darker than card borders; for a same-tone rule inside a card use `border-t border-slate-200 dark:border-slate-700` instead.
- `ply-kbd` — `inline-flex h-6 min-w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 px-1.5 font-mono text-xs font-medium text-slate-700 shadow-[0_1px_0_0_rgb(226_232_240)] dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200`. Use for shortcuts in menus, empty states, and command palettes.
- `ply-code` — `language` (`HTML`, `TypeScript`, `JavaScript`, `Bash`), `[(showCode)]`; host `block not-prose`; ships its own copy button (`ply-icon-button` + tooltip + toast). Inline code in prose: `<code class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.85em] text-slate-800 dark:bg-slate-800 dark:text-slate-200">`.
- `[plyTypography]` / `[plyHeadingText]` (`directives/typography/ply-heading.directive.ts`) — base `text-slate-900 dark:text-white` + variant: `display` `text-4xl font-bold tracking-tight sm:text-5xl`, `title` `text-3xl font-bold tracking-tight`, `heading` (default, also `plyHeadingText`) `text-2xl font-semibold tracking-tight`, `subheading` `text-lg font-medium`, `body` `text-base font-normal 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`. `class` merges through `cn()`, so `class="text-xl"` overrides the size.

## Type unions

| Type | Values |
|---|---|
| `ButtonColor` / `StrokedButtonColor` | `primary` `success` `danger` `warning` `accent` `white` `black` `default` `secondary` `dark` `slate` `transparent` |
| `ButtonSize` / `StrokedButtonSize` / `IconButtonSize` / `GroupButtonSize` | `sm` `default` `md` `lg` `xl` `xxl` |
| `IconButtonColor` | as `ButtonColor` minus `dark` `slate`, plus `inverted` |
| `LinkColor` / `LinkSize` | `primary` `success` `danger` `warning` `accent` `default` `secondary` / `sm` `md` `default` `lg` `xl` |
| `BadgeColor` | `primary` `danger` `success` `accent` `warning` `default` `transparent` `slate-light` `primary-light` `danger-light` `success-light` `accent-light` `warning-light` |
| `BadgeSize` / `BadgeShape` | `sm` `md` `default` `lg` `xl` / `rectangular` `circle` `rounded` `pill` |
| `AlertColor` / `AlertVariant` | `''` `primary` `success` `danger` `accent` `warning` / `soft` `solid` `outline` |
| `ToastColor` / `ToastPosition` | `primary` `success` `danger` `warning` `accent` / `top-*` `bottom-*` × `left` `right` `center` `start` `end` |
| `CheckboxColor` / `RadioColor` / `ToggleColor` / `SliderColor` / `ProgressColor` / `ChartColor` | `primary` `danger` `success` `accent` `warning` |
| `ToggleSize` / `ToggleShape` | `sm` `md` `default` `lg` / `rounded` `square` `smooth` |
| `AvatarSize` / `AvatarShape` / `AvatarStatus` | `xs` `sm` `md` `lg` `xl` `full` / `circle` `square` / `active` `inactive` |
| `ChipColor` / `ChipSize` | `''` `primary` `success` `danger` `warning` `accent` / `sm` `md` `default` `lg` |
| `SpinnerColor` / `SpinnerSize` | `primary` `success` `danger` `warning` `accent` `inverted` / `sm` `md` `default` `lg` `xl` |
| `SkeletonVariant` | `text` `circular` `rectangular` `card` `table-row` |
| `StatCardColor` / `StatCardVariant` / `TimelineColor` | `primary` `success` `danger` `warning` `accent` `default` / `classic` `metric` / as `StatCardColor` |
| `DropdownPlacement` / `PopoverPlacement` / `TooltipPlacement` | `start` `end` `left` `right` `top-start` `top-end` / `top` `bottom` `left` `right` `top-start` `top-end` `bottom-start` `bottom-end` / `top` `bottom` `left` `right` |
| `DrawerPosition` / `DrawerSize` / `BottomSheetHeight` | `left` `right` `top` `bottom` `start` `end` / `sm` `md` `lg` `xl` or CSS length / `auto` `half` `full` or CSS length |
| `TableColumn` | `{ key, label, sortable?, width?, minWidth?, resizable?, filterable?, align?: 'left' \| 'center' \| 'right' }`; `TableSortDirection` `asc` `desc` `''` |
| `ComboboxOption` / `ToastAction` / `MeterGroupItem` / `ChartDataPoint` | `{ value, label, disabled?, description? }` / `{ label, onClick, dismiss? }` / `{ label, value, color?, icon? }` / `{ label, value, color? }` |

`start` / `end` variants follow `dir` (RTL-aware); `left` / `right` are physical. Prefer `start` / `end` in shared components.

## Statistical profile of the library (for judgement calls)

- Neutrals are slate only (~5.5k `slate-*` utilities, zero `zinc` / `gray` / `neutral`). Brand blue is `--ply-primary` on interactive surfaces, raw `blue-500` on selection controls and links, `blue-600` on solid badges.
- Spacing uses the standard scale (`p-4`, `gap-2`). Do not introduce `[Npx]` for padding, margin, or gap. A fixed control size that is not on the scale may stay a pixel value.
- Radius: controls `rounded-[var(--ply-radius)]` (0.5rem default), surfaces `rounded-xl`, menus / tooltips / kbd `rounded-md`, checkbox `rounded`, radio / chips / avatars `rounded-full`.
- Motion: `duration-200` dominates (`transition-all` on controls, `transition-colors` on rows and links); `duration-300` for toggles, tooltips, tab pills, progress; `duration-150` only for row hovers and exit transitions. No `motion-reduce:` utilities — honor `prefers-reduced-motion` in the global stylesheet.
- Text: `text-sm` is the default UI size; `text-xs` for meta, errors, hints, badges; `text-base` only for `body` prose and `lg` controls. `tabular-nums` already ships in stat-card, progress, countdown, animated-counter, meter-group, time-picker. `tracking-widest` is unused — the widest tracking in the library is `tracking-wider` (`overline`, table heads) and `tracking-[2px]` on form-block labels.

## Motion inventory

| Where | Values |
|---|---|
| `ply-button`, `[ply-input]`, `ply-alert` | `transition-all duration-200` |
| table rows, list rows | `transition-colors duration-150` |
| `animations.ts` triggers | `dropdown` (enter `.3s .1s` from `translateY(15px)`), `openClose` (height `.3s`), `rotate180`, `tabAnimation`, `slideLeft/Right/Top/Bottom` (`.3s`) |
| Dialog | box 200ms + 100ms `ease-out` in, 100ms + 200ms `ease-in` out; backdrop 230ms |
| Bottom sheet | backdrop 200ms `ease-out` / 150ms `ease-in`; panel 300ms |
| Toast | spring `400ms cubic-bezier(0.22, 1.12, 0.36, 1)`, exit 280ms |
| `ply-progress` fill | `duration-300`; tooltip bubble `scale-95 → scale-100 duration-150 ease-out` |
| Global | `@media (prefers-reduced-motion: reduce)` in the global stylesheet sets animation and transition durations to `0.01ms` |

## Icon names

The outline sprite (`icons.svg`) has 392 `<symbol>` tags and 389 unique names (`user`, `share`, and `shuffle` each appear twice). The filled sprite (`icons-filled.svg`) has `star` and `heart` only.

State pairings the library already uses: empty `inbox` / `search` / `folder`; error `alert-triangle`; warning `alert-circle`; success `check`; info `info-circle`; loading `loader`; close `close`; avatar fallback `user`; trend `trend-up` / `trend-down`, `arrow-up` / `arrow-down`.

accessibility, activity, airplay, alarm-clock, alert-circle, alert-octagon, alert-triangle, align-center, align-justify, align-left, align-right, app-window, archive, area-chart, arrow-down, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-in, arrow-in-circle, arrow-in-square, arrow-left, arrow-left-circle, arrow-out, arrow-out-circle, arrow-out-square, arrow-right, arrow-right-circle, arrow-up, arrow-up-circle, arrow-up-left, arrow-up-right, at-sign, attach, award, aws, backward, backward-step, badge-check, bar-chart, bar-chart-2, bar-chart-3, bar-chart-4, barcode, battery, battery-charging, bell, bell-off, blocks, blog, bluetooth, bold, bolt, book, book-open, bookmark, bot, box, boxes, brain, briefcase, bug, building, cable, calculator, calendar, calendar-check, calendar-plus, calendar-x, camera, camera-off, car, cart, cast, check, check-circle, check-square, checkbox, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, circle-dashed, clipboard, clipboard-check, clipboard-list, clock, close, cloud, code, command, compas, component, contact, cookie, copy, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, cpu, credit-card, crop, cut, dark, database, diamond, disc, doc, dollar-sign, download, download-cloud, edit, edit-box, edit-line, elements, elements-ui, email, eraser, expand, export, external-link, eye, eye-not, facebook, figma, file, file-check, file-code, file-minus, file-minus-2, file-plus, file-plus-2, file-search, file-text, file-x, file-x-2, file-zip, film, filter, fingerprint, flag, flip-horizontal, flip-vertical, folder, folder-minus, folder-minus-2, folder-open, folder-plus, folder-plus-2, folder-search, folder-x, folder-x-2, forbidden, forward, forward-step, gamepad, gauge, gift, git-branch, git-commit, git-merge, git-pull-request, github, glob, grid, grid-ui, group, handshake, hard-drive, hash, heading, headphones, heart, heart-off, help-circle, hexagon, highlighter, history, home, home-2, hourglass, id-card, images, img, import, inbox, indent, info, info-circle, instagram, italic, key, keyboard, languages, laptop, layers, layout, layout-2, layout-3, layout-4, layout-5, layout-6, layout-columns, layout-grid, layout-rows, layout-sidebar, layout-topbar, library, life-buoy, light, line-chart, line-chart-2, link, linkedin, list, list-checks, list-ordered, list-todo, loader, location, lock, mail-open, map, map-marker-2, map-marker-3, maximize, megaphone, menu, message, mic, mic-off, minimize, minus, minus-circle, minus-square, monitor, more, more-horisontal, mouse, move, music, network, notebook, octagon, outdent, package, paintbrush, palette, panel-bottom, panel-left, panel-right, panel-top, paste, pause, pause-circle, pentagon, percent, phone, phone-call, phone-off, pie-chart, pie-chart-2, pie-chart-3, pie-chart-4, pie-chart-5, pin, plane, play, play-circle, plug, plus, plus-circle, plus-square, power, printer, puzzle, qr-code, quote, radar, radio, receipt, refresh, repeat, replace, reply, rocket, rotate-left, rotate-right, rss, save, scale, scan, scan-face, scan-line, scissors, search, search-minus, search-plus, send, server, settings, share, shield, shield-alert, shield-check, shopping-bag, shuffle, signal, slack, sliders, smile, sparkles, speaker, split, square, square-dashed, star, sticky-note, stop, stop-circle, store, strikethrough, table, table-2, tablet, tag, target, templates, templates-ui, terminal, thumb-down, thumb-up, ticket, timer, toggle-left, toggle-right, translate, trash, trend-down, trend-up, triangle, trophy, truck, tv, type, underline, ungroup, unlink, unlock, unplug, upload, upload-cloud, usb, user, user-check, user-circle, user-cog, user-minus, user-plus, user-x, users, video, video-off, volume, volume-1, volume-x, wallet, wand, warehouse, watch, wifi, wifi-off, workflow, wrap-text, x, x-circle, x-square, x-twitter, zap
