---
name: frontend-component
description: >
  Generates React page components (list, detail, form) consuming
  @atlashub/smartstack npm package: PermissionGuard, Slot, SmartStackProvider.
  i18n catalogues are module-level (one JSON per module, one root key per
  entity); the CLI emits the already-merged full file (floor < existing < PRD)
  and writes it atomically.
phase: development/frontend
cli: cli/scaffold-component
allowed-tools: [Read, Glob, Grep, Bash]  # Bash: CLI invocation
---

# Frontend Component — Pages + Extension Points

Generates React page components following SmartStack feature structure with
`PermissionGuard`, `Slot`/`Fill` extension points, and module-level i18n
catalogues (entity-keyed roots, self-merged emission).

All UI primitives are imported from `@atlashub/smartstack` (npm package) — not
from the SDK source tree.

## ⚠ Source of truth — customisation-ui baseline (2026-04-22)

Templates in `cli/scaffold-component/generate.ts` are derived **verbatim** from
the reference app under `D:\01 - projets\SmartStack.app\features\customisation-ui\web\smartstack-web\`.
Baseline pages read:

- `src/pages/platform/administration/uiConfiguration/ThemesListPage.tsx`
- `src/pages/platform/administration/uiConfiguration/PresetsListPage.tsx`
- `src/pages/platform/administration/tenants/OrganisationCreatePage.tsx`
- `src/pages/platform/administration/tenants/TenantDetailPage.tsx`
- `src/components/ui/PageTemplate.tsx`
- `src/components/ui/DataTable.tsx`
- `src/components/ui/ResponsiveDataTable.tsx` (list-page wrapper — responsive columns + truncation tooltip; scaffold-ui-primitives)
- `src/components/ui/EntityCard.tsx`
- `src/base.css`, `src/index.css`

The conformity rules the generator enforces are cross-checked by
`skills/development/frontend/ui-polish/tokens.json` (`hardRules` array).

**Legacy rule**: older versions of this SKILL.md described patterns like bare
`<div>` roots, raw `<table>`, Tailwind color utilities (`bg-red-500`), etc. —
those are obsolete. The current templates use `<PageTemplate>` + `<DataTable>` +
CSS variable tokens (`var(--color-accent-500)`) + `.input`/`.btn-primary` helper
classes. **Do not re-introduce the legacy shapes.**

## Layout (emitted)

Pages live under `src/pages/`, matching customisation-ui's source-of-truth
convention AND the registry import path emitted by scaffold-routes
(`@/pages/{appLower}/{module}/{section}/`). Hooks / services / types live
under `src/features/`, written by scaffold-api-client. Pages reach them
through the `@/` alias :

```
src/pages/{appLower}/{module}/{section}/
  {Entity}ListPage.tsx        — DataTable, route helpers, computed-field columns
  {Entity}DetailPage.tsx      — read-only display, computed values projected
  {Entity}FormPage.tsx        — input fields, computed fields excluded
  {Entity}DashboardPage.tsx   — KPI cards + alerts (when 'dashboard' in views[])

src/features/{module}/{entityLower}/
  hooks/use{Entity}.ts        — React Query (use{Entity}, useDashboard{Entity}, mutations)
  services/{entity}Service.ts — axios wrappers
  types/index.ts              — DTOs (List, Detail, Create, Update, Dashboard*)
