---
name: scaffold-ui-primitives
description: Scaffold theme-compliant UI primitives into the client project (EntityLookup combobox, DateInput inline calendar, EnumSelect + MultiSelect dropdowns, SegmentedControl, Textarea, Switch, TruncatedText + the owned base DataTable and its ResponsiveDataTable wrapper for responsive list tables with truncation tooltips, TabStrip scrollable tab bar with arrow nudges, SectionCard titled category card with the read-first per-section edit toggle, plus the modern kit Skeleton, EmptyState, Badge, StatCard, and the URL list-state layer useListState + SavedViewsMenu). Idempotent, honors @customised marker.
group: D
phase: devFrontend
kind: main
section_label: 'SCAFFOLD-UI-PRIMITIVES (EntityLookup, DateInput, EnumSelect, MultiSelect, SegmentedControl, Textarea, Switch, TruncatedText, DataTable, ResponsiveDataTable, TabStrip, SectionCard, Skeleton, EmptyState, Badge, StatCard, useListState, SavedViewsMenu in src/components/ui/)'
allowed-tools: [Read, Write, Edit, Glob, Grep]
---

# scaffold-ui-primitives — Theme-compliant primitives scaffolder

## Context

You are scaffolding **shared UI primitives** into a SmartStack-generated client
app. The primitives are the theme-compliant form controls `scaffold-component`
imports for a Form/Detail page: `EntityLookup` (FK combobox), `DateInput`
(inline calendar — replaces the native `<input type="date">`), `EnumSelect`
(single-select dropdown for higher-cardinality enums), `MultiSelect` (multi-value
chips), `SegmentedControl` (1-click inline radio group for low-cardinality enums),
`Textarea` (auto-growing long text) and `Switch` (boolean toggle). They are all
self-styled with theme tokens — no native browser-chrome widgets — so the modern
form `scaffold-component` emits is low-click by default (type a date, tap a
segment, flip a switch).

These primitives live **locally** in `src/components/ui/` (not in
`@atlashub/smartstack`) for three reasons:

1. **Theme compliance** — they must use SmartStack tokens (`var(--bg-card)`,
   `var(--color-accent-*)`, …) so a theme swap from the admin re-paints them
   instantly. Hardcoded Tailwind colors (`bg-blue-500`, `dark:bg-gray-700`)
   are **forbidden** and the test snapshot enforces it.
2. **No upstream churn** — the `EntityLookup` paradox (component documented
   in CLI but absent from `@atlashub/smartstack`) was the trigger for this
   skill. Scaffold-component now imports `@/components/ui/EntityLookup` and
   this skill is what makes that import resolve.
3. **Local override surface** — clients can replace the file (or mark it
   `// @customised`) without forking the SDK.

Without this skill, `scaffold-component` emits `<EntityLookup>` JSX → TypeScript
fails to resolve the import → audit `DEV-UI-022` fires BLOCKING. So the
orchestrator (`/ba-develop` Phase 3.0) MUST invoke `scaffold-ui-primitives`
before any feature page is scaffolded.

## Invocation

```
npx --prefer-offline tsx skills/development/frontend/ui-primitives/cli/scaffold-ui-primitives/index.ts \
  --spec '{"projectPath":"<abs>","appCode":"<app>"}'
```

`--spec` JSON:

| Field | Type | Required | Notes |
|---|---|---|---|
| `projectPath` | string | yes | Absolute path to the web root (holds `src/`). |
| `appCode` | string | yes | Reserved for future per-app branding hooks. |
| `force` | boolean | no | Overwrite even when content matches. Default false. |

## What it produces

- `src/components/ui/EntityLookup.tsx` — debounced + paginated FK combobox with
  keyboard nav, clear button, loading/empty/error states.
