---
name: audit-dev-frontend
description: Audit code generated by the Frontend phase against the PRD slice — page coverage, registry wiring, lazy-import integrity, i18n namespaces, drift detection
group: D
phase: devFrontend
kind: audit
audit_only: true
section_label: 'AUDIT-DEV-FRONTEND (rules to apply against generated React pages vs the PRD Frontend slice)'
allowed-tools: [Read, Glob, Grep, Bash]  # Bash: CLI invocation
---

# audit-dev-frontend — Frontend Phase Code-vs-PRD Audit

## Context

You are auditing the output of the Frontend phase of `ba-develop`.
Compare the PRD slice (spec) against the generated `.tsx` pages, registries
and i18n files (reality), and emit findings for every drift.

You receive in the system prompt:

- `--- PRD SLICE: FRONTEND ---` — Markdown listing the screens / pages /
  components the user wants for this application.
- `--- PROJECT INVENTORY (frontend) ---` — deterministic scan of:
  - `pages[]` (file, entity, kind),
  - `registries[]` (which generated registry files exist),
  - `i18n.locales[]` and `i18n.namespaces[]`.
- `--- FILES TOUCHED THIS PHASE ---` — files modified during the current run.
- `--- APPLICATION CODE / MODULE CODE ---` — active scope.

You may use `Read` / `Glob` / `Grep` to verify rules. **No `Edit` /
`Write` / `Bash`** — you are read-only.

Apply every rule across the module scope. Emit `ok` findings for passing
rules and `err` / `warn` findings for drifts. Use `err` only when the spec
is unambiguous and the gap is clearly wrong (missing required page,
broken registry import). Use `warn` for conventions, drift, and missing
nice-to-haves.

## Rules

### DEV-UI-001 — Every screen in the PRD slice has a generated page file
- **Severity**: err (if any required page missing), ok (if all present)
- Check: for each screen declared in the PRD slice with a SmartComponent
  that maps to a page (`SmartListView`, `SmartForm`, `SmartCard`,
  `SmartDashboard`, `SmartKanban`, `SmartAppHome`, `SmartModuleHome`,
  `SmartSectionHome`), find a matching entry in `inventory.frontend.pages[*]`.
  Match key = `(entity.toLowerCase(), kind.toLowerCase())` against
  `(page.entity.toLowerCase(), page.kind.toLowerCase())`. Pages without
  an entity (Dashboard / *Home) match by their screen `code`.
- **ok**: label=`DEV_UI_001_ok`, params=`{ count: <number> }`
- **err**: label=`DEV_UI_001_err`, params=`{ missing: "<comma-separated entity/kind or screen-code>" }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`
- **solution** (mandatory on err): "Re-run the Frontend phase. The
  subagent must call `scaffold-component` for each missing page,
  using the matching SmartComponent pattern from the PRD slice."

### DEV-UI-002 — Every generated page is referenced in at least one registry
- **Severity**: err (if any orphan page), ok (if all wired)
- Check: for each page in `inventory.frontend.pages[*].file`, search the
  registry files listed in `inventory.frontend.registries[]` (typically
  `componentRegistry.generated.ts` and friends) for a `lazy-import`
  pointing at that file. Use `Grep` with the relative file path or the
  page module's expected key.
- A page that exists on disk but isn't registered will not render at
  runtime — DynamicRouter cannot resolve it.
- **ok**: label=`DEV_UI_002_ok`
- **err**: label=`DEV_UI_002_err`, params=`{ pages: "<comma-separated orphan page paths>" }`
- **solution** (mandatory on err): "Either re-run the routes scaffolder
  to regenerate the registry, or add a manual `lazy-import` entry for
  each orphan page. The DB-driven router fails to resolve unregistered
  pages."

### DEV-UI-003 — Registry lazy-imports resolve to existing files
- **Severity**: err (if any broken import), ok (if all valid)
- Check: for each registry file, `Read` it and extract every
  `() => import('<path>')`. Resolve `<path>` relative to the registry
  file. The target file MUST exist on disk (`Glob`). Broken imports
  fail the Vite build silently for lazy chunks until the route is
  visited at runtime.
- **ok**: label=`DEV_UI_003_ok`
- **err**: label=`DEV_UI_003_err`, params=`{ broken: "<comma-separated registry→missing-import>" }`
- **solution** (mandatory on err): "Either restore the missing files or
  remove the orphan import entries from the registry. Lazy-imports that
  resolve to non-existent paths produce a runtime error on first
  navigation, not at build time."

### DEV-UI-004 — i18n namespace exists for the module across every locale
- **Severity**: warn (if any locale missing the namespace), ok (if all present)
- Check: derive the expected namespace from the module code in the PRD
  (typically `<applicationCode>.<moduleCode>` or `<moduleCode>` alone).
  For every locale in `inventory.frontend.i18n.locales`, the namespace
  MUST appear in `inventory.frontend.i18n.namespaces`. The frontend
  falls back to the key string when a translation is missing — the user
  sees raw camelCase in the UI.
- Skip when `inventory.frontend.i18n.locales` is empty.
- **ok**: label=`DEV_UI_004_ok`
- **warn**: label=`DEV_UI_004_warn`, params=`{ missing: "<comma-separated locale.namespace pairs>" }`
- **solution** (mandatory on warn): "Add the missing namespace JSON
  files for each locale (typically `src/locales/<locale>/<namespace>.json`).
  Even an empty `{}` is preferable to a missing file — the i18next
  loader logs a warning but does not crash."

### DEV-UI-005 — Pages wrap their content in `<PageTemplate>`
- **Severity**: warn (if any page raw), ok (if all wrap)
- Check: for each page in `inventory.frontend.pages[*].file`, `Grep`
  for `<PageTemplate` in the file content. Pages that skip the wrapper
  break the Studio layout (sidebar, breadcrumbs, header).
- Skip pages whose `kind` is `dashboard` or `home` if the project's
  customisation-ui doesn't expose a PageTemplate for them (verify by
  reading at most one example dashboard / home page from the
  customisation-ui templates).
- **ok**: label=`DEV_UI_005_ok`
- **warn**: label=`DEV_UI_005_warn`, params=`{ pages: "<comma-separated page paths missing PageTemplate>" }`
- **solution** (mandatory on warn): "Wrap each listed page with
  `<PageTemplate title=\"...\">{...}</PageTemplate>`. The template is the single
  source of truth for PAGE layout (title, breadcrumbs, action slot, content
  width). The app chrome around it — desktop header + sidebar, mobile shell +
  bottom bar — is rendered by @atlashub/smartstack, never by the page."

### DEV-UI-006 — Sensitive actions are gated by `<PermissionGuard>` / `useHasPermission`
- **Severity**: warn (if any unguarded action button), ok (if all guarded)
- Check: for each page that exposes a write action (button, menu item)
  matching the keywords `Create`, `Edit`, `Delete`, `Update`, `New`,
  `Add`, `Remove`, the file must reference either `PermissionGuard` or
  `useHasPermission` at least once. `Grep` the page contents for both
  identifiers.
- Skip pages that obviously have no write actions (read-only Dashboard,
  Home pages without quick-actions).
- **ok**: label=`DEV_UI_006_ok`
- **warn**: label=`DEV_UI_006_warn`, params=`{ pages: "<comma-separated page paths missing permission gate>" }`
- **solution** (mandatory on warn): "Wrap each write button with
  `<PermissionGuard permission=\"<key>\">...</PermissionGuard>` or
  short-circuit the handler with `useHasPermission`. Without the gate,
  unauthorized users see a button that throws on click instead of
  being hidden."

### DEV-UI-007 — Each page has at least one `*.test.tsx` test file
- **Severity**: warn (if any page has no test), ok (if all covered)
- Calque the existing frontend gate (`runGate frontend` already counts
  test files). Check: for each page in `inventory.frontend.pages[*].file`,
  there must be a sibling test file matching `<base>.test.tsx` OR a
  test file under `__tests__/` with a matching name. Use `Glob` to
  enumerate.
- **ok**: label=`DEV_UI_007_ok`
- **warn**: label=`DEV_UI_007_warn`, params=`{ pages: "<comma-separated page paths missing tests>" }`
- **solution** (mandatory on warn): "Re-run `scaffold-tests --layer frontend`
  for each listed page, or add a minimal smoke test that mounts the page
  with a Test wrapper providing QueryClient + i18n."

### DEV-UI-008 — No "ghost" pages (drift detector)
- **Severity**: warn (if any ghost found), ok (if none)
- Reciprocal of DEV-UI-001: every page in
  `inventory.frontend.pages[*]` must trace back to a screen in the PRD
  slice. Match key = `(entity, kind)` for entity-bound pages, screen
  `code` for hub pages. Pages with no upstream spec are stale (PRD
  edited after a previous run) or hallucinated.
- **ok**: label=`DEV_UI_008_ok`
- **warn**: label=`DEV_UI_008_warn`, params=`{ pages: "<comma-separated ghost page paths>" }`
- **solution** (mandatory on warn): "Either re-add the corresponding
  screen to the PRD slice if it was removed by mistake, or delete the
  page file AND remove its registry entry. Leaving ghost pages in the
  registry leaves dead routes in the Studio menu."

### DEV-UI-009 — Every PRD use case has at least one page targeting its primary entity
- **Severity**: err (if any use case uncovered), ok (if all covered)
- Check: for each entry in `prdSlice.useCases[*]` with an `entity` field,
  there must be at least one page in `inventory.frontend.pages[*]` with a
  matching entity (case-insensitive). Use cases without an entity are
  skipped (process-only flows). A use case with no UI entry-point cannot
  be exercised by the user — the PRD scenario is unreachable in practice.
- **ok**: label=`DEV_UI_009_ok`, params=`{ count: <number> }`
- **err**: label=`DEV_UI_009_err`, params=`{ useCases: "<comma-separated UC.code(entity)>" }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`
- **solution** (mandatory on err): "Each PRD use case must have at least
  one page targeting its primary entity. Re-run scaffold-component to
  generate the missing pages. If the use case is intentionally process-only
  (e.g. background jobs), remove the `entity` field from the PRD slice."

### DEV-UI-010 — RETIRED (was: `enforcedAt: ui|both` rules wired into forms)
> **RETIRED** (audit « chaîne de garanties », chantier 2.5). The check was
> structurally DEAD in the v5 file-based flow: its only input was
> `prdSlice.businessRules[*].enforcedAt` — a field that exists in NO current
> schema (`règles-métier.md`, pagespecs, scaffold specs), fed by a
> `--prd-slice` JSON `/ba-create-prd` never produces. On every real run it
> returned `ok` with « no UI-enforced business rules » — a green that proved
> nothing (the false-comfort class the audit exists to eliminate). A check
> that cannot fail is worse than no check.
> The REAL chain for UI-visible rule behaviour: rules ride the generated
> validators (DEV-API-008 — module-scoped trace + no-rules-declared catch-up),
> whose error messages the forms already surface inline; their test floor is
> DEV-TEST-009. The CLI keeps a stub returning the retirement note so
> `--rules DEV-UI-010` invocations stay explainable — never re-apply this rule
> conversationally.

