---
name: ui-polish
description: >
  Audits and auto-fixes React pages in a generated SmartStack frontend against the
  customisation-ui design system standards (CSS variable tokens, PageTemplate
  wrapper, DataTable/EntityCard components, lucide-react icons, semantic badges,
  permission keys, i18n namespaces). Produces a violation report (audit mode) or
  applies mechanical fixes (apply mode) based on tokens.json. Invoked after
  Phase 4 of ba-develop to guarantee visual consistency with the
  reference app.
phase: development/frontend
cli: cli/ui-polish
allowed-tools: [Read, Glob, Grep, Bash]  # Bash: CLI invocation
---

# ui-polish

Lints `*.tsx` files under `src/pages/**` and `src/features/**/pages/**` against
the SmartStack design system tokens and patterns defined in `tokens.json`.

Source of truth for all rules: `tokens.json` (extracted verbatim from
`D:\01 - projets\SmartStack.app\features\customisation-ui\web\smartstack-web\src\base.css`
+ baseline pages `PresetsListPage.tsx`, `ThemesListPage.tsx`, `TenantDetailPage.tsx`,
`OrganisationCreatePage.tsx`).

## When to use

- After Phase 4 of `ba-develop` completes and the smoke-HTTP gate
  has passed (pages load without 404) — run `--audit` to detect violations,
  then `--apply` to mechanically repair the auto-fixable ones.
- Before committing generated pages to verify they match the reference
  SmartStack.app anatomy.
- When extending an existing generated app manually — run `--audit` before PR.

## Modes

### `--mode audit`

Scans every `*.tsx` under the project's page directories and returns a JSON
report listing each violation (one per rule fired per file). No file
modifications. Exit code `1` if any ERROR severity finding is present, `0`
otherwise (warnings/info do not fail the gate).

### `--mode apply`

Re-runs the audit, then applies mechanical fixes for every finding flagged
`autoFixable: true` in `tokens.json`. Fixes include:

- **R1-pagetemplate** — wrap page body in `<PageTemplate title=…>` if missing.
  **Project-aware:** when the project ships no PageTemplate component the fix is
  refused (returns null) — never injects a phantom import — and the finding is an
  INFO architecture note rather than a per-page error.
- **R3-primary-cta** — a raw-Tailwind CTA (`bg-blue-500`, …) is switched to the
  `.btn .btn-primary` helper (converges with R21 — the helper is the one true CTA,
  never an inline re-assembly of accent + text-white + radius).
- **R7-error-banner-anatomy** — rewrite `bg-red-*` error banners to the
  token-based anatomy.
- **R10-permission-key-format** — strip `{appCode}.` prefix from permission
  keys; normalize separators to `.`.
- **R11-slot-anatomy** — inject `<Slot name="<entity>.header.actions" />`
  after the PageTemplate opening tag if missing.
- **R13-badge-anatomy** — rewrite `bg-green-500`/`text-white` badges to the
  `bg-[var(--success-bg)] ...` anatomy.
- **R14-no-hardcoded-colors** — remap saturated/semantic Tailwind color
  utilities (`text-blue-600`, `bg-emerald-500`, `border-rose-400`, …) **anywhere**
  in `className` to the accent ramp (`var(--color-accent-<shade>)`) or the
  matching status token (`var(--<status>-bg|text|border)`). **Status dots** — a
  `bg-<status>` on a tiny round element (`rounded-full` + `h-2`/`w-2`/`size-2`…)
  maps to the saturated `var(--<status>-dot)`, not the pale `var(--<status>-bg)`
  surface (which is invisible at dot size).
- **R15-no-neutral-hardcoded-colors** — remap neutral *background* utilities
  (`bg-white`, `bg-gray-100`, `bg-zinc-900`, …) to `var(--bg-card|muted|app)`.
  Neutral `text-*`/`border-*` are **reported only** — the generated theme has no
  neutral text/border token to map them to.
- **R16-token-role-misuse** — flip a status token used outside its role:
  `bg-[var(--<status>-text)]` (saturated foreground used as a fill → illegible,
  worst in dark mode) → `bg-[var(--<status>-bg)]`; `text-[var(--<status>-bg)]`
  (pale surface used as text → invisible) → `text-[var(--<status>-text)]`. Catches
  bugs R2/R14 miss (the value is already a `var(--token)`, only its role is wrong).