```

Page imports use the alias :

```ts
import { useBudgets, useDeleteBudget } from '@/features/budgets/budget/hooks/useBudget';
import type { Budget } from '@/features/budgets/budget/types';
```

This split keeps page files trim while letting api-client own the data layer
and routes own the URL/registry contract.

## ⚠ BLOCKING — i18n rules

**Any generated component violating these rules MUST fail audit and be rewritten.**

### i18n.1 — No hardcoded visible strings

Every string the user sees flows through `t('key')`. That includes labels,
titles, placeholders, button text, column headers, tooltips, empty states,
error messages, and confirmations.

### i18n.2 — One JSON per MODULE, all 4 locales

Keys are written to a **per-module** catalogue under `locales/`. The emission is
**self-sufficient**: `generate()` folds the existing on-disk catalogue in
(loaded by `index.ts`, injected via `GenerateContext.existingI18n`) and emits
the ALREADY-MERGED full module file — sibling entity roots carried verbatim,
the target entity merged with precedence **floor < existing < this call's PRD
i18nKeys**. Existing-over-floor is what stops the cross-view clobber (the
pipeline scaffolds one view per call while the floor is view-agnostic: without
it, a later `form` call's floor `list.subtitle` reverts the `list` call's PRD
text). The writer still deep-merges (belt-and-suspenders), writes atomically
(tmp + rename) and **fails closed** on a malformed existing catalogue —
never a silent overwrite. So sibling entities in the same module (and any
custom keys) coexist without overwrite:

```
src/i18n/locales/fr/hrm.json      (relative to the web root — the same base
src/i18n/locales/en/hrm.json       as the pages; `projectPath` IS the web root,
src/i18n/locales/it/hrm.json       validated by assertWebProjectRoot)
src/i18n/locales/de/hrm.json
```

Each module file is registered as its own i18next namespace (basename =
namespace) via `addClientResources()` — NEVER through a second `i18next.init()`
in app code. The @atlashub/smartstack SDK boots the shared i18next singleton
and its init() REPLACES the resource store, so any parallel init clobbers every
bundle registered before it (business pages then render raw keys — DEV-UI-035
flags this class). The registration is emitted deterministically by
`aggregate-component-registry` into `src/extensions/moduleResources.generated.ts`,
which scans `src/i18n/locales/<locale>/*.json` and is imported at the END of
`componentRegistry.generated.ts` (i.e. AFTER the SDK init):

```ts
// src/extensions/moduleResources.generated.ts (generated — do not hand-edit)
import { addClientResources } from '@atlashub/smartstack';
import frHrm from '../i18n/locales/fr/hrm.json';
addClientResources('fr', { hrm: frHrm });
```

### i18n.3 — Namespace = module, keys entity-prefixed

Inside each module JSON every entity owns a top-level key; its keys nest under
it. Pages call `useTranslation('{module}')` and reference the **entity-prefixed**
key:

```tsx
const { t } = useTranslation('hrm');            // the MODULE namespace
<h1>{t('employee.list.title')}</h1>             // reads hrm.json → employee.list.title
<span>{t('employee.form.fields.lastName')}</span>
```

`scaffold-component` emits a **COMPLETE floor** — every key its templates render
(`breadcrumb.section`, `list.actionsColumn`, `list.edit`, `list.delete`,
`detail.edit`, `form.submitCreate`/`submitUpdate`, `kanban.*`, `reconduction.*`,
…) in all 4 locales — then layers the PRD's `pageSpec.i18nKeys` on top as
**overrides** (the PRD value wins). So a thin PRD i18nKeys block never leaves a
rendered key untranslated. The `validate-page` rule `i18n-keys-resolve` enforces
that every `t('…')` (without an inline `defaultValue`) resolves in
`src/i18n/locales/{locale}/{module}.json` for all 4 locales; DEV-UI-038 re-runs
the same checks module-wide over the assembled bundles (+ fr/en/it/de key-set
parity).

**Known limitation — floor updates**: under floor < existing < PRD, a floor
wording shipped in a NEW CLI release only reaches NEW catalogues (existing
values win). The sanctioned reset is `--reset-i18n-entity`: drops ONE entity's
subtree back to current floor + current call's PRD (siblings untouched). Pass
it ONLY on the FIRST view call of a full re-scaffold — later per-view calls
must run without it or they stomp the previous view's PRD keys.

**Sibling-branch key scheme**: labels are LEAVES; every secondary channel is a
SIBLING parent — `form.placeholders.{field}`, `form.options.{field}.{value}`,
`{root}.actionParams.{code}.{param}`, `{root}.actionParamOptions.{code}.{param}.{value}`.
Never nest under a label leaf (`form.fields.{x}.placeholder`,
`{labelKey}.params.{p}` — a nested JSON cannot hold a string and children at
the same key; flatToNested drops the child). Legacy nested-scheme PRD keys are
remapped at the CLI input edge, on BOTH the TSX and the catalogue side.

**Placeholder self-heal**: a legacy `"[en] <fr>"` / `"[it] …"` / `"[de] …"`
placeholder value in `pageSpec.i18nKeys` (the abandoned "author FR, defer the
rest" convention) is **dropped at the CLI input edge** — the floor's real
translation (or humanised label) ships instead, so a bracket-tagged raw value
can never reach the screen. This complements the `/ba-audit-prd` PRD-089 gate
(which still flags the PRD for `/ba-translate-prd` backfill); it does not
replace it.

### i18n.4 — Validation keys scoped to their field

Don't reuse a validation message from another field just because it reads
similarly. `lastNameRequired` is ONLY for the `lastName` input. If you need one
for `target`, create `targetRequired`.

## ⚠ BLOCKING — React patterns

### react.1 — `useParams<{ id: string }>()` always null-checked

The DynamicRouter convention uses `:id` for every detail/edit path. The hook
must be null-checked before use — no bang assertions:

```tsx
const { id } = useParams<{ id: string }>();
if (!id) return <Navigate to=".." replace />;
const { data } = useEmployee(id);
```

### react.2 — `useEffect` cleans up subscriptions

Any `window.*.onEvent`, `setInterval`, `setTimeout`, `addEventListener` MUST
have a cleanup return.

### react.3 — Typed props, no `any`

Props interfaces are mandatory. FormData state is typed from the spec fields,
not `Record<string, unknown>`.

### react.4 — Auth imports come from the scaffolded seam

```tsx
import { PermissionGuard } from '@/components/auth/PermissionGuard';
import { useAuth } from '@/business/auth/useAuth';
```

Both files are emitted by `scaffold-frontend-auth` (Phase 3.0). The package
does NOT export a `PermissionGuard`, and its `useAuth().hasPermission` has no
strip-leading-appCode pass — page-side 3-seg paths (`{module}.{section}.{action}`)
would fail against the canonical 4-seg grants for every role-based user. The
local `useAuth` is a thin adapter OVER the package hook (single auth source)
adding that matching; never re-implement either inline in a page.

## FK fields → `<EntityLookup>` (not `<input>`)

When the orchestrator (`/ba-develop` Phase 3a) populates `fkTo` on a field
(`type: guid` + name ending in `Id`), `scaffold-component` emits
`<EntityLookup apiEndpoint="/api/{module}/{plural-kebab}/lookup" />` instead
of the legacy `<input type="text">`. The combobox component lives in the
client project at `src/components/ui/EntityLookup.tsx` — scaffolded by
`scaffold-ui-primitives` at Phase 3.0 — and consumes the `use{Target}Lookup`
React Query hook scaffolded by `scaffold-api-client` (paginated, 60s
`staleTime`).

Resolution rule (orchestrator side): for each field whose `type` ∈ `{guid, uuid}`
and whose name ends with `Id`, look up the matching
`Relations: ... *→1 {Target} (FK {ThisFieldName} ...)` line in the entity's
`entité.md`. If found, set `fkTo: { entity: '{Target}', module: '{ownerModule}' }`.
Same-module FKs reuse the current module; cross-module FKs read the owner
module from the attribute description (`FK cross-module vers {APP}/{MODULE}.{Target}`).
When unresolved, the field falls back to `<input>` and the orchestrator
emits a warning — audit `DEV-UI-022` then surfaces it as **BLOCKING** on
the next pass.

The `<EntityLookup>` import + the per-target `use{Target}Lookup` import are
added to the FormPage automatically (deduplicated when multiple FKs target
the same entity).

### List FILTERS on a FK — same combobox, resolved from DATA

A `pageSpec.filters[]` entry that matches a FK renders the same server-searching
`<EntityLookup>` (debounced `?search=` against the target's `/lookup`), whatever
`control` the PRD declared — `text` would substring-match a Guid and `select`
ships with no options. `date-range` and `boolean` keep their own meaning.

The target is resolved in this order, and **never guessed**:
1. `filters[].fkTo` — carried by the filter itself (create-prd, or the
   `ba-develop/cli/derive-filter-fks` backfill). The nominal path.
2. the entity field of the same (camelised) name.

`filters[].field` MUST therefore be the **FK property** (`clientId`), not the
relation name (`client`): that single string is the filter state key, the DTO
property, the wire query param AND the backend `[FromQuery] Guid?`. A
relation-named filter matched none of them — it rendered a free-text box over a
Guid whose param the backend never bound. Validation now REJECTS a `lookup`
filter (or an `…Id` filter) that resolves to nothing, instead of degrading.

Core targets served by a non-standard combined DTO (`TenantOrganisation`) get
the explicit endpoint + `selectItems` adapter on the FILTER too — the form path
always did this, the filter path used to bail out to a text box and leave
DEV-UI-033 with a finding its own heal could not clear.

## Permission keys

Pattern: `{module}.{section}.{action}` — NO appCode prefix. Examples:
- `hrm.employees.read` (guards list + detail)
- `hrm.employees.create`
- `hrm.employees.update`
- `hrm.employees.delete`

## Slot/Fill pattern

Every page exposes named slots so clients/extensions can inject content:

```tsx
<Slot name="hrm.employee.list.before" />
<Slot name="hrm.employee.detail.after" context={{ data }} />
<Slot name="hrm.employee.form.fields.after" context={{ formData, onChange }} />
```

The slot catalogue is mirrored by `frontend/extension-config/scaffold-extension-config`.

## After generation — MANDATORY

Once the CLI has finished writing the files, invoke the audit skill on the
generated paths:

```
@.claude/skills/development/audit/SKILL.md
Audit the components I just generated in src/features/{module}/{entity}/
and the locale files I added under src/i18n/locales/{locale}/{module}.json.
```

The audit MUST be green before declaring the generation "done".

## Spec fields

| Field | Type | Required | Notes |
|---|---|---|---|
| `module` | string | yes | Module code (kebab-case). |
| `appCode` | string | yes | App code (kebab-case). |
| `entity` | string | yes | Entity name (PascalCase). |
| `section` | string | yes | Section code (kebab-case). |
| `views` | array | yes | Page types to generate: `list`, `detail`, `form`, `dashboard`, `app-home`, `module-home`, `section-home`, `reconduction`. Default `['list', 'detail', 'form']`. The standalone `kanban` view is RETIRED (validate refuses it): the board renders as the kanban viewMode of the LIST page, from the pagespec`s first-order `kanban` block + `viewModes` (fold: `create-prd/cli/derive-kanban-spec`). |
| `fields` | array | no | Field definitions (from BA spec). Default `[]`. |
| `projectPath` | string | yes | Absolute path to the WEB project root (contains `src/`, `package.json` — enforced by `assertWebProjectRoot`). Pages AND i18n catalogues are written relative to it. |
| `pageSpec` | object | no | Enriched mode (Phase 3a): full `PageSpecMin` from PRD (i18nKeys, columns, actions, widgets, tabs). Legacy mode ignores this. |
| `prdContext` | object | no | Enriched mode context: `appCode`, `i18nConfig`, `accessibility`, `security` from PRD. |
| `kanbanConfig` | object | no | RETIRED legacy sentinel — refused by validate with the migration message. The board is configured by `pagespec.kanban` (statusField, columns anchored on the status enum, BR Flow `transitions`, `terminalColumns`, `dndCards`) — see `lib/page-spec-kanban.ts`. |
| `reconductionConfig` | object | no | Reconduction view configuration (optional when `'reconduction'` in `views`). Specifies `actionLabelKey`, optional `withRefuse`. |
| `pwa` | object | no | Mobile/offline declaration `{ "support": "adapted"\|"desktop-only", "offline"?: "read"\|"write" }` (SSOT `lib/pwa-meta.ts`; `pageSpec.pwa` wins over this mirror). See **Offline degradation** below. |
| `versioned` | boolean | no | Backend entity implements `IVersionedEntity` (rowversion) — the FORM echoes the loaded `rowVersion` in the update payload (real 409 on stale concurrent edit). Set in lockstep with scaffold-entity's `versioned`. |

## Offline degradation (PWA)

The generator branches on `normalizeOffline(pwa.offline)`:

- **`'read'`** — the page works offline as a READ surface (SW GET cache):
  every mutation control (list Create + row Edit/Delete, detail Edit/Delete,
  form submit) gains `disabled={!isOnline}` (`useOnlineStatus` from
  `@atlashub/smartstack`) with the `offline.actionUnavailable` tooltip; lists
  show a `offline.staleData` hint banner, the form a `offline.formUnavailable`
  banner. Cancel/navigation stays enabled.
- **`'write'`** — mutations stay **ENABLED** offline (the apiClient outbox
  captures them with an optimistic 202 — disabling them is an audit error,
  DEV-PWA-010). List + detail headers mount
  `<OutboxStatusChip resourceKey="{app}.{module}.{section}">`
  (scaffolded by `frontend-pwa`) showing pending/failed/conflict counts.
  Requires the entity's outbox spec module (scaffold-api-client,
  `pwa.offline: 'write'`) and `versioned: true` end-to-end.
- absent / `'none'` — output byte-identical to the pre-PWA generator.

i18n floor: the `offline.*` (read+write) and `outbox.*` (write) keys are
emitted in all 4 locales ONLY when the level demands them — never on a
non-PWA spec. ⚠ BLOCKING: `support: 'full'` is rejected (v1 has no
`.mobile.tsx` variants — use `'adapted'`).

## Mobile kit (PWA)

`@atlashub/smartstack` ships a small kit of mobile-shaped primitives —
`MobileEmptyState`, `MobileFab`, `MobileFilterBar`, `MobileDetailTabs` — used by
the package's own "descente par paliers" shell. They are **safe-rendered on
desktop** (plain flow markup) with ONE exception: `MobileFab` is
`position: fixed`, so it must always be render-gated.

The generator wires two of them on the **LIST page**, and only when
`pwa.support === 'adapted'` (the sole generatable level — `'desktop-only'` is
never resolved by the mobile shell, `'full'` is rejected):

- **`MobileEmptyState`** replaces the table's plain empty row: when `filtered`
  is empty the page renders the dashed empty card instead of
  `<ResponsiveDataTable>`. It reuses the existing `list.empty` key — **no new
  i18n key**, so the 4-locale floor is untouched. Filters/search stay mounted
  above it, so an over-filtered list is never a dead end.
- **`MobileFab`** doubles the primary create button in the thumb zone, inside
  the same `<PermissionGuard permission="{module}.{section}.create">`. It is
  gated on `useViewportMode() === 'mobile'` so it never floats over the desktop
  layout, and on an **offline-READ** page the gate also requires `isOnline`
  (`MobileFab` exposes no `disabled` prop, and hiding is the stricter reading of
  DEV-PWA-006). On an **offline-WRITE** page it stays live — DEV-PWA-010
  forbids degrading writes.

The kit does **not** touch the detail or form pages, and `pwa` absent (or
`desktop-only`) leaves the output **byte-identical** to the pre-PWA generator.

`MobileFilterBar` (horizontal chip row) and `MobileDetailTabs` (scrollable tab
strip) are **not** emitted: the generated filter bar and related-tabs strip are
already responsive, and swapping them would change desktop layout. Reach for
them by hand in a `@customised` page when a list needs a chip-style filter row
on mobile:

```tsx
import { MobileFilterBar, useViewportMode, type MobileFilterChip } from '@atlashub/smartstack'

const viewportMode = useViewportMode()
{viewportMode === 'mobile' && (
  <MobileFilterBar
    value={search}
    onChange={setSearch}
    chips={statusChips}          // MobileFilterChip[] = { id, label }
    activeChip={status}
    onChipChange={setStatus}
  />
)}
```

`MobileFilterBar` is fully **controlled** (`value` / `onChange` are required)
and reads its own labels from the `mobile` i18n namespace shipped by the
package — nothing to add to the app's catalogues.

## Invocation

```bash
npx --prefer-offline tsx skills/development/frontend/component/cli/scaffold-component/index.ts \
  --spec '{"module":"hrm","appCode":"myapp","entity":"Employee","section":"employees","views":["list","detail","form"],"fields":[{"name":"firstName","type":"string","required":true}],"projectPath":"/path"}'
```

`--spec-file <path>` reads the same JSON from disk — a pagespec carrying its
`i18nKeys` in four locales routinely exceeds the shell's argv limit (Git Bash
truncates far below the Windows 32 KB ceiling), which made inline `--spec`
generation impossible without a spawn-based workaround.

## Per-page validation gate — `validate-page` (sibling CLI)

The orchestrator in `ba-develop` Phase 3a runs `validate-page`
after every `scaffold-component` invocation as a deterministic gate (one page
in, pass/fail out). The CLI is read-only — it never writes files — and
returns an envelope listing every violation with line numbers + suggested
fixes that the retry path forwards back into the spec under `priorErrors[]`.

```bash
npx --prefer-offline tsx skills/development/frontend/component/cli/validate-page/index.ts \
  --project-path "/path/to/web-root" \
  --page-file "src/pages/myapp/hrm/employees/EmployeeListPage.tsx"
```

Eight blocking rules — each violates produce `severity: 'err'` and the CLI
exits 1:

| Rule id | Detects |
|---|---|
| `imports-resolve` | Any `@/…` or relative import that does not resolve to a file on disk. |
| `pagetemplate-wrapping` | Page must import `PageTemplate` from `@/components/ui/PageTemplate` AND render at least one `<PageTemplate …>`. |
| `useparams-null-check` | `useParams<…>()` without an `if (!id) return …` guard within 8 lines. |
| `permissionguard-on-mutations` | Any `mutateAsync` / `mutation.mutate` / `useDelete` / `useUpdate` / `useCreate` reference not enclosed in `<PermissionGuard permission="…">` within 30 lines upward. |
| `no-local-usepermissions` | Any local `function usePermissions(` or `const usePermissions =` definition (the subagent improvisation that bypasses the platform). |
| `i18n-keys-resolve` | `t('key')` whose key is missing in any of the four `src/i18n/locales/{locale}/{module}.json` files. |
| `hook-imports-exist` | Named imports from `@/features/…` whose target file does not export the symbol. |
| `permission-keys-no-appcode` | `permission="X.Y.Z"` literals that don't match `^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)?\.[a-z][a-z0-9-]*$` (lowercase, 3 or 4 segments — section or resource level). |

## Key rules (reminder)

1. **Feature isolation**: each entity in its own folder.
2. **API via `@atlashub/smartstack` client**: never hardcode `/api/...` paths in JSX — hooks abstract this.
3. **React Query**: all fetching via hooks (no `useEffect + fetch`).
4. **PermissionGuard**: protect mutating actions (create, delete, update).
5. **Slot/Fill**: define extension points for customization.
6. **`useParams<{ id: string }>()` strict**: no custom param names (`:userId`, `:ticketId`).
7. **Computed fields are READ-ONLY** : a `fields[]` entry with a non-empty
   `formula` (mirrored from the BA data model) is **excluded from the form**
   — no `<input>`, no entry in `${E}FormData`, no entry in the initial state.
   The List and Detail templates already render the value directly from the
   DTO (`item.<name>` / `data.<name>`) since `scaffold-business` projected
   the formula into the LINQ query — **no stub helper, no `(b) => 0`**. Audit
   DEV-UI-014 rejects any list page that introduces a stub helper.
8. **Dashboard view (optional)** : adding `'dashboard'` to `views[]` emits
   `${E}DashboardPage.tsx` calling the typed `useDashboard${E}` hook
   (produced by `scaffold-api-client` when `entities[].hasDashboard` is
   true). The page renders KPI cards from `consolidated.metrics` (key →
   `t('dashboard.kpi.<key>')`) plus an alerts list with severity badges.
   Filter inputs (`startDate`, `endDate`) drive the hook's query params.
   Slot points : `${entity}.dashboard.{header,kpis.before,kpis.after,footer}`.
   Never fetch raw axios in a Dashboard page — always go through the hook.

## Anatomy cible (baseline customisation-ui)

Every generated page MUST follow this shape. The `ui-polish` skill audits that
the output matches.

### ListPage
```tsx
import { PageTemplate } from '@/components/ui/PageTemplate'
import { ResponsiveDataTable, type ResponsiveColumn } from '@/components/ui/ResponsiveDataTable'
import { Loader2, Plus, Pencil, Trash2 } from 'lucide-react'
import { PermissionGuard, Slot } from '@atlashub/smartstack'

<PermissionGuard permission="{module}.{section}.read">
  <PageTemplate
    title={t('list.title')}
    subtitle={t('list.subtitle')}
    icon={<List />}
    actions={
      <PermissionGuard permission="{module}.{section}.create">
        <button className="... bg-[var(--color-accent-500)] text-white hover:bg-[var(--color-accent-600)]">
          <Plus /> {t('list.create')}
        </button>
      </PermissionGuard>
    }
  >
    <Slot name="{entity}.header.actions" />
    <DataTable<Entity> ... />
  </PageTemplate>
</PermissionGuard>
```

### DetailPage
```tsx
<PageTemplate title={displayTitle} icon={<FileText />} actions={editButton}>
  <Slot name="{entity}.detail.header" context={{ data }} />
  {/* sectioned body — one SectionCard per resolved section, SHARED
      form.section.<camel> labels (there is NO detail.section.* namespace) */}
  <SectionCard title={t('…form.section.<camel>', { defaultValue: '…' })}>
    <dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
      {/* dt/dd rows with text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] */}
    </dl>
  </SectionCard>
  <Slot name="{entity}.detail.sidebar" context={{ data }} />
