---
name: frontend-routes
description: >
  Generates PageRegistry registrations for SmartStack's DB-driven routing
  (DynamicRouter). Validates componentKey format before emission and fails
  loud on bad specs rather than producing silent-spinner registries.
phase: development/frontend
cli: cli/scaffold-routes
allowed-tools: [Read, Glob, Grep, Bash]  # Bash: CLI invocation
---

# Frontend Routes — PageRegistry + DynamicRouter

Generates route registrations for SmartStack's database-driven routing system.
The CLI emits **one file per (app, module)** (`src/extensions/{app}-{module}Registry.ts`,
app-scoped via `lib/app-classification.extensionsModuleId`) that calls
`PageRegistry.register()` for each page.

## Output pattern

```ts
// src/extensions/myapp-hrmRegistry.ts
import React from 'react';
import { PageRegistry, lazyWithRetry } from '@atlashub/smartstack';

const EmployeeListPage = lazyWithRetry(() =>
  import('@/pages/myapp/hrm/employees/EmployeeListPage').then((m) => {
    const mod = m as { default?: React.ComponentType; EmployeeListPage?: React.ComponentType };
    const resolved = mod.EmployeeListPage ?? mod.default;
    if (!resolved) {
      throw new Error("Page 'EmployeeListPage' at @/pages/myapp/hrm/employees/EmployeeListPage has no default or named export matching its file name.");
    }
    return { default: resolved };
  })
);

PageRegistry.register('myapp.hrm.employees', EmployeeListPage);
// ... detail, create, edit
```

`lazyWithRetry` (re-exported from `@atlashub/smartstack` since v3.51+) wraps
`React.lazy` with exponential-backoff retry on transient `ChunkLoadError`s
(HMR rebundle window, CDN flap, post-deploy stale cache). Without retry, a
transient `Failed to fetch dynamically imported module` surfaces immediately
as a `RouteErrorBoundary` screen on the user's first navigation after a
deploy. With retry, the next attempt picks up the fresh chunk.

The wrapper also tolerates both `export default` and `export const {PageName}`
forms. If neither exists, it throws at resolution time with a developer-readable
message — preventing the classic silent-spinner failure.

## ⚠ BLOCKING — componentKey validation

The CLI validates every emitted `componentKey` against SmartStack's contract
BEFORE writing files. On failure, the CLI throws — no partial registry is emitted.

Rules (enforced by `validateComponentKey` in `types.ts`):
- 2 to 5 dot-separated segments: `{app}.{module}`, `{app}.{module}.{section}`, `{app}.{module}.{section}.{view}`, or `{app}.{module}.{parentSection}.{section}.{view}` (nested resources)
- Each segment matches `/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/` (kebab-case, starts with letter)
- Final segment may be one of the 24 implicit suffixes (detail, edit, create, new, duplicate, settings, configure, permissions, members, history, logs, analytics, preview, versions, comments, attachments, audit, export, notifications, schedule, workflow, summary, test, runs, import)

Rejected examples:
- `Hrm.Employees.List` (not kebab-case)
- `hrm.employees` (only 2 segments when app is required)
- `myapp.hrm..employees` (empty segment)
- `myapp.hrm.employees.detail.extra.view` (6 segments)

## Entity fields

Each entity in the spec defines one or more pages:

| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | Entity name (PascalCase). |
| `section` | string | yes | Section code (kebab-case). |
| `views` | array | yes | Views to generate: `list`, `detail`, `create`, `edit`, `dashboard`, `reconduction`, `app-home`, `module-home`, `section-home`. Default `['list', 'detail', 'create']`. The legacy `kanban` token is accepted but IGNORED (warning) — the board is a viewMode of the LIST page (`?view=kanban`), no route/key of its own. |
| `pluralName` | string | no | Explicit plural form (PascalCase). When omitted, falls back to `${name}s`. |
| `parentSection` | string | no | Optional parent section (kebab-case) for nested resources. When set, componentKey becomes `{app}.{module}.{parentSection}.{section}[.{view}]`. |
| `pageFilePaths` | object | no | Per-view file path overrides (e.g. `{ "list": "src/pages/budgeting/budgets/BudgetsListPage.tsx" }`). Keys: `'list'`, `'detail'`, `'create'`, `'edit'`. When provided, overrides the default `@/pages/{appCode}/{module}/{section}/` layout. |
| `pwa` | object | no | Entity-level mobile/offline metadata `{ "support": "adapted"\|"desktop-only", "offline"?: "read"\|"write"\|bool }` — applies to every view. Source: `pagespec.pwa`. See **Mobile / offline metadata** below. |
| `pwaByView` | object | no | Per-view `pwa` override, keys = view names (e.g. `{ "create": { "support": "desktop-only" } }`). Wins over `pwa`. |

Spec-level field: `defaultPwa` (object, optional) — fleet default applied to
entities/views that declare no `pwa` of their own. Used by `/pwa` to flip a
whole module in one deterministic run.

## Mobile / offline metadata (PWA)

`scaffold-routes` is the ONE emitter of the socle's per-page `PageMobileMeta`
(the third `PageRegistry.register` argument — the seam the socle's transitional
`mobileMeta.ts` documents as CLI-owned). Resolution per view:
`pwaByView[view]` → `entity.pwa` → `spec.defaultPwa` → nothing.

```ts
PageRegistry.register('client.hrm.employees', EmployeesListPage, {
  mobile: { support: 'adapted', offlineCapable: true },
});
```

Mapping (SSOT `lib/pwa-meta.ts`, shared with scaffold-component/api-client and
audit-dev-pwa):

| Spec | Emitted `PageMobileMeta` | Meaning |
|---|---|---|
| no `pwa` field anywhere | *(2-arg call — byte-identical legacy output)* | Page is implicitly `desktop-only` in the mobile shell. |
| `{ "support": "desktop-only" }` | `{ support: 'desktop-only' }` | Explicitly refused — distinguishable from "never considered" (DEV-PWA-004). |
| `{ "support": "adapted" }` | `{ support: 'adapted' }` | Responsive desktop page reused by the mobile shell. |
| `"offline": "read"` (or `true`) | `…, offlineCapable: true` | Reads served from the SW GET cache offline; scaffold-component disables mutations offline. |
| `"offline": "write"` | `…, offlineCapable: true` | Same registry metadata — write-ness is a DATA-LAYER fact (outbox specs emitted by scaffold-api-client), never registry metadata. |

⚠ BLOCKING rules:
- `support: 'full'` is **rejected** (v1): it requires a dedicated `.mobile.tsx`
  variant (`PageMobileMeta.mobileComponent`) the generators do not emit yet.
  Use `'adapted'`.