### DEV-UI-011 — The page's PermissionGuard matches the PAGESPEC's permission
- **Severity**: err (missing guard, or guard on the wrong permission), warn
  (frontend-only gating), ok otherwise. Needs `--module-path`.
- **Rewritten 2026-08 (chantier 4.1)** — the rule used to be structurally
  inert: its expected permissions came from a `--prd-slice` JSON the v5 file
  flow never produces (→ ok « no PRD permissions to map » forever), and its
  controller scan only saw QUOTED literals while generated controllers use
  const-expressions exclusively — it could not fail.
- Check (per module pagespec carrying a ≥3-segment `permission`):
  - locate the generated page: pagespec `filePath` basename ↔ inventory
    first; FALLBACK on entity + view kind with the REAL `pluralize()`
    (CategoriesListPage ↔ Category — a naive `entity+'s'` missed every
    irregular plural). `@customised` pages and pages not yet scaffolded are
    skipped;
  - **missing-guard** (err): the page carries no `<PermissionGuard>` at all —
    the route loads for everyone, fails its API call with 403 and renders a
    broken empty state instead of « you do not have access »;
  - **wrong-permission** (err): no guard permission (literals + the form's
    `const permission = …` variable shape) starts with the spec's PREFIX
    (permission minus the action segment) — the H11 drift: guards recomputed
    from `{module}.{section}` while the spec binds another prefix.
    Related-tab guards legitimately carry another entity's prefix — the rule
    asks for AT LEAST ONE matching guard, so they never false-err;
  - **frontend-only** (warn, with `--backend-path`): the spec permission is
    enforced by NO controller — `[RequirePermission]` scanned with
    const-expressions resolved through the generated `*Permissions*.cs`
    constants. DEV-API-021/033 own the hard backend axis; this leg only
    flags a cosmetic guard with no server enforcement behind it.
- Companion generator fix: `scaffold-component` (render/context.ts `permKey`)
  now derives every guard key from the pagespec `permission` prefix when
  provided (legacy fallback `{module}.{section}`) — so **re-running
  scaffold-component heals both err shapes**.
- DEV-UI-011 covers **page-level** wrapping. The action-level rule
  (DEV-UI-006) remains separate — both must pass for full coverage.
- **ok**: label=`DEV_UI_011_ok`
- **err**: label=`DEV_UI_011_err`, params=`{ page, pagespec, permission, issue }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`
- **solution** (mandatory on err): "Re-run scaffold-component for the listed
  page — the generator derives the guard from the pagespec permission. If the
  pagespec permission itself is wrong, fix it upstream (/ba-create-prd,
  PRD-055) first."

### ~~DEV-UI-012~~ — RETIRED (2026-08, chantier 4.1)

> **This rule no longer applies — do not evaluate it.** It read
> `prdSlice.rbac`, fed by a `--prd-slice` JSON the v5 file flow never
> produces, so it returned ok (« no PRD RBAC entries ») on every real run —
> a check that could not fail, pure false comfort.
> Its axis — « every granted permission is materialized where the runtime
> reads it » — is covered for real elsewhere: **DEV-CORE-011** (bidirectional
> `rbac.md` ⇄ seed-state grant parity via `derive-rbac-grants --mode check`),
> **DEV-API-021** (controller constants ⊆ seeded grants) and **DEV-UI-046**
> (every registry componentKey reachable in the seeded nav). The CLI keeps a
> stub returning the retirement note so `--rules DEV-UI-012` invocations stay
> explainable — never re-apply this rule conversationally.

### DEV-UI-013 — `navigate()` URLs come from the per-module routes helper, not hardcoded literals
- **Severity**: err (if any hardcoded URL), ok otherwise
- Check: for each `*Page.tsx` page, grep for absolute string literals inside
  `navigate(...)` (regex `navigate\s*\(\s*(['"\`])(\/[^'"\`]+)\1`). Pages
  must instead `import { routes } from '@/extensions/<module>Routes'` and
  call `routes.<section>.create()` / `.detail(id)` / `.edit(id)`.
- The bug it catches : when a page navigates to `/budgets/budgets/new` while
  the registry registered `'budgets.budgets.create'`, DynamicRouter cannot
  resolve the URL → blank page or perpetual spinner. The routes helper is
  the single source of truth shared with `{app}-{module}Registry.ts`
  (app-scoped naming, `lib/app-classification.extensionsModuleId`).
- **ok**: label=`DEV_UI_013_ok`
- **err**: label=`DEV_UI_013_err`, params=`{ file, url }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`, autoFixable
- **solution** (mandatory on err): "Replace the hardcoded URL with a call
  to the per-module routes helper. Re-run `scaffold-component --overwrite`
  for the affected page — the regenerated template imports `routes` from
  `@/extensions/<module>Routes` and uses it consistently."

### DEV-UI-014 — List pages do not emit stub helpers for missing DTO fields
- **Severity**: err (if any stub helper), ok otherwise
- Check: in List pages only, regex-detect named arrow/function helpers whose
  body returns a literal stub (`return 0`, `return ''`, `return null`,
  `return false`). Pattern names ending in `For` (`consumptionFor`,
  `priceFor`) are the typical "give-up fallback" the sub-agent emits when
  it can't find the field in the list DTO.
- This rule does **not** auto-fix : the structural fix is to declare the
  missing field as a computed attribute with a `formula` in the BA data
  model (audit DM-016). `scaffold-business` then projects it via LINQ and
  the column reads `item.<name>` directly — no helper, no stub.
- **ok**: label=`DEV_UI_014_ok`
- **err**: label=`DEV_UI_014_err`, params=`{ file, helper }`
- **fixSkill**: `frontend-api-client`, **fixPhaseKey**: `data` (for the
  scaffolders to re-emit the corrected DTO)
- **solution** (mandatory on err): "Don't paper over the gap with a stub.
  Either (a) declare the missing field as a computed attribute (`formula`)
  on the BA data model so scaffold-business projects it via LINQ, or
  (b) extend the list DTO to include the value computed by the backend
  service. The List column should read `item.<name>` directly."

### DEV-UI-015 — API client signatures match controller routes
- **Severity**: err (if any mismatch), ok otherwise
- **Requires**: `--backend-path` flag pointing to the .NET project root.
  Skipped (`ok` with `note`) when not supplied — equivalent to the
  best-effort behaviour of system-prompt mode.
- **Check**: parse every `*Controller.cs` to extract endpoints (verb +
  full route from `[Route("api/…")]` + method-level `[HttpGet/Post/Put/Delete]`,
  plus `[FromBody]` / mutation-parameter detection). Parse every
  `services/*Service.ts` (and `src/services/**/*.ts`) for axios calls
  (`apiClient.get('/...')`, `axios.post('/...', body)`). Match TS calls
  against controller endpoints by **(verb, normalised URL)**. URLs are
  normalised : lower-case, leading `/api/` stripped, template-literal
  `${id}` → `{id}` (matches the ASP.NET route token).
- **Detected drifts** :
  - No matching endpoint → `no controller route matches '{verb} /{url}'`
    (typo or missing endpoint).
  - Same URL but different verb → `wrong HTTP verb — service uses 'X' but
    controller exposes 'Y'`.
  - Body presence mismatch → `controller expects a body ([FromBody]) but
    service does not send one` (or the reverse).
- This rule catches the original Budget Dashboard 400 pattern : a service
  call hitting an endpoint that doesn't exist, or sending the wrong shape.
  Mismatches at this layer surface as runtime 400/404 errors with no
  console diagnostic — exactly the kind of bug `npm run build` cannot detect.
- **ok**: label=`DEV_UI_015_ok`, params=`{ count }`
- **err**: label=`DEV_UI_015_err`, params=`{ file, verb, url, reason }`
- **fixSkill**: `frontend-api-client`, **fixPhaseKey**: `api`, audit-only
  (the fix is a re-scaffold with the corrected spec — apply.ts does NOT
  rerun scaffold-api-client because the spec must come from a fresh PRD
  slice read).
- **solution** (mandatory on err): "Re-run scaffold-api-client with the
  corrected spec, or fix the controller route attribute. Mismatches here
  produce silent runtime 400/404 — `npm run build` won't catch them."

### DEV-UI-016 — List pages must use `<DataTable>` from customisation-ui
- **Severity**: warn (if raw `<table>` found), ok otherwise
- Check: in List pages only, fail if the source contains `<table` AND does
  not render `<DataTable` **or** `<ResponsiveDataTable` (the scaffold-component
  baseline wrapper around the local `@/components/ui/DataTable`). The
  customisation-ui baseline mandates DataTable for built-in search /
  pagination / sortable columns + theme cohesion. (For the responsive-columns
  + truncation-tooltip nudge, see ui-polish **R27**.)
- **ok**: label=`DEV_UI_016_ok`
- **warn**: label=`DEV_UI_016_warn`, params=`{ pages }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`, autoFixable
- **solution** (mandatory on warn): "Re-run scaffold-component with
  `--overwrite` for each listed page — the canonical template emits
  `<DataTable>` with searchable + paginated columns wired via the
  `DataTableColumn<T>[]` contract."

### DEV-UI-017 — Generated CSS utility classes are declared in `index.css`
- **Severity**: warn (if any class is used but not declared), ok otherwise
- Check: scan every page `className="..."` for tokens in the tracked set
  (`input`, `btn-primary`, `btn-secondary` — the helpers the scaffold
  templates rely on). Each used token must appear as a `.<token> { ... }`
  declaration in `src/index.css`. Without the declaration, the form
  inputs / buttons render unstyled because Tailwind's `@apply` chain
  misses the helper layer.
- **ok**: label=`DEV_UI_017_ok`
- **warn**: label=`DEV_UI_017_warn`, params=`{ file, classes }`
- **fixSkill**: `frontend-theme`, **fixPhaseKey**: `frontend`, autoFixable
- **solution** (mandatory on warn): "Re-run scaffold-theme to inject the
  missing helper classes into `src/index.css`. The theme scaffolder is
  idempotent and respects the `/* @customised */` marker for hand-edited
  blocks."

### DEV-UI-018 — Barrel `index.ts` re-exports resolve to existing files
- **Severity**: err (if any broken re-export), ok (if all valid)
- **Scope**: barrels under `src/components/**/index.ts`, `src/pages/**/index.ts`,
  `src/hooks/**/index.ts`, `src/utils/**/index.ts`, `src/services/**/index.ts`,
  `src/features/**/index.ts`. Excludes `src/extensions/componentRegistry.generated.ts`
  (already covered by DEV-UI-003).