</PageTemplate>
```

Sections are **first-order pageSpec data** (form AND detail):
`pageSpec.sections[]` entries are `{key, labelKey?, label?, description?,
columns?, fields[]}` and the array order = the card order. Membership is
seeded on `field.section`; precedence: `uiDesign` overlay > explicit
`field.section` > `sections[].fields` > seeded BA `tabs[]`. Canonical schema
+ resolver: `skills/lib/page-spec-sections.ts`; the `uiDesign` overlay
contract: `skills/lib/ui-design-overlay.ts` (single reader/writer — no
duplicated parsing, drift-locked by test). A spec that resolves NO sections
keeps the legacy flat body — one sober card holding a single `<dl>` grid.
relatedTabs (360) and the detail header/custom actions are untouched by
sectioning.

When `pageSpec.tabs[]` is present, the body mounts the **`<TabStrip>`**
primitive (emitted by `scaffold-ui-primitives` — run it first; /ba-develop
Phase 3.0 already orders it before the pages) around the inline `role="tab"`
trigger buttons. TabStrip owns the two-layer 'underlined tabs' structure
(outer divider, inner `-mb-px` row so the active tab's `border-b-2
border-[var(--color-accent-600)]` *replaces* the divider) AND the overflow
behaviour: the native horizontal scrollbar is hidden and ChevronLeft/Right
nudge arrows appear only on the side(s) that can still scroll; `activeKey`
scrolls a deep-linked trigger into view. Never re-inline the strip container —
a single-layer `flex` + `border-b` element produces a doubled / misaligned
underline (guarded by audit **DEV-UI-027**). Each tab gets the ARIA
`role="tab"`/`tabpanel` contract; panels sync to `?tab=key`. Inside a tab
panel, resolved sections render as flat `<h3>` sub-group headers over their
own `<dl>` — the panel IS the card, never a nested `<SectionCard>`.

### FormPage (modern, low-click — read-first EDIT)

`width="standard"` (full-width, not the narrow `focused` column). Fields are
grouped by their resolved section — each named section renders the
**`<SectionCard>`** primitive (`@/components/ui/SectionCard`, emitted by
`scaffold-ui-primitives` — run it first), titled through `form.section.<camel>`;
fields with NO section land in one sober SectionCard **without a header** (no
redundant "Details"). There is no inline `<section>` chrome anymore — the card
shell AND the per-section edit toggle live in the primitive (props
`title/description/editable/editing/onToggleEdit/editLabel/doneLabel/
sectionKey`). Sections resolve per the precedence above (uiDesign overlay >
`field.section` > `sections[].fields` > seeded tabs); a form with none ships
the single untitled card.

**EDIT mode is read-first by default.** Each SectionCard opens as a READ
`<dl>` grid (dt/dd, 3 columns by default — per-section `columns` 1-3) with a
per-section "Edit"/"Done" toggle (`editable={isEdit}`; testids
`section-edit-<key>` / `section-done-<key>`, un-sectioned group key
`default`). A `baseline` snapshot drives the field-by-field `isDirty` check;
the footer carries the status chip (`form.unsavedChanges` while dirty,
`form.savedAt` + HH:MM after a save — no savedAt on an offline-write page,
the outbox chip is authoritative), a Reset button (restores the baseline,
folds sections back to read) and the dirty-gated submit
`disabled={isPending || (isEdit && !isDirty)}` (an offline-read page appends
`|| !isOnline` INLINE in the same expression). A successful EDIT save **stays
on the page** (new baseline, sections fold back — only CREATE and Cancel
`navigate(-1)`), and server resync is guarded by `!isDirty`. CREATE renders
the edit grids directly (toggles off). Opt-out (regenerable): pagespec
`editExperience: 'direct'` or overlay `uiDesign.editMode: 'direct'` → the
legacy direct-edit output. The floor i18n keys (`form.editSection`,
`form.doneSection`, `form.unsavedChanges`, `form.reset`, `form.savedAt`) are
seeded in all 4 locales AND every call carries a `defaultValue`.

```tsx
<PageTemplate width="standard" title={isEdit ? t('…form.editTitle') : t('…form.createTitle')}>
  {error && <div className="… bg-[var(--error-bg)] …">{error}</div>}
  <form ref={formRef} onSubmit={handleSubmit} onKeyDown={onFormKeyDown} className="space-y-6">
    <Slot name="{entity}.form.fields.before" context={{ formData, onChange }} />
    <SectionCard title={t('…form.section.<camel>')} editable={isEdit}
      editing={!isEdit || editingSections.has('<key>')} onToggleEdit={() => toggleSection('<key>')}
      editLabel={t('…form.editSection')} doneLabel={t('…form.doneSection')} sectionKey="<key>">
      {isEdit && !editingSections.has('<key>') ? (
        <dl>{/* READ grid — dt/dd rows, `sm:grid-cols-2 lg:grid-cols-3` by default */}</dl>
      ) : (
        <div>{/* edit grid — `md:grid-cols-2` (or 1 col when formLayout==='single-column');
            textareas, inline calendars and `fullWidth` fields span both columns;
            an odd run of half-width cells auto-promotes its LAST cell to
            `md:col-span-2` so no field is left orphaned beside dead space */}</div>
      )}
    </SectionCard>
    <Slot name="{entity}.form.fields.after" context={{ formData, onChange }} />
    <div className="flex items-center justify-end gap-3">
      {/* status chip (mr-auto) + Reset (dirty only) + Cancel (secondary) + dirty-gated Submit */}
    </div>
  </form>