- **R21-cta-inline-bypass** — an inline accent-fill CTA (`bg-[var(--color-accent-500|600)]
  text-white …` on a `<button>` that lacks the `btn` class) is switched to the
  `.btn .btn-primary` helper: the helper-owned color utilities are stripped, layout
  utilities are kept (the apply-diff review tidies the residue). Only when the project
  ships `.btn-primary`.

Findings flagged `autoFixable: false` are left for Claude to handle (e.g.
hardcoded strings that need i18n extraction, useParams null-checks that
require domain knowledge).

## Invocation

```bash
# Audit — report only, no mutation
npx --prefer-offline tsx skills/development/frontend/ui-polish/cli/ui-polish/index.ts \
  --project-path "/abs/path/to/generated-app" \
  --mode audit

# Apply — mechanical fixes then re-audit
npx --prefer-offline tsx skills/development/frontend/ui-polish/cli/ui-polish/index.ts \
  --project-path "/abs/path/to/generated-app" \
  --mode apply
```

## Arguments

| Arg | Type | Required | Description |
|-----|------|----------|-------------|
| `--project-path` | string | yes | Absolute path to the generated SmartStack web app (the folder containing `src/pages`). |
| `--mode` | `audit` \| `apply` | yes | `audit` reports only, `apply` rewrites files. |
| `--rules` | string list | no | Comma-separated rule IDs to include (e.g. `R1,R3,R7`). Default = all. |
| `--page` | string | no | Comma-separated page path(s) relative to project root — scan only those (default = all pages under `src/pages` + `src/features`). When set, 1st-level local imports (`@/components/**`, `@/features/**`) are also scanned for violations. |
| `--url` | string | no | Comma-separated URL paths (e.g. `/administration/tenants/{id}`) — resolved to component files via `PageRegistry.register()` data in `src/extensions/*Registry.ts`. |
| `--tokens-file` | string | no | Override tokens.json path (default = sibling of this script). |
| `--regenerate-threshold` | integer | no | In `apply` mode, pages with ≥ N violations get rebuilt from template. Default `3`, `0` = disable regeneration. |

## Output envelope

Extends the standard execute envelope with an extra `report` key:

```json
{
  "success": true,
  "command": "ui-polish",
  "data": { "mode": "audit", "rulesRun": ["R1", "R3", "R7", ...], "valid": false },
  "report": {
    "projectPath": "/abs/...",
    "filesInspected": 12,
    "findings": [
      {
        "ruleId": "R1-pagetemplate",
        "severity": "error",
        "file": "src/pages/crm/prospection/ProspectionListPage.tsx",
        "line": 5,
        "message": "Page root is a bare <div>. Must wrap in <PageTemplate title=…>.",
        "autoFixable": true
      }
    ],
    "counts": { "errors": 3, "warnings": 2, "infos": 1 },
    "byRule": { "R1-pagetemplate": 1, "R3-primary-cta": 2, ... }
  },
  "errors": [],
  "warnings": [],
  "nextSteps": [
    "3 ERROR findings — run with --mode apply to auto-fix the 2 fixable ones.",
    "The remaining 1 error (R4-i18n-namespace) requires Claude to extract hardcoded strings."
  ]
}
```

## Rules reference

All rule definitions live in `tokens.json` under `hardRules[]`. Each has:

- `id`: `Rxx-kebab-slug`
- `description`: human-readable explanation
- `violation`: what triggers a finding
- `autoFixable`: `true` if `--mode apply` can repair mechanically
- `severity`: `error` | `warning` | `info`

Current rule set (v1.4.0):