- **Check**: for each barrel, regex-extract every `export { ... } from '<rel>'`
  / `export * from '<rel>'` with a relative path. Resolve `<rel>` against the
  barrel's directory, trying `.tsx`, `.ts`, `/index.tsx`, `/index.ts`. Fail if
  none exists on disk.
- **The bug it catches**: `src/components/email/index.ts` exports
  `EmailTemplateEditor3PaneLayout` from a file that was renamed to
  `2PaneLayout.tsx`. Vite returns **404** on the missing file when transforming
  the barrel → barrel can't be served → every dynamic `import()` traversing it
  (e.g. `EmailTemplateEditPage` consuming `@/components/email`) **cascades** into
  `Failed to fetch dynamically imported module` on the *parent* bundle — and
  `lazyWithRetry` cannot rescue a true 404. The user sees a `RouteErrorBoundary`
  screen on a route that has nothing to do with the broken file.
- **ok**: label=`DEV_UI_018_ok`
- **err**: label=`DEV_UI_018_err`, params=`{ broken: "<comma-separated barrel→missing-import>" }`
- **solution** (mandatory on err): "Either restore the missing file or delete
  the dead re-export line. A barrel `index.ts` that references a non-existent
  sibling is a tripwire — it breaks every consumer of the barrel even though
  the consumer never touched the dead export."

### DEV-UI-019 — Pagespec `kind:api` action produces service method + hook + button

> **Status: spec only — NOT implemented in the deterministic CLI.** The
> service↔pagespec↔controller subset is covered today by
> `/audit-dev-actions-alignment` (colocated CLI); hook + button wiring remains
> a manual review point until this rule lands in `audit.ts`.

- **Severity**: err (any pagespec api-action missing on the frontend), ok (if all wired)
- Check: load every `<MODULE>/pagespecs/*.md` of the audited module and
  parse the fenced ```json blocks. For each `pagespec.actions[]` entry
  with `kind: "api"` AND `code ∉ STANDARD_CRUD_CODES`:
  1. `<entityLower>Service.ts` MUST declare a member named
     `toCamel(endpoint)` (e.g. `endpoint: "sync-from-proconcept"` →
     `syncFromProconcept`).
  2. `use<Entity>.ts` MUST declare a hook named
     `use<PascalCase(endpoint)><Entity>` (e.g.
     `useSyncFromProconceptTypeAffaire`).
  3. The page matching the action's `scope` (`row` → list/detail page,
     `header` → list page) MUST import that hook AND wire a button whose
     `onClick` calls the resulting mutation.
- De-duplicate by `(scope, endpoint, httpMethod)` across pagespecs — the
  same action MAY appear on several views and only needs ONE wiring.
- **ok**: label=`DEV_UI_019_ok`, params=`{ count: <number> }`
- **err**: label=`DEV_UI_019_err`, params=`{ actions: "<comma-separated entity:code:missingArtifact>" }`
- **fixSkill**: `frontend-api-client` + `frontend-component`, **fixPhaseKey**: `frontend`
- **solution** (mandatory on err): "Re-run Phase 3a of `/ba-develop`. The
  orchestrator derives `customActions[]` from `pagespec.actions[]` and
  forwards them to `scaffold-api-client` AND `scaffold-component`. A
  missing artefact means either the regen was skipped or the pagespec
  was edited after the last run — sync the contracts via
  `/audit-dev-actions-alignment` before re-running."

### DEV-UI-020 — Pagespec `kind:navigate` actions emit `navigate()`, NOT axios

> **Status: spec only — NOT implemented in the deterministic CLI.** Prevented
> upstream by `scaffold-component` (its template branches on `kind` and emits
> no hook for navigate actions); no deterministic re-check exists yet.

- **Severity**: err (any navigate action wired as an api call), ok otherwise
- Check: for each `pagespec.actions[]` entry with `kind: "navigate"` of
  entity `<E>`, the page MUST contain a button whose handler calls
  `navigate(<targetRoute>)` AND MUST NOT import a hook
  `use<PascalCase(endpoint)><E>` named after this action's code. The bug
  this catches is the legacy pattern `POST /{id}/open` → 405 — a legacy
  generator that ignored `kind` and emitted an axios call for what should
  have been a router transition.
- **ok**: label=`DEV_UI_020_ok`
- **err**: label=`DEV_UI_020_err`, params=`{ actions: "<comma-separated entity:code pairs wired as api>" }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`, autoFixable
- **solution** (mandatory on err): "The component template wired a
  navigate-kind action as an api call. Re-run `scaffold-component` after
  upgrading to a template that branches on `kind` (it now imports NO hook
  for navigate actions, just `useNavigate()`)."

### DEV-UI-021 — Hardcoded `apiClient.<verb>(...)` URLs match a pagespec endpoint

> **Status: spec only — NOT implemented in the deterministic CLI.** The
> service-URL ↔ pagespec-endpoint cross-reference is covered today by
> `/audit-dev-actions-alignment`; run it for the alignment report.

- **Severity**: err (any axios URL outside the contract), ok otherwise
- Check: same parser as DEV-UI-015 (axios call detector) BUT cross-references
  the discovered URLs against the union of `pagespec.actions[].endpoint`
  values (per entity). Any URL hitting `/api/<module>/<plural>/<non-crud-segment>`
  that is NOT in this union AND NOT one of the canonical CRUD shapes
  (`/api/<module>/<plural>` GET/POST, `/api/<module>/<plural>/:id`
  GET/PUT/DELETE) is a hardcoded URL bypassing the per-page contract.
  Catches the legacy pattern: service.ts calls `/sync-from-pce` while the
  pagespec declares `endpoint: "sync-from-proconcept"` → instant 405.
- **ok**: label=`DEV_UI_021_ok`
- **err**: label=`DEV_UI_021_err`, params=`{ calls: "<comma-separated file:line:URL>" }`
- **fixSkill**: `frontend-api-client`, **fixPhaseKey**: `frontend`
- **solution** (mandatory on err): "Either declare the missing custom
  action in the pagespec (then re-run Phase 3a) or fix the URL to match
  the pagespec endpoint. Hardcoded URLs outside the contract drift the
  moment the BA reshapes the screen — service.ts must always be the
  reflection of the pagespec, never a parallel source of truth. See
  `/audit-dev-actions-alignment` for the alignment report."

### DEV-UI-022 — FK fields render as `<EntityLookup>`, never as `<input>` / `<select>`
- **Severity**: err (any FK rendered as a text input or static dropdown), ok (all FK fields use the combobox)
- Scope: Form / Create / Edit pages only — List pages can show FK columns as text (they're not editable).
- Check: scan each form page for `<input ... (name|id)="xxxId">` and
  `<select ... value={...xxxId}>`. The convention `xxxId` mirrors the BA
  `entité.md` Rel: line FK naming (`Employee *→1 Department (FK DepartmentId)`
  → camelCase `departmentId` in the React state). Hidden inputs
  (`type="hidden"`) and a small whitelist (`externalId`, `parentId`,
  `guidId` — identifiers that happen to end in "Id" but are not FKs) are
  excluded.
- Symmetric to backend DEV-DAT-008 (same-module FKs are real constraints,
  not bare `Guid`). On the frontend, the symmetric guarantee is that the
  user interacts with the canonical combobox over
  `/api/{module}/{plural}/lookup` — not a free-text Guid input that breaks
  on first paste.
- **ok**: label=`DEV_UI_022_ok`
- **err**: label=`DEV_UI_022_err`, params=`{ file, field, entity }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`, autoFixable: false (orchestrator must enrich the field with `fkTo` from `entité.md` Rel: lines before re-scaffolding — see ba-develop Phase 3a)
- **solution** (mandatory on err): "Re-run scaffold-component for this page so the orchestrator (ba-develop Phase 3a) enriches the field with `fkTo: { entity, module }` from entité.md Rel: lines. The generator then emits `<EntityLookup apiEndpoint=\"/api/{module}/{plural}/lookup\" />` (scaffolded into `src/components/ui/EntityLookup.tsx` by `scaffold-ui-primitives` at Phase 3.0). If the FK has no declared relation in entité.md, add a `Relations:` line to the entity definition first."

### DEV-UI-026 — Breadcrumb hrefs come from the routes helper, not hardcoded/relative literals
- **Severity**: err (any breadcrumb href that is a string literal), ok otherwise
- Check: for each `*Page.tsx`, isolate the `breadcrumbs={[ … ]}` block
  (regex `breadcrumbs\s*=\s*\{\s*\[([\s\S]*?)\]\s*\}`) and flag any `href:` whose
  value is a quoted literal — relative (`'..'`, `'../x'`) or absolute
  (`'/app/mod/sec'`). Breadcrumbs must instead use
  `import { routes } from '@/extensions/<module>Routes'` and
  `href: routes.<section>.list()`. The block scoping is deliberate: a page may
  legitimately contain `<Navigate to=".." />` or `navigate('/x')` (DEV-UI-013's
  domain) — those live outside the breadcrumbs block and are never flagged.
- The bug it catches: a relative `'..'` resolves against the current route under
  React Router v6 — from an edit page (`/app/mod/sec/:id/edit`) `..` lands on the
  detail page, not the list. And every literal drifts when a route is renamed.
  This is the breadcrumb twin of DEV-UI-013 (which guards `navigate()`).
- This rule does **not** auto-fix: a fixer would need the page→section→routes-key
  mapping, not safely recoverable from page source. The structural fix is to
  re-run `scaffold-component --overwrite` for the page — the regenerated template
  imports `routes` and emits `href: routes.<section>.list()`.
- **ok**: label=`DEV_UI_026_ok`
- **err**: label=`DEV_UI_026_err`, params=`{ file, href }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`, autoFixable: false
- **solution** (mandatory on err): "Breadcrumb hrefs must come from the per-module routes helper, never a hardcoded/relative literal. Re-run `scaffold-component --overwrite` for the affected page — the regenerated template imports `routes` and uses `href: routes.<section>.list()`."

### DEV-UI-027 — Detail-page tabs mount the TabStrip primitive (or the legacy two-layer markup)
- **Severity**: err (a tab strip with neither TabStrip nor the two-layer structure), ok otherwise
- Check: identify a tab strip UNAMBIGUOUSLY by its active/inactive underline
  toggle — a `border-b-2` button whose class flips between an accent border
  (`border-[var(--color-accent-NNN)]`) and `border-transparent`. EVERYTHING is
  gated on that signature, so a plain `flex … border-b` header bar or Kanban
  filter (no `border-b-2` toggle) is never treated as a tab strip and never
  flagged. On a tab strip, the page passes when it mounts the **`<TabStrip>`
  primitive** (usage AND the `@/components/ui/TabStrip` import — a comment
  mentioning TabStrip is not enough): the two-layer `-mb-px` overlap and the
  overflow chevron arrows live inside the primitive (scaffold-ui-primitives).
  The legacy inline two-layer markup — bottom border on an OUTER full-width div,
  INNER flex row carrying `flex gap-1 -mb-px` — stays accepted (pre-primitive /
  `@customised` pages). Neither marker — or `flex` + `border-b` on the SAME
  element (incl. the `flex items-center gap-1 border-b …` variant) — is the
  single-layer bug.