</PageTemplate>
```

The right control is chosen per field — the pageSpec `control` hint wins, else
it's inferred: long text → `<Textarea>`; date → `<DateInput>` (type
`dd/mm/yyyy`, a chip, or one click — its calendar header carries month + year
dropdowns to jump to any year; a `dateBounds: "past"|"future"` judgment from
`/ui-design` then forbids the wrong direction, e.g. no future birth date); enum
with ≤4 options →
`<SegmentedControl>` (1 click) else `<EnumSelect>`; bool → `<Switch>`; FK →
`<EntityLookup>` (opens on focus, shows the label not the Guid — a user-FK adds a
one-click "Me" button). Field-state flags from the data model are honored:
`readonly`/`isComputed` → display-only (shown only when they hold a value, never
an editable picker); `readonlyOn:"create"` → locked to its default on create;
`visibleWhen` → conditional render; system columns (`id`, `createdAt`, …) are
never editable. Polish: the first field autofocuses, ⌘/Ctrl+Enter submits,
required fields validate inline (message under the field), enum option labels go
through i18n, and the create payload drops locked/readonly/system fields.
Native `<input>` controls carry `data-testid="form-field-<camelKey>"`; the
composite primitives (EnumSelect, DateInput, MultiSelect, EntityLookup, …)
own their internal controls and get no such testid.

### Forbidden patterns (caught by ui-polish audit)

- Bare `<div>`/`<main>` at page root (must be `<PageTemplate>`)
- Raw Tailwind color utilities: `bg-red-500`, `text-green-600`, `border-blue-200` — use `var(--status-{bg,text,border})`
- Hex in `className`: `bg-[#6366f1]` — use `bg-[var(--color-accent-500)]`
- Custom spinners — use `<Loader2 className="animate-spin" />`
- `useParams<{id: string}>()` without null-check
- Permission keys with appCode prefix: `myapp.hrm.employees.read` — use `hrm.employees.read`
- Icon imports from `@heroicons` or `react-icons` — lucide-react only

## Unified fiche — « fiche unique, édition en place » (lib/edit-surface)

An entity whose view-set carries BOTH `detail` and `form` (and no
`editExperience`/`uiDesign.editMode: 'direct'` opt-out) gets ONE fiche:

- the **DetailPage** renders the own fields as read-first `SectionCard`s that
  toggle to edit IN PLACE (permission-driven — `canUpdate` from `useAuth`),
  with the dirty-gated sticky save bar (`form-submit`), the summary band, the
  delete/custom actions, and a TabStrip carrying ONLY the 360 related tabs;
- `/edit` mounts the SAME DetailPage with every section opened (the header
  « Modifier » button does the same via `handleEditAll`); scaffold-routes
  derives the identical answer from the same signals (`directEdit` mirrors the
  pagespec opt-out) so the two CLIs can never disagree;
- the **FormPage** degrades to the DIRECT create surface (its dormant edit
  half is unreachable);
- testids are PRESERVED (`section-edit/done-<key>`, `form-submit`,
  `form-field-<camel>`, `detail-action-edit`) — the uat-ui / ui-test drivers
  work unchanged (on /edit every section is already open, so the toggle loop
  is a no-op).

Legacy is byte-preserved for: detail without form (read fiche), form without
detail (create+edit FormPage), and the `direct` opt-out.

### Related tabs pointing outside the page's own module

A `relatedTabs[]` entry whose target `(relatedApp ?? the pagespec's appCode,
relatedModule)` differs from the page's own is emitted differently in two ways
— everything else is byte-identical to a same-module tab:

- **imports follow the TARGET's application**: `@/features/{relatedApp}/
  {relatedModule}/{entity}/hooks/…` and `@/extensions/{relatedApp}-
  {relatedModule}Routes`. Built from the PAGE's application (as they were until
  the field existed), a cross-application tab produces paths that do not exist.
  Requires `src/extensions/{relatedApp}-{relatedModule}Routes.ts` — i.e.
  scaffold-routes must have run for the TARGET module too.
- **the tab is gated on the tenant catalogue**: one
  `const show{Entity}Related{Key} = moduleAvailability.hasModule('{app}',
  '{module}')` per such tab, applied to its trigger (or band cartouche) AND its
  panel, and folded into the strip's `visibleTabKeys` so the active tab can
  never land on a hidden trigger. Where the target module was not delivered to
  the current tenant, the tab simply does not exist for that client. This needs
  `src/components/ui/useModuleAvailability.ts` — **run scaffold-ui-primitives
  first** (the CLI's primitive preflight already fails closed on it).

The guard is derived, never authored: `lib/page-spec-related-tabs.
requiresAvailabilityGuard` is the single place stating the rule, read by this
emitter and by DEV-UI-049 so the two cannot disagree. A tab that must stay
visible regardless carries `"availabilityCheck": false` in the pagespec.
A same-module tab is never guarded — the page itself would be unreachable if
its own module were missing.