- `offline` on a `desktop-only` meta is rejected (the shell never resolves it).
- A `pwaByView` key must name a view the entity emits.
- Requires `@atlashub/smartstack` with the PWA channel (see `frontend-pwa`
  skill's version gate) — on older packages the 3rd argument is a type error.

Transforming an EXISTING page = re-run scaffold-routes with the updated spec
(the registry is AUTO-GENERATED, no `@customised` concern), then re-run
`aggregate-component-registry`. A pagespec gaining `pwa` changes its specHash —
the next `/ba-develop` pass regenerates that page naturally.

## ⚠ BLOCKING — Wiring in main.tsx

Every generated `{app}-{module}Registry.ts` MUST be imported in `src/main.tsx` BEFORE
`createRoot(...).render(...)`:

```tsx
import './extensions/myapp-hrmRegistry';
import './extensions/myapp-catalogueRegistry';
// ... one per (app, module)

createRoot(...).render(<App />);
```

Missing this import = the `PageRegistry.register()` calls never run = `DynamicRouter`
finds nothing = blank screen for every route in that module.

The `frontend/structure` SKILL's `main.tsx` template lists the expected shape.

In practice `aggregate-component-registry` consolidates the per-module imports
into `src/extensions/componentRegistry.generated.ts` (main.tsx imports just that
one file). It **registers nothing of its own** — it is a pure aggregator of
side-effect imports. A componentKey with NO registration is not an error: the SDK
renders `ApplicationHomePage` / `ModuleHomePage` for a bare application/module
route, and a registered component ALWAYS wins over that generic page (see
`development/backend/data-layer/references/navigation-home-kpis.md`, "Which page
renders where"). Never register a placeholder on a platform key such as
`administration.<module>` to "avoid a spinner": it blanks the platform page served
there — the CLI did exactly that up to 5.12. The aggregator ALSO emits `src/extensions/moduleResources.generated.ts`:
it **scans `src/i18n/locales/<locale>/*.json`** and registers every bundle
(basename = i18next namespace, including `common`; `vitrine`/`login` excluded —
their own generated files register them) via `addClientResources()`, imported at
the END of the aggregated registry so it runs AFTER the SDK's i18next init. The
scan is deliberately decoupled from registry filenames — registries are
app-scoped (`{app}-{module}Registry.ts`) while locale bundles stay bare
(`{module}.json`, matching `useTranslation('{module}')`). Business pages on
disk with ZERO locale bundle make the aggregator exit 1 (never register module
namespaces through a parallel i18next init — that's DEV-UI-035).

## Migrating a legacy monolithic registry (split-component-registry)

Apps generated under the legacy MCP flow ship ONE monolithic
`componentRegistry.generated.ts` registering every page INLINE
(`PageRegistry.register('key', lazy(() => import('@/…')))`) and no per-module
`{app}-{module}Registry.ts`. On that layout the whole chain FAIL-CLOSES:
`aggregate-component-registry` and `scaffold-routes` exit 1 with the
`registry.legacy-monolith` / `registry.mixed-layout` guard (re-aggregating
would replace the monolith with an EMPTY aggregate — every page unregistered,
blank app), and `audit-dev-pwa` errs with DEV-PWA-011. This section is the
sanctioned way OUT — never bypass the guards.

### Step (a) — mechanical split (routing-identical, one command)

```bash
npx --prefer-offline tsx skills/development/frontend/routes/cli/split-component-registry/index.ts \
  --spec '{"projectPath":"<webRoot>"}'
cd <webRoot> && npm run build   # every lazy chunk must resolve
```

Lossless by construction: every register statement is carried VERBATIM (inline
lazy import + `{ mobile: … }` 3rd argument included) into the
`{app}-{module}Registry.ts` its componentKey designates (`extensionsModuleId` —
the SAME basename scaffold-routes emits), the monolith is backed up
(`componentRegistry.legacy-<stamp>.bak`) and replaced in-process by a clean
aggregate. Fail-closed with ZERO writes on anything unattributable
(a key with <2 dotted segments, an unknown top-level statement, a phantom
import, an existing per-module target file) — a page is never silently lost.
Idempotent: re-running on a migrated layout is a clean no-op. Legacy pages are
STILL routed after the split — nothing user-visible changes.

### Step (b) — canonical regeneration, module by module (documentary — no new CLI)

The split modernizes the LAYOUT; the pages themselves stay legacy. To bring a
module onto the canonical surface (complete pages, SmartStack theme, structure
`src/pages/{app}/{module}/{section}/`), regenerate it through the EXISTING
pipeline, one module at a time:

1. **Custom-key diff** — compare the split file's keys (envelope
   `moduleFiles[].keys`) with the keys derivable from
   `.smartstack/ba/<APP>/<MODULE>/pagespecs/`. Any monolith key WITHOUT a
   pagespec must either gain one, or move into a hand-owned `@customised`
   extensions file BEFORE step 3 overwrites the split file — otherwise
   scaffold-routes drops it.
2. `scaffold-component` per pagespec — complete pages under the canonical
   tree. On a legacy app whose live registry still serves the keys from the
   old pages, its registry-takeover guard fail-closes; pass
   `allowTakeover: true` (this flow IS the intentional re-point).
3. `scaffold-routes` with the pagespec views (+ `pwa` fields) — OVERWRITES the
   split file (same basename, intended handoff: same keys, imports now
   pointing at the canonical pages; the legacy pages are no longer routed).
4. `aggregate-component-registry`.
5. Gates (`audit-dev-frontend`, `audit-dev-pwa`), then delete the de-routed
   legacy pages (reviewed via git) and, once everything is verified, the
   `.bak`.

## ⚠ BLOCKING — Key Rules

1. **DB-driven routes**: routes come from `GET /api/navigation/menu`, NEVER hardcoded in the React tree.
2. **PageRegistry**: central registry mapping `componentKey` → lazy-loaded component.
3. **Implicit suffixes**: `.detail` → `/:id`, `.edit` → `/:id/edit`, `.create` → `/create`. Handled by `DynamicRouter` — do NOT add `<Route path="/:id">` manually.
4. **ProtectedRoute**: applied globally by `DynamicRouter` — no per-route wrapping needed.
5. **Lazy loading**: ALL pages via `lazyWithRetry()` (re-exported from `@atlashub/smartstack`) — never bare `React.lazy()` (no retry on chunk-load races) and never static imports of page components.
6. **No hardcoded navigation**: `<Link to={getPath('myapp.hrm.employees')}>` via SDK helper, or relative `<Link to="../employees/123">`.
7. **componentKey MUST match the seed ComponentKey**: backend seed + frontend registry are two halves of the same contract. Diff = silent spinner.
8. **Visibility & routing via `hasNavAccess(permissions, nodePath)` (SmartStack ≥ 3.62)** — a module/section routes ONLY when the role holds `{nodePath}.access` (exact), a DESCENDANT's `.access` (a granted child reveals its ancestors), or a covering wildcard (`*`, `app.*`, `app.module.*`). Data actions (`read`, `create`, `lookup`, …) never open the menu or the route. Never bypass with `hasFullAccess`, never compare strings raw. `hasRouteAccess` (any-permission-under-path) survives as DIAGNOSTIC only — it attributes the AccessDenied reason (`permission` vs `tenant`), never guards a route.

## Surcharger une page CORE (HR, administration, support…)

Le client ne demande pas toujours une page **neuve** : il peut vouloir
**remplacer** une page core existante (p. ex. la liste employés du module RH
`hr.employees.list`) par la sienne. C'est un cas distinct de la génération CRUD
ci-dessus, et le mécanisme n'est PAS `extensions.pages`.

Recette (corollaire direct de la Règle 7 « componentKey MUST match the seed ») :
1. Récupérer le **componentKey core exact** (via `PAGE_KEYS`, `/api/navigation/menu`, ou `PageRegistry.keys()`).
2. Ré-enregistrer **cette même clé** avec la page du client, dans un registry `src/extensions/{app}-overridesRegistry.ts` (`lazyWithRetry`).
3. Importer ce registry dans `main.tsx` **après** `import { SmartStackProvider }` → *last-write-wins*, la page client gagne.

⚠ `extensions.pages[PAGE_KEYS.HR_…]` ne surcharge PAS une page métier sur desktop
(ce canal n'est lu que par `withPageOverride`, réservé aux 9 pages publiques/auth
`home`/`login`/auth). Une page métier ne se surcharge QUE par ré-inscription
`PageRegistry`.

→ Contrat complet, recette pas à pas, pièges (warning DEV, variante mobile,
clé inventée) : **`references/override-core-page.md`**.

## Menu API contract (DB → DTO → DynamicRouter)

`GET /api/navigation/menu` returns a **4-level hierarchy of DTOs** that the frontend's `useRouteConfig` flattens into routes:

```
ApplicationMenuDto  →  ModuleMenuDto  →  SectionMenuDto  →  ResourceMenuDto
       │                    │                  │                  │
       ├─ ComponentKey      ├─ ComponentKey    ├─ ComponentKey    ├─ ComponentKey
       ├─ PermissionPath    ├─ PermissionPath  ├─ PermissionPath  └─ PermissionPath
       ├─ IsOpen ⚠          ├─ RequiredFeature
       └─ RequiredFeature
```

- `ComponentKey` + `PermissionPath` exist at **every** level (computed at runtime from hierarchical code paths).
- **`IsOpen` exists ONLY on `ApplicationMenuDto`** — domain property persisted on `core.nav_Applications`. When `true`, bypasses permission + feature checks at the Application level (e.g. `myspace.*` is always accessible to authenticated users). The seeding skill MUST set `IsOpen` on the Application, never on lower levels.
- **`RequiredFeature` exists on `ApplicationMenuDto` and `ModuleMenuDto`** (2 levels, sourced from `LicenseFeatureMapping`) — gates by license edition.
- `OUTLET_SECTIONS` config (in `DynamicRouter`) declares sections that render their sub-pages via React Router `<Outlet>` (tab UIs like `Settings`, `AppSettings`). Components resolve from `PageRegistry` — never hardcode the Outlet tree.

## After generation — MANDATORY

```
@.claude/skills/development/audit/SKILL.md
Audit the route registrations I just generated. Check lazy-loading,
componentKey format, main.tsx wiring, no hardcoded <Route path=>.
```

## Invocation

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


Pass the entity's **pagespec views verbatim** — `form` expands to `create` +
`edit` (normalizeViews). Spelling routes-native views by hand is how `edit`
used to get dropped (the schema default has none either) while every list page
called `routes.{x}.edit(...)` → TS2339. `--spec-file <path>` reads the same
JSON from disk when the spec outgrows the shell's argv limit.

## Output files

- `src/extensions/{app}-{module}Registry.ts` — one file per (app, module),
  contains all `PageRegistry.register(...)` calls. The filename is app-scoped
  (`lib/app-classification.extensionsModuleId`) so two apps sharing a module code
  (e.g. rh/configuration + clients/configuration) don't overwrite each other.
- `src/extensions/{app}-{module}Routes.ts` — single source of truth for URL paths
  in the module. Generated alongside the registry from the same spec, so URL
  paths and componentKeys can never drift. Generated pages MUST consume it
  (`import { routes } from '@/extensions/{app}-{module}Routes'`) instead of
  hardcoding URLs in `navigate()` calls (scaffold-component imports it via the
  SAME `extensionsModuleId` helper).

  ```ts
  // Example: src/extensions/gaf-budgetsRoutes.ts
  export const routes = {
    budgets: {
      list:   () => '/budgets/budgets',
      detail: (id: string) => `/budgets/budgets/${id}`,
      edit:   (id: string) => `/budgets/budgets/${id}/edit`,
      create: () => '/budgets/budgets/create',
    },
  } as const;
  ```

  URL conventions match `DynamicRouter`'s `IMPLICIT_SUFFIXES` table : `.create`
  → `/create` (NOT `/new`), `.detail` → `/:id`, `.edit` → `/:id/edit`. Audit
  DEV-UI-013 catches any page that bypasses the helper with a hardcoded URL.

## Unified fiche routing

`.edit` registers `${Entity}DetailPage` when the entity's views carry BOTH
`detail` and `form`/`edit` and `directEdit` is not set (lib/edit-surface —
the same SSOT scaffold-component reads): the fiche opens every section on a
`/edit` pathname. `.create` always stays on `${Entity}FormPage`. Pass
`directEdit: true` only when the form pagespec (or its uiDesign overlay)
carries `editExperience`/`editMode: 'direct'`.