- The bug it catches: without the `-mb-px` overlap (owned by TabStrip in the
  canonical form), the active tab's 2px `border-b-2` is drawn ABOVE the
  container's 1px `border-b`, stacking a second line under the active tab → a
  doubled / misaligned underline instead of the active tab's colour cleanly
  replacing the gray divider. Emitted by `scaffold-component`'s detail view when
  `pageSpec.tabs[]` is present.
- This rule does **not** auto-fix: the structural fix is to re-run
  `scaffold-component --overwrite` for the page (after `scaffold-ui-primitives`,
  which emits `TabStrip.tsx`) — the regenerated template mounts `<TabStrip>`
  around the `accent-600` triggers with the ARIA tab contract.
- **ok**: label=`DEV_UI_027_ok` (params `{ note: 'no tab pages on disk' }` when no tab strip exists)
- **err**: label=`DEV_UI_027_err`, params=`{ file, reason }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`, autoFixable: false
- **solution** (mandatory on err): "Detail-page tabs must mount the `<TabStrip>` primitive around the `role=\"tab\"` triggers — it owns the two-layer structure and replaces the horizontal scrollbar with chevron nudge arrows. The legacy inline `-mb-px` two-layer markup stays accepted. Re-run `scaffold-component --overwrite` for the affected page (after scaffold-ui-primitives) to regenerate the correct markup."

### DEV-UI-028 — Business i18n namespaces register through the SDK-surviving channel
- **Severity**: err (a business namespace with a locale bundle on disk but not
  registered), ok otherwise.
- Check: for every namespace discovered under `src/i18n/locales/<locale>/<ns>.json`
  that is NOT an SDK-core / public namespace (`common`, `navigation`, `auth`,
  `admin`, `translation`, `errors`, `validation`, `vitrine`, `login`), require
  that `src/extensions/moduleResources.generated.ts` registers it — i.e. the
  namespace appears as an `addClientResources(locale, { <ns>: … })` object key
  (bare or quoted for dashed module codes). A missing file flags ALL business
  namespaces at once.
- The bug it catches: the `@atlashub/smartstack` SDK boots its OWN i18next
  instance and its `init()` REPLACES the resource store, so a namespace loaded
  any other way (e.g. a parallel instance in `src/i18n/index.ts`, imported before
  the SDK) is clobbered and its pages render RAW KEYS at runtime — even though
  DEV-UI-004 passes (every JSON file and key is present on disk). The only
  surviving channel is `addClientResources` called AFTER the SDK init;
  `aggregate-component-registry` emits exactly that into
  `moduleResources.generated.ts` and imports it at the end of
  `componentRegistry.generated.ts`. This rule is the runtime-channel complement
  to DEV-UI-004's on-disk check.
- **Heal**: re-run `aggregate-component-registry --project-path "<web-root>"`
  (re-emits the file from the on-disk locale bundles), then re-audit.
- **Zero-namespace hardening**: "no business namespace on disk" is ok ONLY when
  no business page exists either. When business pages
  (`*(List|Detail|Form|Dashboard|Create|Edit)Page.tsx`) are on disk with ZERO
  business bundle, the rule **errs** (params `{ namespaces: 'none',
  businessPages }`, fixSkill `frontend-component`, not auto-fixable): the locale
  JSON was never scaffolded, or landed in a phantom `web/<app>-web/src/i18n`
  tree (pre-5.11 scaffold-component bug — `ss upgrade` relocates it). Pre-fix,
  this exact state self-neutralized the rule and shipped raw-key pages green.
  Home/hub and unclassified pages don't count (a vitrine-only app stays ok).
- **ok**: label=`DEV_UI_028_ok` (params `{ note: 'no business i18n namespaces on disk' }` when none exist AND no business page)
- **err**: label=`DEV_UI_028_err`, params=`{ namespace, file }` (per unregistered namespace), `{ missing, namespaces }` (file absent) or `{ namespaces: 'none', businessPages }` (business pages without any bundle)
- **fixSkill**: `frontend-routes` (`frontend-component` for the zero-namespace case), **fixPhaseKey**: `frontend`, autoFixable: true (except zero-namespace: false)

### DEV-UI-029 — Generated i18n bundles carry NO untranslated placeholder
- **Severity**: err (any locale value is a `[xx] …` placeholder), ok otherwise.
- Check: read every generated bundle under `src/i18n/locales/<locale>/<ns>.json`
  (fallback `src/locales/<locale>/<ns>.json`), walk all string values, and flag
  any value matching `^\s*\[(fr|en|it|de)\]\s` — the abandoned "author FR, defer
  the rest" convention (`"[en] Absences"`). One `err` per offending file (with a
  `count` + a `sample` of up to 3 offending `key="value"` pairs).
- The bug it catches: `ba-create-prd` authored only FR and wrote `[xx] <fr>`
  placeholders for `en/it/de`; there is NO downstream translation pass, so
  `scaffold-component` copies the placeholder verbatim OVER the always-translated
  floor and it ships to the end user as a raw marker. DEV-UI-004 passes (the file
  and key exist) and DEV-UI-028 passes (the namespace is registered) — only the
  VALUE is wrong, which is why this rule scans values, not structure.
- **Not auto-fixable**: translation is a judgment call. Heal by authoring the real
  translations at the source — run `/ba-translate-prd` to backfill the module's
  pagespec `i18nKeys` in place, then re-run the Frontend phase to regenerate the
  bundle (or fix `i18nKeys` in `/ba-create-prd`). The upstream gate is
  `/ba-audit-prd` PRD-089, which blocks the PRD GO on the same placeholders.
- **ok**: label=`DEV_UI_029_ok` (params `{ note: 'no i18n locales on disk' }` when none exist)
- **err**: label=`DEV_UI_029_err`, params=`{ file, count, sample }` (per offending bundle)
- **autoFixable**: false (route to `/ba-translate-prd`)

### DEV-UI-030 — List pages drive pagination/search/sort SERVER-side
- **Severity**: err (a `*ListPage.tsx` paginates client-side), ok otherwise.
- Check: for each `*ListPage.tsx`, pass when it runs the DataTable in `serverMode`
  (page/size/search/sort AND the debounced filter map sent to `use{Plural}(...)`,
  `PaginatedResult.totalCount` read back) — on BOTH strata, without exception.
  The historical exemption for the integration-mode filtered list is GONE: the
  integration `GetAll` binds one `[FromQuery]` param per pagespec filter and the
  generated `GetAllAsync` applies the predicates (`lib/page-spec-filters.ts`
  contract on scaffold-business + scaffold-controller), so a client-filtered
  page (`onFilterChange` without `serverMode`) is always a stale scaffold — err.
  A page with neither `serverMode` nor filters fetches only the first page and
  paginates/searches those rows in the browser — err.
- The bug it catches: the reported "la pagination n'est jamais serveur" — the list
  page called `use{Plural}()` with no args, the API's default `pageSize=20`
  applied, and the DataTable paginated those 20 rows in memory: « 1-20 sur 20 ·
  Page 1/1 » with 33 rows in base, 13 unreachable; search and filters matched
  only the loaded page. The exemption this rule used to carry is exactly what
  let all 29 client lists ship that way.
- Remediation is LOCKSTEP: regenerate the page (`scaffold-component --overwrite`)
  AND the entity backend (`scaffold-business` + `scaffold-controller`) so the
  filter params exist on the wire — a regenerated frontend against a stale
  backend posts params ASP.NET silently ignores (see DEV-API-017).
- **ok**: label=`DEV_UI_030_ok` (params `{ note: 'no list pages on disk' }` when none exist)
- **err**: label=`DEV_UI_030_err`, params=`{ file, reason }` (per offending page)
- **fixSkill**: `frontend-component`, **autoFixable**: false (regenerate via `scaffold-component --overwrite`)

### DEV-UI-031 — 360 related tabs are rendered, permission-gated and FK-filtered
- **Severity**: err (a declared related tab is missing / unguarded / unfiltered),
  warn (a table/cards tab rendered the fail-open `createdAt` fallback column), ok otherwise.
- **Requires**: `--module-path` pointing at `.smartstack/ba/<APP>/<MODULE>` (same
  pagespec plumbing as `audit-dev-api`). Skipped (`ok` + `note`) when not supplied.
- **Why**: this rule guards the 360 view END-TO-END. The canonical schema of
  `relatedTabs[]` is `lib/page-spec-related-tabs.ts` (`parseRelatedTabs` normalises
  BA aliases and fills the `permission` / `labelKey` defaults); its invariant is that
  `relationFk` is propagated VERBATIM from the pagespec to the page's hook call to the
  backend `[FromQuery] Guid?` filter (`DEV-API-019` is the backend half). A detail page
  that drops a tab, its guard or its FK key silently breaks the 360 view: a missing
  guard fetches data the viewer cannot read, a missing FK key lists EVERY related
  record instead of the ones in relation.