- `src/components/ui/DateInput.tsx` — theme date picker (React-rendered, not the
  native `<input type="date">`); ISO `YYYY-MM-DD` value; locale-aware via
  `Intl.DateTimeFormat`. Three low-click paths to a date: **type** it (tolerant
  `dd/mm/yyyy`, also `-`/`.`/no separator — 0 clicks), a quick **chip** (Today /
  Tomorrow / +1 week / +1 month — 1 click), or a **day** in the calendar (1 click).
  `inline` prop renders the calendar in-flow (forms use it — no popover to open);
  the default popover (open-on-focus) stays compact for filter bars. No leading
  icon, so the typed text aligns with every other field.
- `src/components/ui/EnumSelect.tsx` — single-select dropdown for enum / fixed-
  list fields (`options: {value,label}[]`); ARIA listbox; keyboard nav; clearable.
  Used for higher-cardinality enums (scaffold-component routes ≤4-option enums to
  `SegmentedControl` instead).
- `src/components/ui/MultiSelect.tsx` — multi-select with removable chips
  (`value: string[]`).
- `src/components/ui/SegmentedControl.tsx` — inline ARIA radiogroup over
  `options: {value,label}[]`; every choice visible, so picking one is a single
  click (vs. a 2-click `<select>`). Drop-in for the same `{ options, value }`
  shape as `EnumSelect`.
- `src/components/ui/Textarea.tsx` — auto-growing multi-line text input with a
  live character counter (`value: string | null`); replaces the single-line
  `<input>` for long-text / `control: "textarea"` fields.
- `src/components/ui/Switch.tsx` — ARIA on/off toggle (`value: boolean`); replaces
  the bare `<input type="checkbox">` for booleans — one click to flip.
  All of the above style exclusively via SmartStack tokens (they read
  `--radius-input`, so the project's square/rounded choice — scaffold-theme
  `radiusControl` — applies); zero Tailwind color classes; zero `dark:` prefix
  (theme swap handles dark mode at the token layer).