| ID | Severity | Auto-fix | Rule |
|----|----------|----------|------|
| R1-pagetemplate | error / info | yes* | Page MUST wrap in `<PageTemplate>` — **project-aware**: INFO + fix refused when the project ships no PageTemplate component |
| R2-no-hex-colors | warning | no | No hex values in className — use `var(--token)` |
| R3-primary-cta | error | yes | Primary CTA must use the `.btn .btn-primary` helper (raw-Tailwind buttons are switched to it) |
| R4-i18n-namespace | error | no | No hardcoded strings — use `useTranslation('…')` |
| R5-lucide-icons | warning | no | Icons from `lucide-react` only |
| R6-loader2-spinner | warning | no | Use `<Loader2 animate-spin />` for loading |
| R7-error-banner-anatomy | error | yes | Error banner must use token-based bg/border/text |
| R8-datatable-for-lists | info | no | List views SHOULD use `<DataTable>` |
| R9-useparams-nullcheck | error | no | `useParams<{id:string}>()` must be null-checked |
| R10-permission-key-format | error | yes | Permission keys: `{module}.{section}.{action}` |
| R11-slot-anatomy | info | yes | Pages SHOULD expose Slot injection points |
| R12-darktheme-base | warning | no | Dark theme base must be `#161618`, not `#0a0a0a` |
| R13-badge-anatomy | warning | yes | Status badges use `bg-[var(--status-bg)]` anatomy |
| R14-no-hardcoded-colors | error | yes | No saturated Tailwind color anywhere in `className` — mapped to accent / status tokens (status dots → `var(--<status>-dot)`) |
| R15-no-neutral-hardcoded-colors | warning | partial | No neutral Tailwind color — `bg-*` mapped to surface tokens; `text-*`/`border-*` reported only |
| R16-token-role-misuse | error | yes | Status token bound to the wrong utility (`bg-[var(--<status>-text)]` / `text-[var(--<status>-bg)]`) — flipped back to its role |
| R17-status-token-as-decoration | warning | no | Status token used as decorative/categorical palette (`{ blue: 'var(--info-bg)' }`) — reserve status for real status |
| R18-colors-outside-classname | warning | no | Tailwind color utilities in JS data structures (objects, arrays, config maps) outside `className` — bypasses R14/R15 |
| R19-hex-in-inline-style | warning | no | Hex/rgb/rgba in `style={{ }}` props — bypasses the design token system |
| R20-accent-overuse | warning | no | Accent token on non-CTA: thick border (`border-2+`), pale fill as section bg (`accent-50/100`), full fill (`≥500`) on non-button |
| R21-cta-inline-bypass | error | yes | Primary CTA reinvented inline (`bg-[var(--color-accent-500\|600)] text-white …` on a `<button>`) instead of the `.btn .btn-primary` helper — switched to the helper |
| R22-soft-accent-bypass | warning | no | Ad-hoc accent opacity tint (`bg-[var(--color-accent-500)]/10`) instead of `.badge-accent` / the `--accent-bg\|text\|border` triplet |
| R24-dangling-token-ref | warning | no | `var(--x)` in className referencing a token **not defined in the project's theme** (renders inconsistently). Project-aware — only runs when the theme is local-authoritative; skips `--sl-*`/`--tw-*` |
| R25-content-width | warning | no | Page **hard-codes its content width** (`container mx-auto`, page-root `max-w-2xl+ mx-auto`) instead of `<PageTemplate width="standard"\|"focused">`. Two widths sanctioned, the rest banned. Project-aware (needs PageTemplate); skips modals |
| R26-native-form-control | warning | no | Page renders a **native form control** (`<input type="date\|datetime-local">`, `<select>`, `<select multiple>`) instead of the theme-compliant `<DateInput>` / `<EnumSelect>` / `<MultiSelect>` — browser-chrome widgets ignore the theme (corners, surface, dark mode). Project-aware: each control flagged only when the matching primitive exists. (R23 stays reserved for segmented-control anatomy.) |
| R27-responsive-datatable | warning | no | A `*ListPage.tsx` renders a **bare `<DataTable>`** instead of `<ResponsiveDataTable>` (`@/components/ui/ResponsiveDataTable`). The wrapper keeps code/label/actions visible and reveals extra columns as the viewport widens (`minBreakpoint` per column), and clips long cell values with an ellipsis + tooltip. Anti-gate: nudges the responsive baseline; a page already using the wrapper (or with no table) is never flagged. |
| R28-dataviz-hardcoded-colors | error | no | **Chart/dashboard colours hardcoded instead of inherited from the theme.** The blind spot every other colour rule shares: chart colours reach Recharts as real strings through JSX props (`fill="#3b82f6"`, `stroke={COLORS[i]}`) or a palette array — never through className — so R2/R14/R15/R18/R19 all stay silent. Gated to chart code (imports `recharts`, `*DashboardPage.tsx`, `src/components/dashboard/**`). Three shapes: colour literal (error), `recharts` imported outside `ChartCard.tsx` (warning — a second untokenised chart path), stylesheets carrying neither `--dataviz-1` nor `--dataviz-cat-1` (warning only, project-aware — the platform theme runtime injects `--dataviz-cat-*` as inline styles a CSS scan cannot see). Sanctioned channel: `useDatavizPalette()` → platform `--dataviz-cat-1..12` (live, from the tenant's UI configuration) → scaffolded `--dataviz-1..8` → accent ramp, plus `--chart-*` / `--kpi-*`; custom category colours are declared once via `scaffold-theme` (`theme.dataviz`). Exempt: `ChartCard.tsx` (raw-recharts shape) and `useDatavizPalette.*` (its documented fallback). Sibling of audit `DEV-UI-036` |
| R29-modal-centering | error | yes | **Modal overlay not centred / under the header / hardcoded scrim.** Every `fixed inset-0` className must be the canonical wrapper `fixed inset-0 z-[100] flex items-center justify-center p-4 bg-[var(--overlay-scrim)] backdrop-blur-sm` (z-scale: header `z-[70]`, drawer `z-[90]`, modals `z-[100]`). NO scrim-vs-modal heuristic — the only valve is a reasoned marker `// modal-overlay-exempt: <reason>` on the className line or the line above (sidebar scrims, bottom sheets, command palettes, drawers, fullscreen viewers). Unlike page-scoped rules, **also scans `src/components/**` + `src/layouts/**`** (R29 only — where modals actually live). Auto-fix (idempotent): `items-start`→`items-center` (only with `flex`+`justify-center` and no `overflow-y-auto` — the unreachable-top trap), z<100→`z-[100]`, `bg-black/40\|50\|60`→scrim token, strip of `<dialog>`-reset residue. Structural shapes (no flex, translate panels, legacy `align-bottom` markup, non-literal className) stay errors for a manual rewrite — `max-h` + `overflow-y-auto` go on the CARD, never `items-start` on the overlay. R15 defers to R29 on overlay scrims |
| R30 | elevation-tokens | warning | auto | Raw Tailwind `shadow-sm/md/lg/xl/2xl` → the theme elevation scale: sm/md → `shadow-[var(--shadow-card)]`, lg/xl/2xl → `shadow-[var(--shadow-overlay)]` (scaffold-theme emits both, dark-mode aware). |

> **R3/R7/R13 vs R14/R15** — R3 (button), R7 (banner) and R13 (badge) are the
> precise, context-aware fixes; R14/R15 are the general net that catches every
> remaining hardcoded color elsewhere on the page. When both fire on the same
> color at the same location, the audit keeps only the precise finding.
> R29 (modal overlays) claims the overlay scrim token the same way: R15 neither
> reports nor rewrites `bg-black/NN` inside a `fixed inset-0` className.

> **Project-aware (v1.4.0)** — the engine derives a capability set from the TARGET
> project (`theme-capabilities.ts`): helper classes + tokens scanned from its CSS
> (UNIONed with the tokens.json baseline), components scanned from `src/components`.
> Rules then flag bypass of primitives the project **ships** — R21/R22 only fire when
> `.btn-primary` / the soft-accent token actually exists — and never require components
> it **lacks**: R1/R11 downgrade to INFO and refuse their fix when the component is
> absent, so the audit can't emit a build-breaking phantom `<PageTemplate>` import.
> This replaces the old behaviour of enforcing one reference app's anatomy verbatim.

## Design constraints (anti-legacy)

This skill was **not** derived from the existing `skills/development/frontend/component/**`
templates. Rules were extracted directly from:

1. **baseline pages** (`PresetsListPage.tsx`, `ThemesListPage.tsx`, `OrganisationCreatePage.tsx`)
   — the DOM anatomy that the generated code must match;
2. **`base.css` + `index.css`** — the authoritative list of CSS variables and
   helper classes;
3. **`extensions/README.md`** — the Slot/Fill contract.

The existing frontend skills are **informational only**. Never replicate a
pattern from them unless it also exists in the baseline.