- Check: for each detail pagespec (`pagespecs/{Entity}.detail.md` fenced ```json block
  with `view: "detail"`) declaring a non-empty `relatedTabs[]`, parse the tabs via
  `parseRelatedTabs` (each Zod-rejected entry is a BLOCKING err — never silently
  dropped), locate the generated `{Entity}DetailPage.tsx` from the page inventory
  (entity + kind match; the pagespec's module segment disambiguates cross-module
  homonyms; a missing page is ONE err for the whole spec), then per tab:
  - (a) **err** — the page contains the tab's anchor per its RESOLVED placement
    (`lib/page-spec-related-tabs.relatedTabPlacementOf` — never re-encoded): a
    STRIP-placed tab must carry its trigger `id="tab-{key}"`; a BAND-placed tab
    (`placement: 'band'`, the derived default for `displayMode: 'summary'`) must
    carry its always-mounted cartouche wrapper `data-testid="related-band-{key}"`.
    A page generated BEFORE the band existed fails here for its summary tabs —
    intended: the heal IS the re-scaffold (the cartouche row replaces the strip
    trigger). When the anchor is absent the tab is not rendered at all: ONE
    finding, checks (b)/(c)/(d) are short-circuited to keep the report readable.
  - (b) **err** — the page contains `<PermissionGuard permission="{tab.permission}">`
    (the NORMALIZED permission, default `{relatedModule}.{relatedSection}.read`) at
    least ONCE. The scaffolder emits it twice (trigger + panel); the single-occurrence
    check is the documented choice — it stays robust when two tabs share the same
    permission (two FK-filtered tabs onto the same related section).
  - (c) **err** — the page fetches through the FK filter:
    `use{RelatedPlural}({ …, {relationFk}: relatedId` (regex; `relatedPluralOf()` from
    the lib names the hook). Applies to all three display modes — table/cards use
    `{ page, pageSize, {relationFk}: relatedId }`, summary uses
    `{ page: 1, pageSize: 1, {relationFk}: relatedId }`.
  - (d) **warn** — a table/cards tab whose file carries the fail-open fallback label
    key `detail.related.{key}.columns.createdAt` was generated WITHOUT
    `relatedTabsData` (single `createdAt` column). Warn, not err: the tab works but
    shows no business column. (A tab that legitimately declares a `createdAt` column
    emits the same key — acceptable warn-level noise, documented.)
  - (e) **err** — ROUTE FAMILY (the sub-view mis-routing guard), armed ONLY when the
    pagespec tab carries `relatedRouteFamily`: the camelised family must exist in the
    target module's generated `src/extensions/*-{relatedModule}Routes.ts`
    (`lib/routes-registry.parseRoutesFamilies`), and — when the tab navigates at all
    (`summary`, or `withCreate`/`withRowOpen` not both explicitly false) — the page
    must reference `routes.{family}.*`. A tab wired onto another family (typically
    the porteur's menu section) mis-routes every click: "créer un lot" opened
    "Créer Project" on AtlasHub. Legacy tabs WITHOUT the field are not
    family-checked — hand-fixed pages must not be flagged against an incomplete
    pagespec; backfill `relatedRouteFamily` to arm the check.
- A detail pagespec with no `relatedTabs` → rule not applicable (`ok` + note).
- **ok**: label=`DEV_UI_031_ok`, params=`{ count: <tabs checked> }` (or a `note` when skipped / not applicable)
- **err**: label=`DEV_UI_031_err`, params=`{ file, pagespec, entity, tab, reason }` (per tab per failed check; `{ missingPage: true }` / `{ tabIndex, issues }` variants)
- **warn**: label=`DEV_UI_031_warn`, params=`{ file, pagespec, entity, tab, reason }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`, **autoFixable**: false
  (fix = re-run `scaffold-component` for the detail page with the pagespec +
  `relatedTabsData` — it emits trigger, guards and FK-filtered hook calls by construction).

### DEV-UI-032 — detail-page action completeness (edit + delete + header actions)
- **Severity**: err (a detail page is missing an expected action button, or the whole
  detail page is absent), ok otherwise.
- **Requires**: `--module-path` (same pagespec plumbing as DEV-UI-031). Skipped
  (`ok` + `note`) when not supplied.
- **Why**: orthogonal to DEV-UI-031 (which covers the *tabs* of the 360 view). This
  rule covers the *actions*: every detail page must render its **Edit** and **Delete**
  buttons plus one anchor per **header-scoped** custom action — the entity-level
  actions the generator renders on the detail page. `scaffold-component` emits a stable
  `detail-action-<code>` anchor for each (edit/delete are the literal
  `detail-action-edit` / `detail-action-delete` buttons): a PROMOTED custom action
  (Priority+ visible-budget) carries `data-testid="detail-action-<code>"` on its
  button, an OVERFLOWED one carries `testId: 'detail-action-<code>'` on its
  `HeaderActionsMenu` item (forwarded onto the DOM `data-testid`). The check is
  invariant across i18n and refactors, and the anchor doubles as the smoke/UAT
  click selector.
- Check: for EVERY detail pagespec (not only those with relatedTabs), the expected anchor
  set is `{edit, delete}` ∪ `{code | action.scope === 'header' ∧ code ∉ STANDARD_CRUD}`.
  Locate the generated `{Entity}DetailPage.tsx` (same inventory resolution as DEV-UI-031;
  a missing page is ONE err). For each expected anchor, assert the source contains
  `data-testid="detail-action-<anchor>"` OR `testId: 'detail-action-<anchor>'` (the
  overflow-menu shape). **Row/bulk actions are NOT expected on the
  detail page** — they render in the list row by SmartStack convention.
- **Boundary vs neighbours**: DEV-UI-031 = tabs (rendered + guarded + FK-filtered);
  DEV-UI-032 = actions (present + anchored). `audit-dev-actions-alignment` ACTION-DRIFT-006
  only proves a hook exists on *some* page — 032 is the detail-page-specific presence gate.
- **ok**: label=`DEV_UI_032_ok`, params=`{ count: <anchors checked> }` (or a `note` when skipped / no detail pagespec)
- **err**: label=`DEV_UI_032_err`, params=`{ file, pagespec, entity, action, reason }` (per missing anchor; `{ missingPage: true }` variant)
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`, **autoFixable**: false
  (fix = re-run `scaffold-component` for the detail page — it emits edit/delete +
  `detail-action-<code>` header buttons by construction; never hand-add a button).

### DEV-UI-033 — FK Guids never surface on List / Detail / read-first Form pages
- **Severity**: err (raw Guid reaches the user), warn (legacy Guid *fallback*), ok otherwise.
- **Why**: sibling of DEV-UI-022 (form controls). A FK value shown to the user must be
  the target's display label — never the raw Guid (test-RH post-mortem: the
  `statutId` column, filter and detail row all printed the Guid because ONE
  `fkTo` derivation was dropped by the orchestrator).
- **Detection** (idioms scaffold-component emits or used to emit), on `list` /
  `detail` / `form` pages, whitelist `externalId`/`parentId`/`guidId`:
  - `list-column` (err): a `{ key: 'xxxId', … }` column object with NO `render:` —
    the DataTable prints the raw cell value.
  - `filter-input` (err): `<input … value={filters['x']}>` on a REFERENCE filter —
    a free-text filter over a Guid can never match; it must be a server-searching
    `<EntityLookup>`. Two independent signals, so the leg works with or without the
    BA tree on hand: the key is FK-shaped (`…Id`), **or** the module's list pagespec
    declares that filter as a reference (`control: "lookup"`, `fkTo`, or the BA
    alias `entity` — pass `--modulePath`). The second signal is what catches a
    filter authored under the RELATION name (`filters['client']`), which carries no
    `…Id` hint and used to pass every audit while filtering nothing.
    Heal: backfill the pagespec with `ba-develop/cli/derive-filter-fks`, then
    re-scaffold. Authoring gate upstream = PRD-113.
  - `detail-raw` (err): a `<dd>` line rendering exactly `{String(data.xxxId ?? '')}`.
  - `form-raw` (err): the read-first grid's own-line value (FormPages AND unified-fiche DetailPages)
    `{String(formData.xxxId ?? '')}` — the READ grid is a display surface too; it
    must resolve the displayName through the target's `use{Target}Lookup` hook.
  - `guid-fallback` (warn): the pre-2026-07 resolved idiom
    `…?.displayName ?? String(item|data.xxxId ?? '')` — resolves, but leaks the Guid
    when unresolved (the current generator masks with an em dash).
- **ok**: label=`DEV_UI_033_ok` (or a `note` when no list/detail/form pages exist)
- **err**: label=`DEV_UI_033_err`, params=`{ file, field, entity, shape }`
- **warn**: label=`DEV_UI_033_warn`, params=`{ file, field, entity, shape: 'guid-fallback' }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`, **autoFixable**: false
  (fix = derive the field's `fkTo` via ba-develop's `derive-fk-specs` CLI and re-run
  `scaffold-component` — never hand-patch the page).

### DEV-UI-034 — Coded entities never expose an editable Code form input
- **Severity**: err (an editable `code` input on a coded entity's form), ok otherwise.
- **Why**: frontend sibling of DEV-API-022 (the backend coded-entities seam) and
  audit net under scaffold-component's coded-entity validation gate. An entity
  whose `entité.md` block declares `- **Code pattern** : …` has its Code
  engine-allocated at insert (`ICodedEntity` / `CodedEntitySaveHandler`) — a form
  input on it ships a value the engine ignores, or worse a hand-typed pseudo-code.
- **Signal-driven, never name-driven**: the coded-entity set comes from the
  `**Code pattern**` lines of `entité.md` (requires `--module-path`; skipped with
  a note otherwise). An entity WITHOUT a code pattern (user-typed referential
  code, e.g. TypeAffaire) keeps its editable `code` untouched.
- **Detection**: on `form`/`create`/`edit` pages of coded entities, the editable
  idiom every generated control emits — `onChange('code', …)`. The
  display-only rendering (readonly/isComputed) never emits it, and list filters
  go through `onFilterChange` — no false positive on readonly or list pages.
- **Near-miss leg** (the green-by-vacuity killer): a `Code pattern` MENTIONED
  in entité.md in an unparsable shape (table cell, wrong casing, unbolded
  bullet, declarative prose — `lib/code-pattern-grammar.findCodePatternNearMisses`,
  glob `**/entité.md` so section-level docs count) is an **err**
  `reason: 'near-miss-declaration'` — the rule cannot even KNOW the entity is
  coded, so an editable input would ship unflagged. The true zero keeps the
  mute ok note.
- **Supplied relaxation**: an entity whose line declares `surchargeable à la
  création` is SKIPPED (ok with a note) — the create-only SmartCodeField is
  the sanctioned seam, and edit immutability stays guaranteed server-side
  (the Update DTO carries no Code). The near-miss leg is never relaxed.
- **ok**: label=`DEV_UI_034_ok`, params=`{ count }` (or a `note` when skipped /
  no coded entity / no form page / supplied pages skipped)
- **err**: label=`DEV_UI_034_err`, params=`{ file, entity, shape: 'editable-code-input' }`
  or `{ file, line, shape, reason: 'near-miss-declaration' }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`, **autoFixable**: false
  (fix = fix the pagespec — remove `code` from the form fields or set
  `readonly: true` — and re-run `scaffold-component` with the pagespec `codedEntity` flag (boolean or enriched object, verbatim);
  never hand-patch the page).

### DEV-UI-035 — No parallel i18next init in app src/
- **Severity**: err (any app file inits i18next), ok otherwise.
- **Why**: the `@atlashub/smartstack` SDK boots the SHARED i18next singleton and
  i18next's `init()` REPLACES the resource store. Any file under the app's
  `src/` that imports `i18next` and calls `.init(` therefore destroys every
  bundle registered through `addClientResources`
  (`moduleResources.generated.ts`) — business pages render RAW KEYS while the
  shell stays translated (the SDK reloads its core namespaces asynchronously
  AFTER the clobber). The pre-5.6 `ss init` scaffold shipped exactly such a file
  at `src/i18n/index.ts`; DEV-UI-028 cannot see it (every registration check
  passes — the store is destroyed at runtime, after registration).
- **Detection**: scan `src/**/*.{ts,tsx}` (tests excluded); flag any file where
  `from 'i18next'` (quote before the name — `react-i18next` never matches) AND
  `.init(` both appear. The recognisable legacy template (`initReactI18next`
  wired, no `addClientResources`) is `legacyTemplate: true` and auto-fixable —
  the in-place fixer (`neutralizeParallelI18nInit`, lib/frontend-fixers.ts)
  replaces the whole file with the no-op `export {};` seam (byte-identical to
  the `ss init` template, so the file-tracker reads it as unchanged). A custom
  init is flagged but NOT auto-fixable (its resources must be ported to
  `addClientResources()` by hand).
- **Heal (fleet)**: `ss upgrade` applies the same rewrite via the
  `i18n-neutralize-parallel-init` code migration (with a `.bak` backup).
- **ok**: label=`DEV_UI_035_ok`
- **err**: label=`DEV_UI_035_err`, params=`{ file, shape: 'parallel-i18next-init', legacyTemplate }`
- **fixSkill**: `frontend-routes`, **fixPhaseKey**: `frontend`,
  **autoFixable**: true when `legacyTemplate`, false otherwise.

### DEV-UI-036 — Dashboards inherit the theme (no hardcoded dataviz colors)
- **Severity**: err (any shape below), ok otherwise (or a `note` when the app has
  no dashboard surface on disk).
- **Why**: a dashboard is the one surface where colour is DATA, and the one place
  a hardcoded palette survives every other net. Chart colours reach Recharts as
  real colour strings through JSX props (`fill="#3b82f6"`, `stroke={COLORS[i]}`)
  or a palette array — **never through className** — so the className-scoped
  colour rules (and ui-polish R2/R14/R15/R18/R19) all stay silent while the chart
  renders off-brand and ignores dark mode. The sanctioned channel is
  `scaffold-theme` → `--dataviz-1..8` (categorical), `--chart-grid/-axis/-tooltip-bg/-tooltip-text`
  (chrome), `--kpi-value/-label/-trend-up/-trend-down` (KPI), read at runtime by
  `useDatavizPalette()` inside `ChartCard.tsx` (the hook exists precisely because
  Recharts cannot resolve `var()` in an SVG fill). The palette itself resolves the
  **platform theme first** — `--dataviz-cat-1..12`, written live on `<html>` by the
  `@atlashub/smartstack` theme runtime from the tenant's UI configuration — then the
  scaffolded `--dataviz-1..8` (each of which is emitted as
  `var(--dataviz-cat-N, <accent-derived>)`), then the accent ramp.
- **Scope**: `*DashboardPage.tsx` (inventory `kind === 'dashboard'`) +
  `src/components/dashboard/**/*.tsx`. `ChartCard.tsx` and `useDatavizPalette.*`
  are exempt — the first IS the sanctioned wrapper, the second owns the
  documented last-resort constant.
- **Shapes**:
  - `theme-missing-dataviz-tokens` — the app renders dashboards but no stylesheet
    defines `--dataviz-1` **nor** `--dataviz-cat-1`. The hook then falls back
    (accent ramp, then a built-in palette) and every chart stops following the
    theme. Typical cause: `src/index.css` carries `/* @customised */`, so
    `scaffold-theme` skips it and the block never lands. Fix = re-run
    `scaffold-theme` (or paste the `--dataviz-*` block by hand). Scan covers
    `src/**/*.css` **and** the installed package stylesheet
    (`node_modules/@atlashub/smartstack/{,dist/}theme.css` — probed by path, since
    `findFiles` ignores `node_modules`). Severity is **warn, not err, when the
    package is installed**: its theme runtime (`ThemeContext.applyDataViz`) writes
    `--dataviz-cat-1..12` as INLINE styles on `<html>` from the tenant's UI
    configuration, which no static CSS scan can see — the gate never blocks on an
    absence it cannot prove.
  - `hardcoded-dataviz-color` — a colour literal (`#hex`, `rgb()`, `hsl()`) inside a
    dashboard page or primitive.
  - `raw-recharts` — `recharts` imported outside `ChartCard.tsx`, i.e. a second,
    untokenised chart path. Fix = render through `<WidgetRenderer>` / `<ChartCard>`,
    or extend `ChartCard` when a chart type is missing.
  - `stale-dashboard-contract` — the removed `/dashboard/consolidated` +
    `consolidated.metrics` / `getDashboardConsolidated` shape (no backend ever
    served it). The live contract is ONE endpoint per dashboard:
    `GET /api/screens/{slug}/dashboard → { widgets: Record<widgetKey, WidgetResult> }`.
- **ok**: label=`DEV_UI_036_ok`, params=`{ files }` (or `{ note }`)
- **err**: label=`DEV_UI_036_err`, params=`{ shape, file, line?, literal?/match? }`
- **fixSkill**: `frontend-theme` (theme shape) / `frontend-dashboard` (colour +
  recharts shapes) / `frontend-component` (stale contract), **fixPhaseKey**:
  `frontend`, **autoFixable**: false — re-scaffolding is the fix, and mapping a
  literal onto the right token is a design decision.
- **Sibling**: ui-polish `R28-dataviz-hardcoded-colors` enforces the same contract
  page-by-page through `/ui-components`.

### DEV-UI-037 — No field label left as the humanised property name
- **Severity**: warn (heuristic — a word can legitimately be identical across
  locales), ok otherwise (or a `note` when the app has no locale bundles).
- **Why**: `scaffold-component`'s field-label floor is `humanize(<property name>)`,
  emitted **byte-identical in fr/en/it/de** whenever the pagespec's `i18nKeys`
  authored no `form.fields.*` / `detail.fields.*` / `list.columns.*` override —
  an English "Scope" label on a French form. The leak carries no `[xx]` marker,
  so DEV-UI-029 is blind to it; DEV-UI-004/028 (file exists / namespace
  registered) pass too. PRD-106 blocks it upstream at authoring time; this rule
  is the downstream net on the SHIPPED bundles.
- **Signal**: a `*.form.fields.*` / `*.detail.fields.*` / `*.list.columns.*`
  leaf (one segment under the field parent — placeholders/options/chrome
  excluded) whose `fr` value === `en` value === `humanize(leaf)`, and whose leaf
  is not in the locale-invariant allowlist (`description`, `code`, `email`,
  `notes`, `budget`, `organisation`, `total`, `type`, `date`, `client`,
  `contact`, `service`, `section`, `action`, `question`).
- **ok**: label=`DEV_UI_037_ok` (params `{ note }` when no locales/fr on disk)
- **warn**: label=`DEV_UI_037_warn`, params=`{ file, count, sample }` (per fr bundle)
- **fix**: author the override in the pagespec's `i18nKeys` (all 4 locales — the
  PRD-106 contract) and regenerate; or extend the allowlist for a genuinely
  locale-invariant word. **autoFixable**: false — translation is a judgment call.

### DEV-UI-038 — Module-wide i18n catalogue completeness
- **Severity**: err — all three checks are EXACT (no heuristics, unlike 037), ok
  otherwise (or a `note` when no locales / no business namespaces exist).
- Check, over business pages (list/detail/form/dashboard/create/edit with an
  entity) × business namespaces (minus the SDK set), bundles under
  `src/i18n/locales/<locale>/<ns>.json` (fallback `src/locales/…`):
  1. every **bare `t('key')`** (no `defaultValue`) must resolve in ALL 4 locale
     bundles — a missing key renders as the raw string in that locale;
  2. no called key may resolve to an **OBJECT** — the label/children collision
     (`"x": "label"` vs `"x": { child }`): i18next renders the raw key, and
     behind a `defaultValue` the call silently degrades (camelCase param names
     in action dialogs) — flagged even on `defaultValue` calls;
  3. the four locales' **key SETS** must be identical per namespace —
     `fallbackLng` masks drift as wrong-language text on screen.
- The bug it catches: the AtlasHub PROJECTS/BUDGETS incident — a 7-entity
  module shipped 144 keys for 440 called (sibling entities' keys destroyed in
  the shared module bundle by later scaffolds), with PRD labels reverted to the
  floor. `validate-page i18n-keys-resolve` runs checks 1+2 PER PAGE at scaffold
  time; this rule is the module-wide net over the ASSEMBLED bundles (hand
  edits, partial regenerations, lost merges). Shares its extraction/resolution
  with validate-page via `lib/i18n-keys.ts`.
- **ok**: label=`DEV_UI_038_ok` (params `{ pages, namespaces }`, or `{ note }`)
- **err**: label=`DEV_UI_038_err`, params=`{ file, missing, objects, sample }`
  (per offending page — checks 1+2) or `{ namespace, drift, sample }` (per
  drifting namespace — check 3)
- **fixSkill**: `frontend-component`, **autoFixable**: false — heal by
  re-invoking `scaffold-component` for the offending entity × view (the
  emission re-fills all 4 locales in parallel and preserves sibling entities),
  then `aggregate-component-registry`; locale-parity residue from hand-added
  keys is translation judgment (author the pagespec `i18nKeys` or
  `/ba-translate-prd`).

### DEV-UI-039 — List pages stay readable (column budget + FilterBar)
- **Severity**: err (any violation), ok otherwise (or a `note` when no list
  pages exist).
- Check, over every `*ListPage.tsx`:
  1. **Column budget** — the DEFAULT-VISIBLE column count must be ≤ 7: count
     the `columns` literal entries (the `sortable:` field pins the match — the
     visibility literal has none, the actions column has a `render` instead),
     minus the keys the `columnVisibilityDefaults` literal declares
     `defaultVisible: false`. A page with NO visibility literal counts every
     column — that IS the stale shape (pre-budget scaffold or hand edit).
  2. **Filter wall** — ≥ 4 `onFilterChange(` call sites WITHOUT an
     `@/components/ui/FilterBar` import: the flat pre-progressive-disclosure
     toolbar ("page entière de filtres").
- The bug it catches: the AtlasHub invoice portfolio — 22 columns rendered at
  every desktop width + 12 flat filter inputs. The scaffolder now budgets the
  default table (pagespec `columns[].priority`, first-7 fallback, ColumnPicker
  for the rest) and collapses filters behind « Plus de filtres » (pagespec
  `filters[].tier`, cap 3 primary) — an offender predates that or was
  hand-edited.
- **ok**: label=`DEV_UI_039_ok` (or `{ note: 'no list pages on disk' }`)
- **err**: params=`{ file, visibleColumns }` (budget leg) or
  `{ file, filterCallSites }` (filter-wall leg)
- **fixSkill**: `frontend-component`, **autoFixable**: false — re-run
  `scaffold-ui-primitives` (FilterBar/ColumnPicker/useColumnVisibility must
  exist) then `scaffold-component` for the offending entity; author
  `priority`/`tier` in the pagespec (PRD-109/110) so the visible set is a
  judgment, not declaration order.

### DEV-UI-040 — Sectioned fiches: pagespec sections[] ↔ page parity + field wall
- **Severity**: err (parity legs), warn (field wall), ok otherwise (or a
  `note` when no form/detail fiches exist).
- Sections are FIRST-ORDER pagespec data (`sections[]`, canonical schema
  `lib/page-spec-sections.ts`) rendered as SectionCard titles through
  `t('<entity>.form.section.<camel>')` — shared by the form AND the sectioned
  detail. Checks:
  1. **Missing section** (err, needs `--module-path`) — a form/detail pagespec
     declares `sections[]` but the generated page never renders the section's
     label key: the page predates the authoring or was hand-edited.
  2. **Surplus section** (err) — the page renders a `form.section.<key>`
     that neither `sections[]` nor `tabs[]` nor the `uiDesign` overlay knows:
     the spec is behind the page. Only armed when the pagespec carries
     `sections[]` (legacy `field.section`/tabs pages stay silent).
  3. **Field wall** (warn) — a non-`@customised` `*FormPage.tsx` rendering
     ≥ 8 field blocks (native `form-field-*` testids + primitive controls) in
     a single `<SectionCard>`: the fiche mirror of DEV-UI-039's column wall.
     The authoring gate is PRD-112; the judgment pass is `/ui-design`.
- **ok**: label=`DEV_UI_040_ok` (or `{ note: 'no form/detail fiches on disk' }`)
- **err**: params=`{ file, pagespec, sectionKey, shape: 'missing-section'|'surplus-section' }`
- **warn**: params=`{ file, fieldBlocks, sectionCards, shape: 'field-wall' }`
- **fixSkill**: `frontend-component`, **autoFixable**: false — author
  `sections[]` in the pagespec (PRD-111/112) then re-run `scaffold-component`
  for the entity×view (`scaffold-ui-primitives` must have emitted
  SectionCard.tsx first).

### DEV-UI-041 — No dead-mockup chrome; pills through <Badge>
- **Severity**: err (dead navigation / dead KPI / inlined pill), warn
  (unwired StatCard), ok otherwise (or a `note` when no pages exist).
- The pre-live hub pages shipped a LIVE-LOOKING mockup (KPI value `—`
  hardcoded, quickLinks `navigate('#')`) and the pre-Badge pages inlined the
  status-pill markup (non-themable, invisible to the token audits).
  scaffold-component now wires count widgets to the entity list hook
  (StatCard + `totalCount`), resolves quickLinks against the module routes
  (or OMITS them with a generation warning) and emits the `<Badge>`
  primitive. Checks, on every non-`@customised` page:
  1. **Dead navigation** (err) — `navigate('#')`.
  2. **Dead KPI** (err) — the legacy `text-2xl font-bold …>—<` card div.
  3. **Inlined pill** (err) — `inline-flex items-center px-2 py-0.5
     rounded-full` markup instead of `<Badge>`.
  4. **Unwired StatCard** (warn) — `value="—"`: the widget spec was not
     deterministically wirable (foreign entity, field aggregation, chart/list
     type on a hub page); author a wirable widget or take the page bespoke.
- **ok**: label=`DEV_UI_041_ok` (or `{ note: 'no generated pages on disk' }`)
- **err/warn**: params=`{ file }`
- **fixSkill**: `frontend-component`, **autoFixable**: false — re-run
  `scaffold-ui-primitives` (Badge/StatCard/Skeleton/EmptyState) then
  `scaffold-component` for the offending entity.

### DEV-UI-042 — List KPI stat row: pagespec stats[] ↔ page parity
- **Severity**: err (missing row), ok otherwise (or a `note` when no list
  pagespec declares `stats[]` / no `--module-path`).
- `list.stats[]` renders as a StatCard row above the FilterBar (marker
  `data-testid="list-stats-row"`), one server count per wired stat
  (`use{Plural}({page:1,pageSize:1,…}) → totalCount`). A list pagespec
  declaring `stats[]` whose generated `*sListPage.tsx` carries no row predates
  the authoring (or was hand-edited) — the BA's KPI judgment silently
  disappeared. Needs `--module-path` (reads `pagespecs/*.md`); `@customised`
  pages exempt. Authoring gate upstream = PRD-115 (≤ 4 stats, labelled 4
  locales, wirable filter fields); wiring contract = `PageStatMinSchema`
  (scaffold-component `types.ts`).
- **err**: params=`{ file, pagespec, stats }`
- **fixSkill**: `frontend-component`, **autoFixable**: false — re-run
  `scaffold-ui-primitives` (StatCard) then `scaffold-component` for the
  entity's list view.

### DEV-UI-043 — Detail summary band: pagespec summary ↔ page parity
- **Severity**: err (missing band), ok otherwise (or a `note`).
- `detail.summary` (or `uiDesign.detail.summary`) renders as a header band
  above the detail body (marker `data-testid="detail-summary"`): title value,
  status Badge, meta pairs through the shared read-value machinery. A detail
  pagespec declaring one whose generated `*DetailPage.tsx` carries no band
  predates the authoring. Needs `--module-path`; `@customised` exempt.
  Authoring gate = PRD-119 (fields name real entity attributes, ≤ 4 meta).
- **err**: params=`{ file, pagespec }`; **fixSkill**: `frontend-component`.

### DEV-UI-044 — Lifecycle parity: pagespec lifecycle ↔ generated form
- **Severity**: err (missing guards / leaky create payload), ok otherwise (or a `note`).
- A form pagespec carrying a first-order `lifecycle` block
  (lib/page-spec-lifecycle.ts) whose phases OWN fields must have its generated
  `*FormPage.tsx` compile the guards: (a) one `phase:<key>` marker comment per
  owned phase (proof the create-exclusion compiled — the create form no longer
  asks the payment date), (b) `toCreatePayload` omitting every phase-owned
  field (the wire-level half — without it the create POST carries the
  later-phase value), (c) the compiled status chain over
  `formData.<statusField>` when the phase declares `statuses` and the
  statusField is on the form. A page missing them predates the lifecycle
  authoring (or was hand-edited). Needs `--module-path`; `@customised` exempt.
  Authoring gates = PRD-120/121 (`derive-lifecycle --mode check`); heal =
  re-scaffold the entity's form — the guards are generator-emitted, never
  hand-added.
- **err**: params=`{ file, pagespec, phase[, field] }`; **fixSkill**: `frontend-component`.

### DEV-UI-045 — No stacked read grid + raw edit form outside SectionCard
- **Severity**: err (a non-`@customised` form/detail page shows the read `<dl>`
  grid AND ≥3 editable controls without the SectionCard read-first pattern),
  ok otherwise.
- Check: for each `*FormPage.tsx` / `*DetailPage.tsx`, err when the source has
  a `<dl>` + `form.fields.*`/`detail.fields.*` labels AND ≥3 editable controls
  (`input/textarea/select/DateInput/EnumSelect/MultiSelect/EntityLookup/Switch/
  SegmentedControl`) AND no `editingSections.has(` (the exclusive per-section
  toggle). The generator cannot emit this shape — its read and edit grids are
  branches of ONE ternary — so an offender is a hand-edit or an auto-heal
  improvisation, typically born from a MISSING SectionCard primitive
  (`ss upgrade` never re-runs scaffold-ui-primitives; scaffold-component now
  fail-closes on missing primitives up front).
- The bug it catches: the « formulaire empilé sous la fiche » a client observed
  on 5.14-generated pages — the read view stayed AND a full duplicate form
  stacked below it, instead of the fields opening in place.
- **ok**: label=`DEV_UI_045_ok` (params `{ note }` when no form/detail pages)
- **err**: label=`DEV_UI_045_err`, params=`{ file, editableControls }`
- **fixSkill**: `frontend-component`, **autoFixable**: false — re-run
  scaffold-ui-primitives (SectionCard ships there), then re-scaffold the entity.

### DEV-UI-046 — Every registry componentKey is REACHABLE (routability)
- **Severity**: err (a registered componentKey resolves to no route), ok
  otherwise; ok + note when no nav-seed source is available.
- Check: for every componentKey of the registry (via `lib/registry-index`),
  strip the last segment when it is an IMPLICIT_SUFFIX (`lib/implicit-suffixes`
  — the DynamicRouter table); the remaining node chain
  `{app}[.{module}[.{section}[.{resource}]]]` must match a SEEDED navigation
  node. Node source: the committed `.smartstack/core-seed/{app}.state.json`
  snapshots (primary — `--core-seed-state-dir`, or probed from the project
  ancestors), falling back to parsing `*CoreNavigationSeedDataProvider.cs`
  under `--backend-path`.
- The bug it catches: the chain produces TWO rival 4th levels — pagespec
  `routeFamily`/`routeParent` (Phase 3b, scaffold-routes) and
  `core.nav_Resources` (Phase 0, scaffold-core-seed from the BA menu, which by
  ba-create-menu doctrine declares no resource for sibling listings). Nothing
  joined them: on the incident project 21/52 registered keys (40%) fell into
  `ProtectedCatchAll` — a silent redirect to `/applications` that reads as
  lost permissions — while every audit said PASS.
- **ok**: label=`DEV_UI_046_ok` (params `{ count, navSource }`, or `{ note }` on skip)
- **err**: label=`DEV_UI_046_err`, params=`{ componentKey, node, file, navSource }`
- **fixSkill**: `backend-core-seed`, **autoFixable**: false — run
  `ba-develop/cli/derive-nav-resources` on the module, merge the derived
  resources into the core-seed spec and re-invoke scaffold-core-seed (additive
  Phase 3b closing pass); if the key itself is wrong, fix the pagespec
  `routeFamily`/`routeParent` (PRD-108) and re-run scaffold-routes.

### DEV-UI-047 — A unified-surface entity keeps no field-tab DetailPage (catch-up)
- **Severity**: warn (the emitted page contradicts `resolveEditSurface`), ok
  otherwise; ok + note when `--module-path` is unavailable or no detail
  pagespec resolves a unified surface.
- **Requires**: `--module-path` (pagespec plumbing — same gate as DEV-UI-031/040/043).
- **Why**: `lib/edit-surface.ts` is a published SSOT — an entity whose
  pagespec view-set carries `detail` AND `form` without the `direct` opt-out
  renders ONE unified fiche (own fields as section cards, the strip carries
  only the 360 tabs, `/edit` mounts the DetailPage). A page generated BEFORE
  the switch still renders its field tabs — and passed every audit: DEV-UI-031
  checks related tabs, DEV-UI-040/043/044 check declared blocks, nothing
  compared the emitted page to the surface the SSOT resolves TODAY. This is
  the generic §37 motif (a published SSOT whose consumers diverge silently)
  applied to the client's already-generated artefacts.
- Check: per `{Entity}.detail.md` pagespec — entityViews from the pagespec
  FILENAMES (`lib/detail-tab-strip.entityViewsFromPagespecFilenames`),
  editMode via `lib/ui-design-overlay.resolveEditMode` (pagespec
  `editExperience` + `uiDesign.editMode` — deliberately NOT scaffold-routes'
  `entity.directEdit` channel), surface via `lib/edit-surface.resolveEditSurface`.
  When the surface is `unified` and the emitted `{Entity}DetailPage.tsx`
  carries `id="tab-<k>"` for any k ∈ `tabs[]` keys ∪ {`info`} (minus any
  related-tab key — related triggers and band cartouches never
  false-positive) → warn. `@customised` pages are skipped.
- **ok**: label=`DEV_UI_047_ok` (or `{ note }` on skip / no unified surface)
- **warn**: label=`DEV_UI_047_warn`, params=`{ file, pagespec, triggers }`
- **fixSkill**: `frontend-component`, **autoFixable**: false — the remedy is a
  REGENERATION, not a fix: re-run scaffold-component for `{Entity}.detail`
  (and scaffold-routes so `/edit` mounts the DetailPage).

### DEV-UI-048 — Kanban board parity: pagespec `kanban` block ↔ generated list page
- **Severity**: err (legs 1-2), warn (leg 3), ok otherwise; ok + note when
  `--module-path` is unavailable or no list pagespec folds a kanban block.
- **Requires**: `--module-path` (pagespec plumbing — same gate as DEV-UI-042/043).
- **Why**: the board is a viewMode of the LIST page — the pagespec's
  first-order `kanban` block (SSOT `lib/page-spec-kanban.ts`, folded by
  `create-prd/cli/derive-kanban-spec`, PRD-135 upstream) compiles into the
  list page's board branch, and the BR Flow matrix into `ALLOWED_TRANSITIONS`.
  A page that predates the fold silently drops the BA's board (and its
  workflow governance); a surviving standalone `{Entity}KanbanPage.tsx` is the
  retired pre-fold page.
- Check, three legs over each `view: list` pagespec carrying `kanban` +
  `viewModes` ∋ `'kanban'` (coherence itself is PRD-135e's job):
  1. **err** `leg: 'board-missing'` — the generated `{Entity}sListPage.tsx`
     has no `data-testid="list-kanban-board"` → re-run scaffold-ui-primitives
     (useKanbanColumnPrefs) then scaffold-component for `{Entity}.list`;
  2. **err** `leg: 'matrix-missing'` — the board renders but the pagespec's
     non-empty `transitions[]` are not compiled (`ALLOWED_TRANSITIONS` absent)
     — the BR graph silently does not govern the drops;
  3. **warn** `leg: 'standalone-residual'` — a GENERATED (marker-carrying,
     non-`@customised`) `{Entity}KanbanPage.tsx` survives on disk while the
     standalone page is retired → delete it + its registry/route references,
     re-scaffold list + routes. Hand-written/`@customised` boards are skipped.
- **ok**: label=`DEV_UI_048_ok` (or `{ note }` on skip / nothing folded)
- **err/warn**: label=`DEV_UI_048_err|warn`, params=`{ file, pagespec?, leg, transitions? }`
- **fixSkill**: `frontend-component`, **autoFixable**: false — the remedy is a
  regeneration (or a deletion for leg 3), never a hand-fix.

### DEV-UI-049 — A cross-module 360 tab follows the tenant catalogue
- **Severity**: err; ok otherwise; ok + note when `--module-path` is
  unavailable, no detail pagespec declares `relatedTabs`, or no tab leaves its
  own application/module.
- **Requires**: `--module-path` (pagespec plumbing — same gate as DEV-UI-031).
- **Why**: DEV-UI-031 asks whether a declared tab is RENDERED and
  permission-gated. This asks a different question about the same tab. A tab
  pointing INTO another module — a fortiori another application — must also be
  gated on whether that module is part of what the CURRENT TENANT was given
  (`tenant_TenantApplications` / `TenantModules`, resolved server-side and
  served in `GET /api/navigation/menu`), or a client who never had the billing
  application still sees a *Factures* tab on every customer record.
  The nominal case has long half-worked by side effect — `PermissionService`
  filters permissions by the tenant's application catalogue, so an absent
  application loses its permissions and the `PermissionGuard` hides the tab —
  but that filter is at APPLICATION grain only (a disabled `TenantModule`
  removes the menu node and keeps the permissions), global admins bypass it
  entirely, and nothing in the generated page ever SAID the tab had that
  dependency. This rule makes it explicit and carries it to module grain.
- **Scope**: exactly the tabs `lib/page-spec-related-tabs.requiresAvailabilityGuard`
  selects — the target's `(relatedApp ?? own app, relatedModule)` differs from the
  page's own, and the pagespec does not carry `"availabilityCheck": false`. A
  same-module tab is never in scope: the page itself would be unreachable if its
  own module were missing. `@customised` pages are exempt.
- Check, four legs over each in-scope tab of a `view: detail` pagespec:
  1. **err** `leg: 'no-guard'` — the page never reads the tenant catalogue (no
     `useModuleAvailability` declaration for that target);
  2. **err** `leg: 'wrong-target'` — the page does read it, but no
     `hasModule('{relatedApp}', '{relatedModule}')` names THIS tab's target
     (typically the page's own application was passed instead);
  3. **err** `leg: 'trigger-ungated'` / `leg: 'panel-ungated'` — the flag is
     declared but the trigger (or the band cartouche), or the panel, ignores it.
     A trigger without a panel leaves a dead tab; a panel without a trigger
     leaves a body nobody can reach — and still fetches;
  4. **err** `leg: 'wrong-source'` — the page gates on `useLicense().hasModule()`.
     The faux ami: it answers what the customer BOUGHT (license scopes), not
     what this tenant HAS, and license gating was removed from the menu in
     2026-08. It compiles, and it is wrong.
- **ok**: label=`DEV_UI_049_ok`, params=`{ checkedTabs }` (or `{ note }` on skip)
- **err**: label=`DEV_UI_049_err`, params=`{ file, pagespec, entity, tab?, leg, relatedApp?, relatedModule?, flag?, placement? }`
- **fixSkill**: `frontend-component`, **autoFixable**: false — the remedy is
  scaffold-ui-primitives (it emits `src/components/ui/useModuleAvailability.ts`)
  then a re-scaffold of the detail page; to opt a tab out deliberately, author
  `"availabilityCheck": false` on the pagespec tab.

## CLI mode

A deterministic CLI sibling lives at `cli/audit-dev-frontend/index.ts`.
Invoke it via Bash to run the 44 implemented rules (DEV-UI-001..018, 022, 023, 026, 027, 028, 029, 030, 031, 032, 033, 034, 035, 036, 037, 038, 039, 040, 041, 042, 043, 044, 045, 046, 047, 048, 049
— DEV-UI-019/020/021 are spec-only, see their status notes) without LLM
variance, including auto-apply fixes for autoFixable rules:

```
npx --prefer-offline tsx skills/development/audit-dev-frontend/cli/audit-dev-frontend/index.ts \
  --project-path "<web-root>" --mode apply \
  --module-code "<modCode>" --app-code "<appCode>" \
  --prd-slice "<slice.json>" \
  --backend-path "<dotnet-root>" \
  --core-navigation-seed-path "<path/to/CoreNavigationSeedDataProvider.cs>" \
  --core-seed-state-dir "<repo-root>/.smartstack/core-seed" \
  --module-path "<.smartstack/ba/<APP>/<MODULE>>"
```

The CLI emits the same `auditReport` envelope as the system-prompt skill
plus a `report.findings[]` field for the orchestrator to consume. Both
audit modes are equivalent for rules 001-008. Some 009-022 rules read the
PRD slice; **DEV-UI-011 reads the module PAGESPECS (`--module-path`) and
optionally `--backend-path` for its frontend-only warn leg — never the PRD
slice — and DEV-UI-010/012 are retired** (see their sections). The
system-prompt mode does best-effort and can emit `ok` with a
`note: "skipping — context unavailable"`.

### CLI mode — actions-alignment audit (legacy / drift detection)

When debugging an unexpected 4xx/405 on a custom action button (e.g. a project's
synchronization action → 405), or when onboarding a legacy project that
pre-dates the per-page contract, run the dedicated alignment CLI:

```
npx --prefer-offline tsx skills/development/audit-dev-frontend/cli/audit-dev-actions-alignment/index.ts \
  --project-path "<web-root>" \
  --backend-path "<dotnet-root>" \
  --module-path  "<absolute path to .smartstack/ba/<APP>/<MODULE>>" \
  --mode report-only
```

The CLI cross-references the three sources of truth for every custom action:

1. **`pagespecs/*.md`** (BA contract — the `endpoint` field is the URL segment)
2. **`*Controller.cs`** (backend reality — the `[HttpVerb("…")]` attribute)
3. **`*Service.ts`** (frontend reality — the `apiClient.<verb>('/…')` call)

Findings are typed `ACTION-DRIFT-001..005`:

| Code | Severity | Meaning |
|---|---|---|
| ACTION-DRIFT-001 | err | Pagespec endpoint ≠ controller route (same scope, similar suffix) |
| ACTION-DRIFT-002 | err | Pagespec endpoint ≠ service URL (the canonical 405 pattern) |
| ACTION-DRIFT-003 | warn | Service calls a URL with no pagespec OR controller backing (dead call) |
| ACTION-DRIFT-004 | warn | Controller exposes a route nobody references (dead endpoint) |
| ACTION-DRIFT-005 | err | Service verb ≠ controller verb on the same URL (e.g. GET vs POST on same endpoint) |

The report is written to `<projectPath>/_audit/actions-alignment.md` and
emitted on stdout as a JSON envelope. The `--mode apply` variant ships as a
stub (iteration 1 is report-only) — once the per-side rewriter lands, it will
accept `--source-of-truth pagespec|controller` to pick which side wins.

## Output

Emit EXACTLY ONE JSON code block matching the standard `auditReport`
envelope (same shape as `audit-screens`). Set `dimension` to `devFrontend`
on every finding. Include one `ok` finding per passing rule (so the UI
shows green checks) and one finding per failing rule.

```json
{
  "auditReport": {
    "scope": "devFrontend",
    "applicationCode": "<from PRD>",
    "moduleCode": "<from PRD>",
    "findings": [
      {
        "dimension": "devFrontend",
        "code": "DEV-UI-001",
        "severity": "err",
        "label": "DEV_UI_001_err",
        "params": { "missing": "Contract/list,Contract/detail" },
        "solution": "Re-run the Frontend phase. The subagent must call scaffold-component for each missing page.",
        "fixSkill": "frontend-component",
        "fixPhaseKey": "frontend"
      }
    ]
  }
}
```

Stop immediately after the JSON block. Do not narrate.