- `EntityLookup` also accepts a `valueLabel?: string` — shown as the display
  fallback for the current value before the lookup resolves (or if it can't),
  so the raw Guid never flashes in the field. `scaffold-component` feeds it from
  the signed-in user for a user-FK (paired with a one-click "Me" button).
- `src/components/ui/TruncatedText.tsx` — clips overflowing cell content with an
  ellipsis and shows the full value in a tooltip on hover/focus (reuses the package
  `Tooltip`, gated so the popup only appears when actually truncated —
  `scrollWidth > clientWidth`). Pure layout (no colors, no i18n).
- `src/components/ui/DataTable.tsx` — the **owned base data table** (the
  customisation-ui baseline): client + server-controlled sorting, pagination,
  global search, row selection, sticky header + pinned columns (the `sticky`
  column prop), responsive columns, skeleton loading rows and an EmptyState
  empty row.
  Styled exclusively through the `--table-*` tokens `scaffold-theme` emits, so it
  inherits the project theme + dark mode. `ResponsiveDataTable` and every scaffolded
  `*ListPage` import it — it MUST be emitted here or those imports dangle and every
  table breaks (a `--table-*`-less minimal shim with a dead `sortable` flag was the
  pre-fix symptom). The contract test guards against dropping it.
- `src/components/ui/ResponsiveDataTable.tsx` — wrapper around the local
  `@/components/ui/DataTable` that (1) shows/hides columns by viewport breakpoint
  (`minBreakpoint?: 'sm'|'md'|'lg'|'xl'` per column; columns with no `minBreakpoint`
  — code, label, actions — are ALWAYS visible, extra columns appear as the screen
  widens) and (2) clips `truncate` columns via `TruncatedText`. The column drop is
  done in JS (`window.innerWidth`) before reaching `<DataTable>`, so it works on any
  DataTable version. `scaffold-component` emits list pages against this wrapper.
- `src/components/ui/TabStrip.tsx` — the detail-page tab-bar container (the
  two-layer 'underlined tabs' structure of audit DEV-UI-027 lives INSIDE it:
  outer divider + inner `-mb-px` row). On overflow the row scrolls horizontally
  with the native scrollbar hidden; ChevronLeft/ChevronRight nudge buttons
  appear only on the side(s) that can still scroll (never a scrollbar under the
  tabs). `activeKey` scrolls the selected trigger into view (deep-linked
  `?tab=`). Children are the `<button role="tab">` triggers `scaffold-component`
  emits inline — their PermissionGuard wrapping, i18n labels and `id="tab-*"`
  contract stay in the page.
- `src/components/ui/SectionCard.tsx` — the titled CATEGORY CARD every
  sectioned form/detail body is made of (« Identité », « Contrat »…). Owns the
  card chrome (tokens only) and the read-first edit affordance: `editable`
  renders a per-section "Modifier"/"Terminer" toggle in the header, `editing`
  draws the accent border + halo. Fully controlled and i18n-agnostic — the
  page owns the editing state and resolves every label
  (`t('<entity>.form.editSection')` / `doneSection`); the toggle carries the
  driver testid contract `section-edit-<key>` / `section-done-<key>`.
  `scaffold-component` mounts it around form sections (create + read-first
  edit) and sectioned detail bodies.
- `src/components/ui/HeaderActionsMenu.tsx` — the **Priority+ page-header
  overflow menu** (« ⋯ Plus d'actions »). `scaffold-component` renders at most
  2 promoted header actions as visible `hidden lg:inline-flex` buttons and
  passes EVERY header action as an item here (`promoted: true` on the visible
  ones): at ≥ lg the menu drops promoted items (and unmounts entirely when
  nothing overflows), below lg it carries the full set — the page title is
  never crushed by a strip of text buttons. Trigger = `btn btn-secondary`
  MoreHorizontal button; items are permission-gated (useAuth), icon + label,
  optional `danger` and `testId` (the detail `detail-action-<code>` anchor for
  overflowed actions). The lg gate reads `BREAKPOINTS.lg` from
  `tableRepresentation` (matchMedia), so JS and the buttons' `lg:` classes flip
  at the same width. Same portal/keyboard mechanics as `RowActionsMenu`.
- `src/components/ui/Skeleton.tsx` — pulse loading placeholder on the
  `--bg-muted` token. `DataTable` renders skeleton ROWS with it while loading
  (the spinner era is over: the table anatomy shows immediately); `StatCard`
  skeletons its value. Usable standalone in custom loading layouts.
- `src/components/ui/EmptyState.tsx` — the "nothing here yet" panel: icon +
  title + optional description + optional CTA slot. `DataTable` renders it for
  an empty settled result (`emptyIcon`/`emptyMessage` flow into it); pages can
  compose it directly for business-worded empty states.
- `src/components/ui/Badge.tsx` — the reusable status pill (six tones on the
  theme status tokens: neutral, success, warning, error, info, accent).
  `scaffold-component` emits it for boolean Oui/Non and status-coloured
  cells — the pill markup used to be INLINED in every generated page, which
  made it non-themable and invisible to audits. `neutral`/`success` are
  visually identical to the historical inlined pills.
- `src/components/ui/StatCard.tsx` — KPI tile for the CONTENT zone (list
  KPI-row, section homes, detail summaries). Visual sibling of the dashboard
  `KpiCard` but lives here because dashboard-primitives are only scaffolded
  when a dashboard exists; reads the same `--kpi-*` tokens (with theme
  fallbacks) so both render as one system. Supports icon, hint, up/down trend
  and a loading skeleton.
- `src/components/ui/useListState.ts` — URL-backed list state (plan UI 3.1):
  useState-compatible `[value, setter]` hooks persisted in the query string
  (`useListParam`, `useListParamOpt`, `useListNumberParam`,
  `useListRecordParam` — filters ride as `f.<key>` params). Defaults stay OUT
  of the URL (clean links); every write uses the functional
  `setSearchParams` form with `replace: true` so consecutive setters compose
  without history spam; the record hook's value identity is signature-stable
  (safe as a `useEffect` dep — a page change never re-triggers the filter
  debounce). Server-driven list pages hold page/size/q/sort/dir (+ filters,
  segment, view) through these — shareable, refresh/back-proof.
- `src/components/ui/SavedViewsMenu.tsx` — named saved views of the CURRENT
  list state: snapshots the query string per page into localStorage
  (`ss.views.v1.{app}.{module}.{section}.{entity}`), applies one back through
  `setSearchParams`. Self-contained (the page passes only the storage key and
  i18n-resolved labels); storage failures never crash the page. Rendered in
  the FilterBar picker slot next to the ColumnPicker. Because `?view=kanban`
  is URL state, a saved view captures the board mode + its filters with no
  extra wiring.
- `src/components/ui/useKanbanColumnPrefs.ts` — persisted per-board column
  ORDER + visibility for the kanban viewMode of a list page (localStorage
  `ss.kanban.v1.{app}.{module}.{section}.{entity}`, payload
  `{ order, hidden }`). The board's headers are draggable (reorder) and the
  ColumnPicker hides/reveals columns; stored keys unknown to the current
  column set are pruned, new columns spliced in at their declared position,
  storage errors fall back to the pagespec defaults (`initiallyHidden`).
- `src/components/ui/useModuleAvailability.ts` — is an application/module part
  of what THIS tenant was given? A thin adapter over the hook
  `@atlashub/smartstack` exports, which reads the nav catalogue the package
  already holds (`tenant_TenantApplications` / `TenantModules`, resolved
  server-side, refetched on a tenant switch) — no request of its own. The ONLY
  generated file naming the package symbol, so a project on an older package
  degrades in exactly one place: below the floor version the emitter writes a
  PERMISSIVE stub (every module available = the behaviour before the guard
  existed) plus a `ss upgrade` warning, never a broken import. Consumed by the
  detail page's cross-module related tabs (DEV-UI-049); NOT
  `useLicense().hasModule()`, which answers what the customer BOUGHT.
- `src/i18n/locales/{fr,en,it,de}/common.json` — deep-merged with the
  `entityLookup.*`, `dateInput.*` (incl. the `chipToday/chipTomorrow/chipNextWeek/
  chipNextMonth` quick-chip labels), `enumSelect.*`, `multiSelect.*` and
  `tabStrip.*` (arrow `scrollLeft/scrollRight` aria-labels) namespaces.
  `SegmentedControl`, `Textarea` and `Switch` carry no translated strings (their
  labels/options come from the caller). Existing keys are preserved.

## Idempotency contract

`/* @customised */` (or `// @customised`) marker at the top of any file =
preserve. Otherwise overwrite when content drifts. AUTO-GENERATED header
included so a re-run doesn't surprise the dev.

Locale files use **deep merge**: existing keys are preserved verbatim; only
the `entityLookup.*` subtree is reset to the canonical scaffold. Other
namespaces (`auth.*`, `nav.*`, …) untouched.

## When NOT to use

- Adding a custom field-level component (e.g. `RichTextEditor`) → those are
  feature-scoped, scaffold them in `src/features/<module>/<entity>/components/`
  via the regular `scaffold-component` flow.
- Changing the styling of an existing primitive → either mark the file
  `// @customised` (one-off override) or change the tokens in `index.css` via
  `scaffold-theme` (global). Don't patch the primitive.

## Linked checks

- `DEV-UI-022` (audit-dev-frontend, BLOCKING) — fires when a Form page renders
  a FK field as `<input>`/`<select>` instead of `<EntityLookup>`.
- `smoke-generation --quick` — used to flag `[BROKEN] @/components/ui/EntityLookup`;
  once this skill runs at Phase 3.0, the import resolves and the flag clears.
