> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Frontend architecture

> Stralos shell and feature-package architecture

The frontend is composed by `stralos-shell` and separate feature packages.

## Shell responsibilities

* Regional login redirect to the central login application.
* Hand-off receiver (`/auth/handoff`) for a single-use session ticket issued by the central login service.
* Browser session persistence.
* Route guard and login redirects.
* Global navigation (grouped Resources navigation surface — navigation-migration M1).
* Organisation switcher (org-users-directory M3).
* Workspace chrome slots and validation of the workspace remote's projected navigation facts.
* Settings (`/settings`) — routed by the `organisation` feature remote at runtime. The shell registers the route group; the remote owns the organisation settings panels including **Users**, **Billing**, and **Assistant activity**. Users calls the organisation self-service routes at `/api/global/org/*` on `api-global`, which relays to `stralos-admin-api` server-to-server; Billing talks directly to `stralos-api-billing`.
* Hand-off ticket scrub and the `__STRALOS_AUTH__` lifecycle bridge. The security surface itself is served by the login host; the shell links to it from the user menu.
* Employee support session banner — visible when `impersonatedBy` is present on the session user object.
* Non-production environment banner: visible on development and test hostnames. The development banner can be hidden for the current page load.
* New version notification banner — shown when a newer deployment is detected. On development and test, the notification appears inside the environment banner rather than as a separate second banner. On production, the reload happens automatically at the next navigation with no user-visible notice.
* Feature route registration.
* Route catalogue and nav bridge (AI copilot foundations M0b.3).
* Command palette (Cmd+K / Ctrl+K), mounted globally in the authenticated chromed branch. Sourced from `ROUTE_CATALOG`; `nav: "primary"` entries only; project-scoped entries are offered when a project is open and resolved against that project's ID. Entity search is not wired (follow-up).
* Notifications bell in the header, between the command palette launcher and the user menu, on every signed-in route (`src/notifications/`). It polls api-global's unread count every 60 seconds while the tab is visible and lists, marks read, and marks all read through api-global's notification routes. See [Notifications](https://help.stralos.app/internal/notifications.md#notifications-bell).
* AI copilot toggle in the header `trailingActions` slot, availability bridge, and toggle-state persistence (ai-copilot M1). The toggle is a labelled **Talk with Doug** button (**Close Doug** while open), and the user menu has a matching **Talk with Doug (AI Assistant)** item; both render only while the assistant is available. There is no floating assistant pill. The panel itself and all draft network calls are owned by the assistant remote, mounted as a widget in the `appframe-aside` slot.
* **Your settings** user menu item — dispatches `stralos:user-settings-open` for every signed-in member, without a feature flag. The item is absent from billing recovery because that layout does not mount the widget. The settings sheet itself is owned by `stralos-feature-user-settings`, a `floating-end` widget remote. The shell cannot call into it directly; it listens for the window event and opens its sheet. The event name is a cross-repository contract (`src/userSettings.ts`).
* Text size preference — a per-person text size control in the user menu. The preference is stored in `localStorage` under `stralos.textSize` and restored on the next page load. Five steps are offered, labelled **-2** through **+2** as offsets from the default (0). Applied by scaling the root font size so every `rem`-based value in the shell and in all loaded remotes moves coherently without per-component overrides.

## Version detection and reload

The shell watches for a newer deployment and reloads the page at the next completed in-app navigation. Version detection and reload are separate concerns: reload on navigation applies in every environment, while the visible banner is shown only in development and test.

### Detection

The shell polls two documents every five minutes per tab:

* `index.html` — carries the deployed shell's git SHA in its asset URLs. Changing when the shell is activated; unchanged by a remote-only deploy.
* `remotes-manifest.json` — carries each remote's git SHA in its `version` field. Changing when a remote is deployed; unchanged by a shell-only activation.

Either document changing is treated as a new version. Polling only one would miss a whole class of deploys. Each poll is a conditional GET (`cache: "no-cache"` with an ETag), so the steady-state cost is two 304 responses per tab every five minutes.

In addition to the interval, the shell triggers an immediate check on tab visibility change (`visibilitychange`) and on back-forward cache restores (`pageshow` with `persisted: true`). These cover the long-idle tabs that are most likely to be holding a stale document and whose intervals have been throttled or frozen by the browser.

### Reload on navigation

When a newer version is detected, the shell waits for the next completed in-app navigation before reloading. A navigation in progress when the version is found is skipped; the reload is deferred to a subsequent navigation. Reloads are suppressed for full-document navigations, downloads, form submissions, and explicit reloads — all of which already fetch a fresh document.

The shell uses the native `window.navigation` API when available. On `navigatesuccess` the shell checks whether a reload is eligible and, if so, issues `location.reload()` after one task to allow popstate handlers to run first.

When the native Navigation API is unavailable, the shell falls back to observing the `pathname` prop passed to `useNewVersionAvailable`. A pathname change from one render to the next triggers the same reload check. Both paths are active and exclusive — the native path takes precedence, and the fallback runs only when the native API is absent — so a tab in any environment reloads at its first navigation after a new version is detected.

### Reload banner

**New version available** with **Reload** and **Dismiss** controls is shown only when the shell's `runtime-config.json` reports `environment: "development"` or `environment: "test"`. In production, the reload happens automatically at the next navigation with no user-visible notice.

When a development or test hostname shows an environment banner, the update message appears inside it. If the runtime environment is development or test but the hostname has no environment banner, `ShellNotices.tsx` renders the standalone notice. Production shows no update notice.

The `versionAvailability.ts` module owns detection. The `NewVersionNotice` component owns the standalone banner. `EnvironmentNotice` renders the embedded update message when the host has a non-production banner. The `useNewVersionAvailable` hook in `NewVersionNotice.tsx` wires them together and manages the reload trigger.

## Navigation order

MVP navigation is gated by the PostHog `mvp` feature flag in `stralos-shell`. `App.tsx` evaluates it through `useFeatureFlag("mvp", { fallback: true })` on the shell's single PostHog instance; no separate SDK initialisation is required. While the flag is on, `filterMvpNavigation` in `src/mvpNavigation.ts` removes the areas that are not part of MVP. Because the fallback is `true`, MVP navigation is also shown before flags load and whenever PostHog is unreachable or disabled, so a customer never sees unfinished areas by accident. Only a successful evaluation that returns `mvp` as off shows the full navigation.

Global navigation items appear in this order: **Dashboard**, **Projects**, **Resources** (group), **Settings**.

The **Resources** group is a non-routing navigation header whose items are, in order: **Rates Library** (`/rates`), **Construction Activities** (`/activities`), **People** (`/people`), **Crews** (`/crews`), **Plant** (`/plant`), **Companies** (`/companies`), **Locations** (`/locations`), **Company scheduler** (`/scheduler`). It appears as a desktop popover or in the mobile navigation drawer.

Within the Resources popover, the shell groups items into three labelled sections based on the remote key that owns each path:

| Section | Items |
| - | - |
| **Libraries** | Rates Library, Construction Activities |
| **Master data** | People, Crews, Compliance, Plant, Companies, Locations (any path not matched by another section) |
| **Tools** | Company scheduler |

This grouping is applied by the shell's `withResourceSections()` compositor, which reads the remote key from the manifest. A section header is omitted when no items belong to it. The destinations themselves — their labels and paths — come from the served descriptors, so a rename or relocation in the manifest takes effect without a shell release. If the popover composition already carries nested groups (from a future owner-provided section), `withResourceSections` leaves it unchanged.

Navigation labels come from the `navLabel` field in each route descriptor. There is no shell-side override map; the organisation descriptor supplies **Settings** for `/settings`.

Workforce owns **People** (`/people`), **Crews** (`/crews`), and the route-only Compliance report (`/compliance`). Fleet owns **Plant** (`/plant`); the retired `/plant/subcategories` path redirects there. The `workspace` remote owns **Companies** (`/companies`) and **Locations** (`/locations`). Legacy `/clients` and `/suppliers` paths redirect to `/companies`.

### Legacy company-register redirects (M5)

| Legacy path | Canonical path |
| - | - |
| `/clients` | `/companies` |
| `/clients/:id` | `/companies` |
| `/suppliers` | `/companies` |
| `/suppliers/:id` | `/companies` |

The detail forms redirect to the list rather than `/companies/:id` because legacy client and supplier IDs are not unified party IDs.

## Locations page

The **Locations** page (`/locations`) lists the organisation's own offices, yards, and depots. It reads from `GET /api/global/locations` with `owner=ours` to exclude company branches and quarries, which belong to party records and are displayed on the company detail page instead.

The register lists **effectively active** locations only (feature-workspace PR 73). It requests `status=active`, which api-global filters on the effective status, so a location retired on its own and every location of a retired company are both left out; the page also filters `status === "active"` client-side as a guard. There is no **Restore location** row action: **Retire location** is the only one. A location retired on its own can still be set back to **Active** from its detail page (`/locations/:id`), which a company location reaches from the company record's Locations panel and one of the organisation's own sites reaches only by URL; for a location whose company is retired that save is refused with `409 location_owner_retired` ("This location's company is retired, so the location cannot be restored."). Reinstating a company is a future PRD.

### `OrganisationLocation` shape

`api-global` returns location address and coordinate fields nested under `geoTag`, matching the shape every other geo-bearing entity in the estate uses:

```typescript theme={null}
type OrganisationLocation = {
  id: string;
  name: string;
  kind: "office" | "yard" | "depot" | "branch" | "quarry" | "other";
  status: "active" | "inactive";
  geoTag: {
    addressLine1?: string;
    city?: string;
    region?: string;
    countryCode?: string;
    latitude?: number;
    longitude?: number;
  };
};
```

`latitude` and `longitude` are omitted by the server when the row has no coordinates, so both fields are optional. A location with no coordinates is selectable for pricing but cannot resolve a route — the page shows a "No coordinates" indicator only for those rows. A retired location shows a "Retired" badge.

The `CompanyDetailPage` Locations panel reads the same nested `geoTag` shape for a company's own branches and sites.

## Projects list routed actions

Navigating to `/projects?action=add-project` opens the **New project** modal. The Projects page removes the `action` parameter before opening the modal, preserves other query parameters, and prevents React StrictMode from opening the modal twice. The modal is owned by the `workspace` remote.

Use this route from feature remotes that need to start project creation. Do not import or duplicate the project editor in a remote.

## Project navigation (navigation M2)

On a registered `/projects/:projectId/*` route, the `AppFrame` project navigation surface shows the **Project navigation** section for the routed project. It is absent from `/projects` and global routes, even when the header selector has a selected project. Project pages no longer render a persistent workspace header or horizontal sub-navigation.

The desktop rail and mobile project drawer list entries in this order: **Overview** · **Estimate** · **Delivery** \[**Delivery estimate**, **Compare to tender**, **Sign-off**, **Variations in Delivery**, **Programme**, **Schedule**] · **Commercial** \[**Variations**] · **Documents** · **Project Settings**.

* **Overview** (`/projects/:projectId`) is owned by the `workspace` remote and is the index route for the project workspace. It shows project identity, status, key dates, the contract basis panel, and quick-links to the other visible workspaces.
* **Delivery** and **Commercial** are non-routing group headers with their entries indented beneath them.
* **Estimate** (`/projects/:projectId/estimate`), **Documents** (`/projects/:projectId/documents`), and **Variations** (`/projects/:projectId/commercial/variations`) are served by the `estimate` remote.
* **Delivery estimate** (`/projects/:projectId/delivery`), **Compare to tender** (`/projects/:projectId/delivery/compare`), **Sign-off** (`/projects/:projectId/delivery/sign-off`), and **Variations in Delivery** (`/projects/:projectId/delivery/variations`) are served by the `estimate` remote (PRD 210 §12.3). These appear before Programme and Schedule in the Delivery group.
* **Programme** (`/projects/:projectId/delivery/programme`) and **Schedule** (`/projects/:projectId/delivery/schedule`) are served by the `projectSchedule` remote. Programme is declared as a `paths` entry on the `projectSchedule` descriptor alongside the primary Schedule path.
* **Project Settings** (`/projects/:projectId/settings`) appears at `order: 260` in the project section when the `projectSettings` remote is available in the active composition.
* **Tender** and **Finance** have no shipped descriptor destinations, so they do not appear.

There is no **All Projects** item in the project rail any more. The global **Projects** entry in the header is the way back to the project register from inside a project workspace. The rail's top block is the project-context slot, which shows the current project and client name when the workspace remote renders it; until it does (an older workspace build or a load failure), the shell's fallback, an **All projects** link to `/projects`, shows there instead. See [Project rail slots](#project-rail-slots-shell).

### Delivery estimate nav links and the open estimate (PRD 210 §12.2)

A project can hold multiple estimates (for example a tender estimate and a Delivery estimate). The estimate a page works in travels in the route: `/projects/:projectId/estimates/:estimateId/...`. The bare `/projects/:projectId/estimate/...` form stays as the estimate remote's primary resolver — it looks the primary up and redirects to the canonical URL — so inbound links that predate multiple estimates still land correctly.

When the user is on a canonical estimate URL, every estimate nav link in the project drawer and every estimate command in the command palette carries the same `:estimateId`. This keeps the user inside the open estimate when they navigate between sections. A project-level page (Delivery, Documents, Settings) has no estimate ID in its URL; its estimate links fall back to the bare resolver path, which opens the primary.

The bare `/projects/:projectId/estimate` path is the only declared nav destination. The canonical `/projects/:projectId/estimates/:estimateId` mount is `nav: "hidden"` — the shell rewrites each displayed link at render time using the open estimate from the URL, but the catalogue entry stays on the bare form so `routeId` lookups and help-page `routeId` frontmatter are unaffected across both URL forms.

The shell reads the open estimate ID from the URL alone on canonical routes. On non-canonical routes (any path without `/estimates/:estimateId`), the estimate remote publishes the ID via `setActiveContext` so features that need it — such as the rate-exception badge — can still read it from the nav bridge.

### Rate exception badge (PRD 04 M4)

The **Project Rates** item in the project rail carries a live exception-count badge. The badge is published by the Estimate remote using `publishNavigationBadge` on the nav bridge, so the shell renders it without owning the polling policy, count source, or display logic.

The Estimate remote reads the persisted count endpoint (`GET /api/estimate/estimates/:id/rate-exceptions/count`) rather than running the exception pass itself. A stale response is published with `tone: "muted"` and `refreshing: true`.

The badge renders differently depending on whether the project rail is expanded or collapsed:

* **Expanded rail** — the count chip appears in a trailing badge slot (`data-nav-badge`) to the right of the item label. The glyph slot (`data-nav-glyph`) does not contain the chip.
* **Collapsed rail** — only a dot indicator (`data-nav-badge-dot`) is shown. The count chip is hidden to save space, but the full accessible label (for example `Project Rates — 128 rate exceptions`) remains on the link so screen-reader users receive the complete count.

Badge states:

| Visual | Meaning |
| - | - |
| Orange solid badge with count (expanded) / dot (collapsed) | Fresh count — no issues loading. |
| Grey subtle badge with count and refresh icon (expanded) / dot (collapsed) | Stale count — the pricing inputs changed after the persisted count was computed, or a refresh failed after a verified count. Click the rail item to open Project Rates and trigger a refresh. |
| Grey subtle badge with `!` and refresh icon | Unavailable — no verified count yet, or every fetch has failed. Click the rail item to retry. |
| No badge | Fresh count of zero — the shell suppresses a settled zero because it carries no information. A stale zero still shows with the refresh icon, because the count may be wrong and is being re-read. |

The shell suppresses the badge chip when the published `displayText` resolves to a settled zero (numeric value `0` or empty string) and `refreshing` is false. A zero that arrives while `refreshing` is true is not suppressed — it says the number may be stale and is being re-read, which is the polling state the rail exists to show. The `accessibleLabel` is preserved either way, so screen-reader users are still told there are no exceptions when the chip is hidden.

The publisher (`RateExceptionBadgePublisher` in `stralos-feature-estimate`) sends `String(state.count)` without a zero guard; the shell-side suppression is the host's own presentation decision. A one-line guard in the publisher would be the more durable fix and is compatible with this behaviour.

The remote waits for an `estimateId` from the nav bridge before issuing any network call. On canonical estimate routes the shell reads the ID directly from the URL; on other routes the estimate remote publishes it via `setActiveContext`. If the active context is absent (for example, when the user has not yet opened the estimate workspace on this visit), the item renders without a badge. A route change clears the prior context so a badge from one visit cannot leak into a fresh visit to the same path.

The `accessibleLabel` field in the badge value becomes the full accessible name of the navigation item — for example `Project Rates — 3 rate exceptions` or `Project Rates — rate exception count unavailable; open to retry` — while the visible label stays `Project Rates`.

### Project rail slots (shell)

The shell renders two host-owned `RailSlotTarget`s in the project rail (`src/App.tsx`), which the workspace remote's persistent chrome portals into (see [Persistent workspace chrome](#persistent-workspace-chrome)). Each target shows the host's `fallback` for as long as the owner has rendered nothing there.

| Slot (`data-rail-slot`) | Where | Owner component | Host fallback |
| - | - | - | - |
| `project-context` (`railHeaderTarget`) | `AppFrame.projectNavigationHeader`: the top of the project rail and mobile project menu, on a routed project page | `RailHeader`: the project name and client | An **All projects** link to `/projects` |
| `estimate-switcher` (`estimateSwitcherTarget`) | `NavigationGroup.leadingContent` of the top-level **Estimate** group: the first row beneath the **Estimate** label (ui 0.75.2) | `EstimateSwitcher`: the PRD 210 estimate switcher, shown only when the project's **Multiple estimates** setting is on | None (`fallback={null}`) |

`withEstimateSwitcherSlot` applies the switcher slot to the top-level group whose `label === "Estimate"` only; groups at any other level are left unchanged. The **Estimate** label always stays visible as a rail row and is never replaced by the switcher. When the workspace remote has portalled nothing (a single-estimate project, an older workspace build, or a load failure), the row beneath the **Estimate** label is empty.

### Overview page

The `workspace` remote owns the **Overview** page (`/projects/:projectId`). It sources all its data from `api-global` and shows:

* **Project** panel — client and status.
* **Key dates** panel — start date, target submission date, and end date.
* **Contract basis** panel — contract form, on-site overhead percentage, off-site overhead and profit percentage, and special conditions note. If the contract profile has not been set, the panel says so. If the load fails an inline **Retry** button appears; the other panels remain visible.
* **Go to** panel — buttons linking to each visible workspace destination (Overview itself is excluded).

Key dates are stored as `YYYY-MM-DD` strings and displayed as stored to avoid UTC-midnight timezone shift.

### Project selector behaviour

The `workspace` remote provides the persistent header project selector. The shell mounts the remote's persistent chrome capability once in the authenticated layout, outside its route tree, so the same workspace provider and controller survive navigation between owners. The workspace remote owns selector data loading, project and client editors, and remembered project navigation; the shell does not call those domain APIs.

From inside a project workspace, selecting a different project in the header selector keeps the current workspace. For example, switching from project A while on **Delivery › Programme** lands on project B's **Programme** page. From anywhere outside a project workspace, selecting a project opens that project's **Overview**.

When the current project ID cannot be resolved, the workspace remote publishes `projectMissing: true`. The shell suppresses the unmatched project body only for that committed route and clears the fact on navigation.

#### Destination resolution (`projectDestination`)

`useProjectChrome` exposes `projectDestination(projectId)`, a pure render-time function that resolves the destination href for a given project. The rules, in order:

1. If the current path is a project-chrome path, swap the project ID segment to produce the equivalent path in the target project.
2. Otherwise, use the remembered per-project detour stored for that project.
3. Otherwise, fall back to that project's workspace home (`/projects/:projectId`).

The function is total: every project ID resolves to a concrete path. A path that `isProjectChromePath` refuses (for example, because the current route is outside a project workspace) degrades to the workspace home so the selector row always has a valid `href`.

`selectProject` remains the imperative API: it calls `projectDestination` then `navigate`, so the click-to-navigate path continues to work unchanged.

#### Anchor rendering and modifier-click support

Each project row in the selector is rendered as a real anchor element (`<a href>`). This lets the browser handle Ctrl/Cmd-click and middle-click natively — those gestures open the project in a new tab without affecting the current tab.

Prior to this change, rows were `div[role=menuitem]` elements that called `navigate` on click. Modifier clicks silently switched the current tab instead of opening a new one.

Selection state is not set on click. The `projectChrome` effect already adopts `routeProjectId` whenever the route addresses a loaded project, so a plain click's navigation sets the selection and a modifier click correctly leaves it alone.

Keyboard activation uses a `navigate` override on `Menu.Root`: zag passes an anchor item to its `navigate` prop rather than clicking it, so the override ensures Enter activates the router link instead of dispatching a bare `MouseEvent` that would trigger a full-page load.

### Persistent workspace chrome

The workspace remote's `./routes` module exports `persistentChrome` alongside `workspaceRoutes`:

```ts theme={null}
type HostChromeInput = Readonly<{
  version: 1;
  requestKey: string;
  publish(snapshot: OwnerChromeSnapshot): void;
}>;

type OwnerChromeSnapshot = Readonly<{
  version: 1;
  requestKey: string;
  rememberedProjectPath: string | null;
  projectMissing: boolean;
}>;

const persistentChrome = {
  version: 1,
  Provider,
  Header,
  Notice,
  Palette,
  // Optional — present only in owners that support the rail slots:
  RailHeader,       // project/client block for the rail's top
  EstimateSwitcher, // estimate switcher heading the Estimate rail section
};
```

`Provider` receives `remoteKey`, its children, and the optional `hostChrome` input. It owns the shared workspace controller and UI provider. `Header` renders the project selector. `Notice` renders load failures and a missing-project message. `RailHeader` renders the routed project's name and client in the top of the project navigation rail. `EstimateSwitcher` is the estimate-switching dropdown placed beneath the **Estimate** rail entry; it is visible only when the project's **Multiple estimates** setting is on. `Palette` provides the project command palette. The standard route array supplies the workspace outlet pages.

`RailHeader` and `EstimateSwitcher` are optional. An older owner that omits them renders nothing in the corresponding host targets, so the host's fallback for that slot shows instead. The shell portals each component into its `RailSlotTarget` when the owner provides it.

The shell exposes this module as `RemoteRuntime.PersistentChrome` and mounts it with a non-credential session identity, host-owned header and notice targets, the active workspace outlet target, two optional rail slot targets, and an `onChromeState` callback. A null session or missing header target prevents the remote from loading. Workspace route elements register outlet targets; the persistent provider portals the matching route into each target.

The two rail slot targets are `railHeaderTarget` (the rail's top block, provided when a project is routed) and `estimateSwitcherTarget` (the first row of the Estimate section). Their placement and fallbacks are in [Project rail slots](#project-rail-slots-shell).

The shell accepts only the four fields in `OwnerChromeSnapshot`. It verifies the `requestKey` and `rememberedProjectPath`, then scopes the accepted snapshot to the current runtime, session identity, complete route location, and active request lease. Invalid snapshots are reported and cleared; late publications and clean-ups cannot overwrite a later visit.

The workspace stores the remembered route under a user-and-organisation-scoped key. The retired unscoped shell key `stralos.nav.lastProjectPath` is neither read nor written.

After the owner publishes a remembered project route, the global **Projects** rail entry resumes that route even when the user is already inside a project. There is no **All Projects** item in the project rail; while the project-context slot is empty, its host fallback links to `/projects` (see [Project rail slots](#project-rail-slots-shell)).

The initial module load is shared. A missing descriptor shows a retriable header fallback only because no workspace outlet can be validated. A degraded runtime, module-load failure, or provider failure shows retriable fallbacks in the header and in a registered active route outlet; there is no separate notice fallback at that level. After the provider loads, the Header, Notice, and route leaves have independent render boundaries: **Project selector unavailable** in the header, **Workspace status unavailable** in the notice area, and the manifest's workspace fallback copy in the route outlet. A stale-chunk error attempts the guarded one-time reload before showing a fallback.

### Legacy route redirects (M3)

Three project paths moved to canonical locations at navigation-migration M3. The shell registers permanent redirects for the old paths so bookmarks, pasted links, and old copilot citations continue to resolve:

| Legacy path | Canonical path |
| - | - |
| `/projects/:projectId/plan` | `/projects/:projectId/delivery/programme` |
| `/projects/:projectId/schedule` | `/projects/:projectId/delivery/schedule` |
| `/projects/:projectId/variations` | `/projects/:projectId/commercial/variations` |

Redirects preserve the full URL: the project id, any deeper path segments, query string, and fragment are carried across intact. A redirect is only registered when a canonical target exists in the active manifest — if the remote is absent, both the old and new paths 404 rather than looping.

The Documents title route uses `end: false`. Its M4 review descendants (`/projects/:projectId/documents/review` and `/projects/:projectId/documents/review/:documentId`) therefore remain titled **Documents**.

Schedule is served by the `stralos-feature-project-schedule` remote at `/projects/:projectId/delivery/schedule`.

## Route catalog

The shell maintains a labelled inventory of navigable sections in
`src/routeCatalog.ts`. The assistant service uses the catalogue for navigation
questions and deep links.

Each entry in `ROUTE_CATALOG` has the following shape:

| Field | Description |
| - | - |
| `id` | Stable string key — matches the `routeId` field in help-page frontmatter. |
| `label` | Human-readable label; matches the nav item label where the entry is a nav item. |
| `pathTemplate` | react-router path template (`:param` syntax). |
| `scope` | `"global"` or `"project"` (project-scoped entries always start with `/projects/:projectId`). |
| `params` | Named URL parameters extracted from `pathTemplate`, in order. |
| `description` | One-sentence description of what the section is for. |
| `nav` | `"primary"` (appears in nav), `"hidden"` (real route, no nav item — e.g. `/compliance`), or `"chromeless"` (no shell chrome — the contractor portal and the work-items export preview). |

### Generated JSON artifact

`src/routeCatalog.generated.json` is generated by `pnpm gen:route-catalog` and must
be committed with catalogue changes. The drift test (`routeCatalog.drift.test.ts`) cross-checks
the catalogue against the live nav sources in both directions.

The assistant service vendors and parity-checks this generated JSON. It consumes the
catalogue through navigation tools and the deterministic navigation fast path.

### Drift test nav sources

The drift test validates every catalogue `pathTemplate` against four nav sources and checks that
every path in those sources has a catalogue entry:

1. **Remote manifest paths** — `paths` entries from `DEFAULT_MANIFEST`, read via `remotePaths`.
2. **Nav builder output** — the global rail and the project workspace drawer, in both static and
   manifest-composed modes (`buildDefaultNavigation`, `buildRemoteNav`, `buildProjectWorkspaceNav`).
3. **Remote-host sibling constants** — `*_BASE_PATH` declarations in `remote-host.tsx`, enumerated
   explicitly in `REMOTE_HOST_SIBLING_CONSTANTS`. This map has been empty since shell-reduction
   §4.7 act 5: every former sibling is now a descriptor `paths` entry on the relevant remote.
   The equality guard still applies so a new constant cannot slip in unenumerated.
4. **Committed routes contracts** — the `routes-contract.json` files vendored in
   `integration/__contracts__` and imported via `COMMITTED_CONTRACTS` in `integration/contracts.ts`.
   Each file is the exact contract a remote publishes at its served SHA. A catalogue entry whose
   path template matches one of these contracts corresponds to a route the remote actually declares,
   so a typo or a dropped route fails direction (a) rather than passing silently.

The contracts source replaced a hand-written `SHELL_NAV_HIDDEN_PATHS` map that had no equality
guard back to anything real. Only workspace, organisation, and estimate currently emit a contract;
routes belonging to activities, rates, and project-settings still rely on the manifest source
until those remotes adopt the contract.

To add a catalogue entry for a route belonging to a contract-emitting remote, the path must appear
in that remote's committed `routes-contract.json`. A path that is only typed in the catalogue
fails the drift assertion at direction (a).

## Command palette

The shell provides a command palette (Cmd+K on macOS, Ctrl+K everywhere else) that lets the user navigate to any primary destination without reaching for the rail or drawer. It is mounted once in the authenticated chromed layout and driven by `src/commandPalette.ts`.

### Item source

Commands are built from `ROUTE_CATALOG` — only entries with `nav: "primary"` become commands. Hidden and chromeless entries are not offered. When a project is open, project-scoped entries are resolved against the current `projectId` and listed before global destinations. When no project is open, project-scoped entries are omitted entirely.

The palette searches pages, not entities. Project search (by name, a rate, or an activity) is not wired here — the shell holds no project list; that lives in the workspace remote's project selector.

### Group headings

Every item carries a `group`. Global destinations with no catalogue `group` are placed under **Go to**. Project-scoped destinations with no catalogue `group` are placed under **This project**. The palette renders a heading whenever the group changes from one row to the next, so if items from the same group are non-contiguous the heading appears more than once.

`buildCommandPaletteItems` guarantees contiguity: items are sorted so each group's entries are adjacent, in order of first appearance, with catalogue order preserved within each group. This means a project's ungrouped pages (Overview, Estimate, Documents, Project Settings) and its grouped pages (Delivery, Commercial) all appear under a single **This project** heading.

### MVP navigation flag

When the MVP navigation feature flag is active, `buildCommandPaletteItems` applies the same filter as the rail. Destinations hidden by the MVP flag are not offered through Cmd+K.

### Launcher

`CommandPaletteLauncher` renders a **Search** button in the top bar, to the left of the project selector. It shows the search icon and **⌘K** / **Ctrl K** at the `md` breakpoint and wider, and the icon alone on narrower screens. It reads the platform from `navigator.userAgentData.platform` (or `navigator.platform` as a fallback) to choose the shortcut label. The launcher is a button because it opens the palette's dialog; the palette owns the editable search field.

## `window.__STRALOS_NAV__` bridge

The shell installs a nav bridge on `window.__STRALOS_NAV__` at bootstrap (synchronously in `main.tsx`, before any remote loads). It is the navigation analogue of `window.__STRALOS_AUTH__`.

```ts theme={null}
window.__STRALOS_NAV__.version: 1
window.__STRALOS_NAV__.getSnapshot(): NavSnapshot
window.__STRALOS_NAV__.subscribe(listener: () => void): () => void
window.__STRALOS_NAV__.navigate(path: string): void
window.__STRALOS_NAV__.currentRoute(): CurrentRoute
window.__STRALOS_NAV__.isKnownRoute(path: string): boolean
window.__STRALOS_NAV__.setActiveContext(context: { estimateId?: string }): void
window.__STRALOS_NAV__.publishNavigationBadge(
  routeId: string,
  value: { displayText: string; accessibleLabel: string; tone: "emphasis" | "muted"; refreshing: boolean },
  context: { path: string; contextRevision: number }
): () => void
```

`getSnapshot` and `subscribe` implement the `useSyncExternalStore` contract. Remotes use them to subscribe to route, context, and badge changes without depending on React Router directly. Remotes that remain compatible with older shells guard `publishNavigationBadge` before calling it. The returned function releases the published badge when called (for example, on component unmount).

**`currentRoute()`** resolves the current pathname against the catalogue without a
router dependency. It returns:

```ts theme={null}
type CurrentRoute = {
  path: string;        // the live pathname
  routeId?: string;    // matched catalogue entry id, if any
  projectId?: string;  // extracted :projectId for project-scoped routes
  estimateId?: string; // open estimate — read from the URL on canonical routes, else from setActiveContext
}
```

Matching picks the most specific entry (longest `pathnameBase`) so a deep sub-route such as `/projects/abc/estimate/work-items/42` still resolves to `routeId: "estimate"`.

**`getSnapshot()`** returns a `NavSnapshot` — a stable, referentially frozen object that is replaced only when the route, published context, navigation badges, or the resolved composition changes. It is the read path for `useSyncExternalStore`-style consumers.

```ts theme={null}
type NavEntry = {
  label: string;
  scope: "project";
  path?: string;           // absent on group entries
  items?: readonly NavEntry[];  // present on group entries
};

type NavigationBadge = {
  displayText: string;
  accessibleLabel: string;
  tone: "emphasis" | "muted";
  refreshing: boolean;
};

type NavSnapshot = {
  revision: number;                       // monotonically increasing
  contextRevision: number;                // increments when setActiveContext is called
  route: CurrentRoute;                    // the resolved current route
  projectNavigation: readonly NavEntry[]; // resolved project workspace nav for the current project
  badges: Readonly<Record<string, NavigationBadge>>; // published navigation badges by route id
};
```

`projectNavigation` carries the full resolved project workspace nav for the project identified by `route.projectId`. Paths and labels reflect the live composition — remote-backed destinations use the path from the resolved manifest rather than a static fallback. The array is empty when the user is outside a project route, or before the composition resolves.

The shell calls `setNavigationManifest` immediately after the remotes manifest resolves, before registering any remote, so `projectNavigation` is populated from the first snapshot a remote receives at registration time.

**`subscribe(listener)`** registers a change listener compatible with `useSyncExternalStore`. It returns an unsubscribe function. The listener is called synchronously on registration, then on every snapshot change.

**`navigate(path)`** calls the live React Router navigation function. Calls made
before the router registers are queued and flushed in order when
`NavBridgeRegistrar` mounts.

**`isKnownRoute(path)`** returns `true` when a concrete app path resolves to a known catalogue route. The assistant remote calls this before rendering any inline link, citation, or server-driven `navigate` action, so an absent bridge member suppresses every deep link. The function strips a query string and fragment, requires an app-relative path (`/` prefix, not `//`), and matches catalogue templates with `end: false` so a section sub-route resolves to its section. It returns a real `boolean` — the assistant compares with `=== true`, so a truthy non-boolean would read as `false` and silently remove links.

**`setActiveContext(context)`** lets a remote publish record context that is not represented in the URL. The Estimate remote publishes its selected `estimateId` and clears it on unmount.

For canonical estimate routes (`/projects/:projectId/estimates/:estimateId/...`), `currentRoute()` reads `estimateId` directly from the pathname — it does not depend on a `setActiveContext` call. For non-canonical paths (for example, the Delivery estimate pages at `/projects/:projectId/delivery`), `setActiveContext` is the only source for `estimateId`. The shell drops the published context on a pathname change and on browser history navigation so a stale estimate ID cannot carry forward to a later turn.

The call is best-effort and uses optional chaining so a newer remote remains compatible with an older shell:

```ts theme={null}
window.__STRALOS_NAV__?.setActiveContext?.({ estimateId });
```

**`publishNavigationBadge(routeId, value, context)`** lets a remote attach a badge to a navigation item by its stable route ID. The shell renders the badge alongside the item's icon without needing to know the remote's polling policy, count source, or display logic.

`routeId` is the stable catalogue ID for the destination (for example `"estimate.builder.rates"`). The badge is only applied to a navigation item that carries a matching `routeId` field — a label match alone is not sufficient.

`value` carries the display content:

```ts theme={null}
type NavigationBadge = {
  displayText: string;        // short label shown on the badge
  accessibleLabel: string;    // full accessible name replacing the nav item's default label
  tone: "emphasis" | "muted"; // "emphasis" renders orange; "muted" renders grey
  refreshing: boolean;        // when true, a spinning refresh icon appears alongside the text
}
```

`context` binds the publication to the current route and active context revision so the shell can reject a stale result arriving after a navigation or context change:

```ts theme={null}
type NavigationBadgeContext = { path: string; contextRevision: number }
```

Obtain `context` from the bridge snapshot rather than constructing it manually:

```ts theme={null}
const nav = window.__STRALOS_NAV__;
const snap = nav?.getSnapshot();
const cleanup = nav && snap
  ? nav.publishNavigationBadge?.(routeId, value, {
      path: snap.route.path,
      contextRevision: snap.contextRevision,
    })
  : undefined;
```

The return value is a cleanup function. Call it when the badge is no longer valid — for example when the remote unmounts or when a fresh fetch supersedes the previous result. A cleanup from an earlier publication cannot clear a newer one for the same route ID; the bridge compares by object identity. The badge is also discarded automatically on any route or context change.

The shell freezes both the badge value and the context object before storing them. The bridge validates every field and silently ignores publications with a mismatched path, a wrong context revision, or invalid field types.

## Project visit signal

On every committed route, the shell records which project the tab is in, so a remote that remembers per-project state can tell whether the person has been in another project since it last looked. feature-estimate's tender check review memory uses it.

The shell writes `sessionStorage` key `stralos.shell.projectVisit.v1` with the value `{ projectId: string, seq: number }` from `recordProjectVisit` in `src/projectVisit.ts`, called by `commitRouteVisit` in `src/nav-bridge.ts`:

* Moving to a different project increments `seq`, starting at 1. Returning to the same project, or visiting a route outside a project, changes nothing.
* The first document load counts, so a tab opened straight at a project URL (link, typed URL, bookmark or reload) records the visit before any remote loads.

A remote notes `seq` and compares it on its next read. A different `seq` means the person has visited another project in this tab, even if they are back in the original one. The shell is not a published package, so a remote reads the key itself and validates the shape (a non-empty `projectId` and a positive integer `seq`), as the shell's own `readProjectVisit` does.

* `sessionStorage` lasts as long as the tab.
* Storage can be missing, full or blocked, and reading `window.sessionStorage` can throw. The shell guards every access and records nothing on failure.
* A shell without this feature never writes the key. Treat an absent or malformed value as no information, not as no visits.

## Feature packages

Current feature packages:

* `stralos-feature-dashboard`
* `stralos-feature-rates`
* `stralos-feature-activities`
* `stralos-feature-scheduler` — organisation-wide crew scheduling board (`/scheduler`)
* `stralos-feature-workforce` — People, Crews, and Compliance (`/people`, `/crews`, `/compliance`)
* `stralos-feature-fleet` — Plant & Vehicles (`/plant`)
* `stralos-feature-estimate`
* `stralos-feature-project-schedule` — project-scoped crew schedule (`/projects/:projectId/delivery/schedule`) and Programme (`/projects/:projectId/delivery/programme`)
* `stralos-feature-project-settings`
* `stralos-feature-workspace` — Projects, Companies, Locations, and project Overview routes
* `stralos-feature-organisation` — organisation settings panels at `/settings`, including Users, Billing, and Assistant activity
* `stralos-feature-assistant` — the AI copilot widget mounted in the shell's `appframe-aside` slot
* `stralos-feature-user-settings` — the **Your settings** panel, mounted as a widget in the shell's `floating-end` slot. Renders nothing until the shell dispatches `stralos:user-settings-open` from the user menu.

Each feature owns its page components and API client. Features should not import shell internals.

## Shared packages

* `@stralos-co-nz/ui`: App frame (with `trailingActions` slot for shell-injected controls, `projectNavigationHeader` slot for owner-rendered content at the top of the project rail, `NavigationGroup.headerContent` and `NavigationGroup.leadingContent` slots for owner-rendered group headings and first rows in the project rail, and support for grouped navigation entries), [design library](https://help.stralos.app/internal/design-library.md) page-composition primitives (`PageHeader`, `DataTable`, `StatusBadge`, `SectionCard`, `Toolbar`, `RowMenu`, `KindChip`, `KpiStrip`, `ListCount`, `SkeletonRows`, `Dialog` with `actions`, `FormGrid`, `FormField span`, `DateField`, `format` helpers), shared UI primitives, route definition types, forms, coordinate validation helpers, display helpers, shared resource pickers, the `ErrorBoundary` component, the [form feedback pattern](https://help.stralos.app/internal/form-feedback-pattern.md) (including `ConstructionSpinner` for long-running operations), the [`useFeatureFlag` hook](#feature-flag-hook), and the [AI copilot `ChatPanel` component group](https://help.stralos.app/internal/ai-copilot-frontend).
* `@stralos-co-nz/maps-ui`: [Location picker](https://help.stralos.app/internal/location-picker.md) (`LocationPicker`, `GoogleMap`) for Google Places Autocomplete (New) address search and map selection, plus the `LocationEditorValues` form type. Extracted from `@stralos-co-nz/ui` in 0.52.0.
* `@stralos-co-nz/calculator-ui`: calculator modal screens (trench, pit, and volume) extracted from `@stralos-co-nz/ui` at 0.51.0. Import calculator components from this package. Export names and props are unchanged from the previous `@stralos-co-nz/ui` and `@stralos-co-nz/ui/calculators` locations. The `./calculators` entry point is no longer present on `@stralos-co-nz/ui`; replace any such import with `@stralos-co-nz/calculator-ui`. It also exports supporting types such as `DisposalSubstancesState`, `FillMaterialsState`, `FillMaterialOption`, `RenderLayerAssignment`, and `DisposalSubstanceUsage`, plus `UNASSIGNED_LABEL`.
* `@stralos-co-nz/domain`: shared domain types and estimating calculations.

## Fonts subpath (`@stralos-co-nz/ui/fonts`)

From `@stralos-co-nz/ui` 0.23.13, the brand typefaces (Sora and IBM Plex Mono) are no longer imported inside `Provider`. They are published as a separate subpath and must be imported once by the application that owns the page:

```typescript theme={null}
import "@stralos-co-nz/ui/fonts";
```

Add this import to the root entry point of `stralos-shell`, `login-web`, and `admin-web`. A host that upgrades to 0.23.13 without adding this import will lose its typefaces.

Federated remotes must **not** import this subpath. A CSS side-effect import anywhere on a remote's import graph causes the bundler to emit font files into every remote artifact (34 orphaned font files, approximately 338 KB, measured at thin-host M0). The `@stralos-co-nz/ui/fonts` subpath is intentionally unreachable from the package's root barrel or from `Provider`, so a remote that only imports named UI exports cannot accidentally pull fonts in.

## Stralos theme (`stralosConfig`)

`@stralos-co-nz/ui` exports `stralosConfig`, a Chakra UI v3 `defineConfig` preset. Every feature remote and the shell apply this config through `createSystem(defaultConfig, stralosConfig)`.

The theme carries the full colour ramps (green, orange, red, blue, and gray now run 50–950), `colorPalette` slots for all five of those palettes, three named text styles (`heading.page`, `heading.card`, `label.caps`), and a `table` slot recipe that applies the review's header treatment — uppercase letter-spaced headers, strong hairline, middle-aligned cells, and numeric right-alignment — to every `Table.Root` in the estate. See [Design library](https://help.stralos.app/internal/design-library.md) for the composition rules and per-component API.

### Typeface loading (ui 0.23.13)

From ui 0.23.13, the five `@fontsource` typeface imports moved out of `Provider` and onto a separate `@stralos-co-nz/ui/fonts` subpath. A federated remote that bundles ui no longer carries font files. The application that owns the page (the host) is responsible for loading the typefaces once.

`stralos-shell` imports this subpath in `main.tsx`, directly below the `bootstrapScrub` import:

```ts theme={null}
import "./handoff/bootstrapScrub"; // must remain the first import
import "@stralos-co-nz/ui/fonts";
```

The fonts import must stay below `bootstrapScrub`. That module is the first thing `main.tsx` evaluates, and placing any import above it would push a third-party module ahead of the session-ticket scrub.

`login-web` and `admin-web` each need the same import at their own entry point when they upgrade to ui 0.23.13 or later. A host that upgrades without adding the import will render without the suite's typefaces.

### Compact type scale (0.23.4)

The suite uses a compact type scale. All token values sit below the browser's 16 px default — `md` (13 px) is the suite-wide text default, set via a global `body { fontSize: "md" }` rule so unstyled text matches sized controls throughout.

| Token | Value | Notes |
| - | - | - |
| `xs` | 12 px | Small labels, footnotes |
| `sm` | 12.5 px | Tight secondary text |
| `md` | 13 px | **Suite default** — `body` inherits this token |
| `lg` | 15 px | Section headings, prominent labels |
| `xl` | 17 px | Page titles |
| `2xl` | 20 px | |
| `3xl` | 24 px | |
| `4xl` | 32 px | Display |

**Consumer rule:** use Chakra size tokens (`fontSize="md"`, `textStyle`, etc.) rather than hard-coded `px` values. A hard-coded value that matched the old default will appear oversized after the compact scale lands — the named token is the correct abstraction.

### Border radius

Sharp geometry is a Stralos brand signature. Corner radii are intentionally small: `xs`–`md` map to 2 px and `lg`–`xl` map to 4 px.

### Focus ring

The global focus ring is `2px solid {colors.teal.500}` with a 1 px offset, applied to every `:focus-visible` element.

### Colour palette slots and the `_light` key requirement

Each palette block in `stralosConfig` provides eight `colorPalette` slots that Chakra's `Badge`, `Button`, and other components read: `fg`, `muted`, `subtle`, `emphasized`, `border`, `solid`, `contrast`, and `focusRing`.

Slots whose values differ between light and dark mode are expressed as objects. Chakra's own built-in palette blocks key those objects with `_light` and `_dark`. Because `createSystem` deep-merges object values, a Stralos override that keys only `base` and `_dark` leaves Chakra's `_light` entry intact. The `:root, .light` rule then emits Chakra's value rather than the brand value, making the override appear unwritten on screen.

The required shape for a conditioned slot is:

```ts theme={null}
{ base: "{colors.teal.50}", _light: "{colors.teal.50}", _dark: "{colors.teal.950}" }
```

`base` and `_light` must point at the same token. Scalar slots (`solid`, `contrast`, `focusRing`) replace Chakra's object outright and are not affected by this requirement.

All five brand palettes (`navy`, `teal`, `green`, `orange`, `red`, `blue`, `gray`) follow this convention. `theme.test.ts` pins the conditioned slots for each palette through a real `createSystem` call and asserts that all three CSS emissions (`base`, `_light`, `_dark`) match the brand tokens.

#### Teal palette fix (stralos-ui #137)

Prior to this fix, the `teal` palette block keyed its conditioned slots (`fg`, `muted`, `subtle`, `emphasized`) with `base` and `_dark` only. Chakra's own `_light` block survived the merge, so `:root, .light` emitted Chakra's values. The most visible effect was `teal.muted`: the hover background of every teal outline and ghost control rendered as `teal.200` (stronger mint) in light mode instead of the brand's `teal.50`.

The fix adds `_light` to all four conditioned teal slots, matching the convention already in place for the other palettes. The affected controls — teal outline buttons, ghost buttons, and any element reading `teal.muted` — now hover to the correct brand value in light mode.

### Tabular numerals

`td`, `th`, and any element with `data-numeric` receive `font-variant-numeric: tabular-nums` globally. Money and quantity columns align in tables without per-cell styling.

## ErrorBoundary (`@stralos-co-nz/ui`)

`ErrorBoundary` is a React class component that catches render errors in its subtree and shows a recoverable fallback card instead of white-screening the app. It is intentionally dependency-light so it can wrap shell routes and federated remotes as a per-remote isolation layer.

### Props

| Prop | Type | Description |
| - | - | - |
| `featureName` | `string` (optional) | Names the affected area in the fallback heading, for example `Something went wrong in Rates`. Omit for a generic heading. |
| `onError` | `(error: Error, info: ErrorInfo) => void` (optional) | Provider-neutral callback invoked when a render error is caught. Pass the shell's sanitising reporter here. |
| `onRetry` | `() => void` (optional) | Called after the boundary resets when the user clicks **Retry**. Use this to re-trigger any data loads the failed subtree requires. |
| `children` | `ReactNode` | The subtree to protect. |

### Behaviour

* The fallback renders without a Chakra UI provider, so it is safe at the shell root and around federated remotes that may not have a Chakra context.
* The fallback carries `data-ph-no-capture`; the shell's PostHog configuration blocks
  matching elements from session replay.
* If `onError` throws, that reporter failure is swallowed. Console reporting occurs
  only when no `onError` callback is supplied.
* Clicking **Retry** resets the boundary state and calls `onRetry` if supplied. The child subtree re-renders from scratch.

> [!WARNING]
>
> `data-ph-no-capture` protects session replay only. The fallback renders
> `error.message` to the user without sanitising it. Callers must ensure render
> errors never carry credentials, capability tokens, or other sensitive values.
> Telemetry sanitisation does not change the fallback text.

### Usage

```tsx theme={null}
import { ErrorBoundary } from "@stralos-co-nz/ui";
import { captureClientException } from "./observability/posthog";

<ErrorBoundary
  featureName="Rates"
  onError={(error, info) =>
    captureClientException(error, {
      source: "error_boundary",
      feature: "Rates",
      component_stack: info.componentStack ?? undefined,
    })
  }
  onRetry={() => refetch()}
>
  <RatesPanel />
</ErrorBoundary>
```

## Feature flag hook

`useFeatureFlag` from `@stralos-co-nz/ui` reads flags through the shell's single PostHog instance. Remotes must not initialise their own SDK.

```tsx theme={null}
import { useFeatureFlag } from "@stralos-co-nz/ui";

const { enabled, ready } = useFeatureFlag("example_feature", { fallback: false });
if (!ready) return <Skeleton />;
return enabled ? <NewFeature /> : <ExistingFeature />;
```

The flag name above is an example. Create your flag in PostHog before shipping its consumer.

| State | `enabled` | `ready` |
| - | - | - |
| Shell has not published a decision | Configured `fallback` | `false` |
| Flag evaluation succeeds | Boolean value returned by PostHog; an unknown key is `false` | `true` |
| Shell reports flags unavailable, subscription fails, or flag loading fails | Configured `fallback` | `true` |

The hook subscribes to `window.__stralosPosthog` and `stralos:posthog-ready`, including later evaluations after identity changes. Successful refreshes can update an already-resolved flag. If the shell never publishes a decision, the hook remains loading; it has no independent timeout. Outside a browser it resolves to the fallback.

Choose the fallback that is safe for the feature. An unknown key after a successful evaluation returns `false` even when the fallback is `true`.

Flags control browser behaviour. API permission checks remain responsible for authorisation regardless of the flag value.

The package also exports `FeatureFlagState`, `FeatureFlagOptions`, `FeatureFlagReader`, `FeatureFlagCallback`, and `FeatureFlagContext`. See [Feature flags](https://help.stralos.app/internal/observability.md#feature-flags) for the shell publication contract.

## Form feedback pattern (`@stralos-co-nz/ui`)

`@stralos-co-nz/ui` ships a standardised set of components and hooks for form feedback: `useFormValidation`, `FormField`, `SubmitButton`, `notify`, `notifyValidationErrors`, `Dialog`, `DialogCancelButton`, `useDialogClose`, and `ConfirmDialog`.

Use this pattern for new or migrated modal editors and calculator forms. See [Form feedback pattern](https://help.stralos.app/internal/form-feedback-pattern.md) for full API documentation and usage examples.

## AppFrame grouped navigation

`AppFrame` accepts a `navigation` prop typed as `NavigationEntry[]`. A `NavigationEntry` is either a flat `NavigationItem` or a `NavigationGroup` — a non-navigating header that collects related items beneath it. Groups can nest: a group's `items` array accepts any `NavigationEntry`, so a feature section and its child destinations can live in one navigation surface.

### Types

```ts theme={null}
export type NavigationItem = {
  /**
   * Owner-rendered navigation control.
   * When set, AppFrame renders this node in place of the routed button — the
   * owner is responsible for routing, active state, and accessibility.
   * Use this for controls like a workspace project picker that need to live
   * in the nav surface but are not a single destination link.
   * On mobile, the drawer closes only when an anchor element inside the
   * control is activated; a button click that does not navigate leaves the
   * drawer open.
   */
  content?: ReactNode;
  label: string;
  /** Optional accessible name when the visible label is intentionally shorter. */
  ariaLabel?: string;
  path: string;
  active?: boolean;
  scope?: "global" | "project";
  /** Optional compact glyph used by vertical and mobile navigation surfaces. */
  icon?: ReactNode;
  /**
   * A small trailing marker (a count, an AttentionBadge, a "New" chip).
   * The rail's accessible name is ariaLabel ?? label; include meaningful
   * badge information in ariaLabel so screen-reader users receive it.
   */
  badge?: ReactNode;
};

export type NavigationGroup = {
  label: string;
  /** Optional leading icon rendered beside the group label. */
  icon?: ReactNode;
  /**
   * Owner-rendered control shown in place of the label text in the project rail and mobile
   * project menu (e.g. an estimate switcher heading the Estimate section). `label` still
   * names the group for assistive technology and is shown when the rail is collapsed.
   * Additive: when omitted, the label renders as before.
   */
  headerContent?: ReactNode;
  /**
   * Owner-rendered control shown as the first row inside the group, under its label (e.g. the
   * estimate switcher beneath the Estimate heading). Hidden while the rail is collapsed; shown in
   * the mobile drawer too. Additive: when omitted, the group renders only its items.
   */
  leadingContent?: ReactNode;
  scope: "global" | "project";
  /** Nested groups allow feature sections and their child destinations to stay in one rail. */
  items: NavigationEntry[];
};

export type NavigationEntry = NavigationItem | NavigationGroup;
```

`isNavigationGroup(entry)` is exported as a type guard. It returns `true` only when the entry has no `path` and carries an `items` array. A caller's item type that happens to carry its own `items` field still renders as an item because `path` takes precedence.

### Scope partitioning

`AppFrame` partitions top-level entries by scope:

* `scope === "global"` entries render as **Global navigation** (desktop header strip, mobile drawer).
* All other entries render as **Project navigation** (desktop collapsible rail, mobile drawer).

Items inside a group inherit the group's scope; their own `scope` field is ignored.

### Rendering — desktop

Global navigation renders as a horizontal strip inside the sticky header. Flat items are routed
buttons. Groups render as popover triggers whose content lists their items; nested groups remain
available within that popover.

Project navigation renders as a collapsible left rail (`<aside>`). The rail is `220px` wide when
expanded and `48px` wide when collapsed to icon-only. Items use their `icon`; collapsed items expose
the label as their accessible name and tooltip title. The preference is stored in
`localStorage` under `stralos.projectNavigation.collapsed` and restored on the next load. Groups
render as section headers and may contain nested groups.

### Rendering — mobile

Below the `md` breakpoint, the desktop surfaces are hidden and two header controls are available:

* **Open top menu** opens a start-edge drawer for global navigation.
* **Open project menu** opens a start-edge drawer for project navigation.

Both drawers are lazy-mounted and unmounted while closed. Each closes on a navigation click, its
close button, or a route change, including programmatic navigation.

When a `NavigationItem` carries a `content` node, the drawer closes only when an anchor element
inside that content is activated. A button within the control (for example, one that opens a picker
without navigating) does not close the drawer, so the person can continue interacting with the
control before picking a destination.

### Accessibility

* The desktop project rail is labelled `aria-label="Project menu"` and contains a
  `<nav aria-label="Project navigation">`.
* The desktop header strip is wrapped in `<nav aria-label="Global navigation">`.
* An active item carries `aria-current="page"`; a group with an active descendant receives
  `data-nav-group-active="true"`.
* Global group popovers are labelled `aria-label="<group label> navigation"`.
* Each navigation link uses `ariaLabel` as its accessible name when set, falling back to
  `label`. Use `ariaLabel` when the visible label is deliberately short and a richer name
  (for example, including a status count or qualification) improves assistive-technology
  output. The visible text is always `label` regardless of whether `ariaLabel` is set.
  On collapsed desktop rail items, `title` is always set to `label` so hover tooltips
  show the short label rather than the longer accessible name.

### Backward compatibility

Passing a flat `NavigationItem[]` (the previous prop shape) is still valid — the type widens to
`NavigationEntry[]` without a cast. No existing caller needs to change until it wants to use groups
or icons.

### Shell usage

The shell passes its assembled `NavigationEntry[]` to `AppFrame`. The composition is driven by the `group`, `order`, `nav`, and `navLabel` fields in each route descriptor. To add a section group, set a `group` value on the relevant `paths` entries in the remote's descriptor. Manifest key order does not affect nav rendering.

### Project rail slots (ui 0.75.1 and 0.75.2)

Three additive `AppFrame` slots let feature remotes inject owner-rendered content into the project rail and mobile project menu without a shell release. None is available to global navigation.

**`AppFrame.projectNavigationHeader`** (0.75.1) — an optional `ReactNode` rendered at the top of the project rail and the mobile project menu, above all navigation entries, for example the current project name and client. The block is hidden when the rail is collapsed.

```tsx theme={null}
<AppFrame
  navigation={entries}
  projectSelector={<ProjectSelector />}
  projectNavigationHeader={<ProjectRailHeader project={project} />}
>
  {children}
</AppFrame>
```

**`NavigationGroup.headerContent`** (0.75.1) — an optional `ReactNode` shown **in place of** the group's label text in the project rail and mobile project menu. `label` still names the group for assistive technology and is shown when the rail is collapsed.

**`NavigationGroup.leadingContent`** (0.75.2) — an optional `ReactNode` shown as the **first row inside** the group, beneath its label. It is hidden while the rail is collapsed and shown in the mobile drawer.

The shell places the estimate switcher with `leadingContent`, not `headerContent`, so the **Estimate** label stays visible (see [Project rail slots](#project-rail-slots-shell)):

```tsx theme={null}
const estimateGroup: NavigationGroup = {
  label: "Estimate",
  scope: "project",
  leadingContent: <EstimateSwitcherSlot />,
  items: estimateNavItems,
};
```

All three slots are backward-compatible: callers that omit them see no change.

## API base URL resolution

The shell and the migrated Activities, Dashboard, Estimate, Rates, Scheduler, Workforce, Fleet, Project Schedule, and Project Settings clients use this precedence model:

1. The module's `VITE_STRALOS_*_API_URL` build-time variable.
2. The matching property in `window.__STRALOS_API_URLS__`.
3. The matching `localStorage["stralos.<module>ApiUrl"]` support override.
4. `sameOriginFallback(devDefault)`.

The terminal fallback returns `window.location.origin` in a browser production build. Under a Vite development build, or when `window` is unavailable, it returns the module's localhost default. Explicit configuration always takes precedence.

Global clients check `VITE_STRALOS_GLOBAL_API_URL` before the legacy `VITE_STRALOS_API_URL`. The shell also supports `VITE_STRALOS_ESTIMATE_API_URL`, `VITE_STRALOS_DOCUMENTS_API_URL`, and `VITE_STRALOS_ASSISTANT_API_URL`; their local defaults are ports `3303`, `3304`, and `3307` respectively. A production build without an explicit Assistant URL uses the serving origin. The Activities client supports `VITE_STRALOS_ACTIVITIES_API_URL` (default port `3302`) and `VITE_STRALOS_RATES_API_URL` (default port `3301`); the Rates API URL is used to load item summaries from `/api/rates/rate-summaries` and exact per-item options from `/api/rates/rate-items/{id}/options` for all three calculator modals.

Dashboard uses this chain for the Global API. Project Settings resolves its Global, Estimate, and Documents API bases independently through the same four stages. Its Global configuration accepts `VITE_STRALOS_GLOBAL_API_URL` and the legacy `VITE_STRALOS_API_URL`; the Estimate and Documents build-time keys are `VITE_STRALOS_ESTIMATE_API_URL` and `VITE_STRALOS_DOCUMENTS_API_URL`. Their development defaults remain ports `3300`, `3303`, and `3304` respectively.

### Billing API (`stralos-api-billing`)

The **Billing** panel (`BillingPanel`) in Settings talks directly to `stralos-api-billing` using the signed-in user's bearer token. It is intentionally not routed through the Global API or any admin service. The base URL resolves through the same four-stage chain:

1. `VITE_STRALOS_BILLING_API_URL` build-time variable.
2. `window.__STRALOS_API_URLS__.billing`.
3. `localStorage["stralos.billingApiUrl"]` support override.
4. `sameOriginFallback("http://localhost:3308")`.

The billing panel is visible to organisation admins only (`role === "admin"`). Members do not see the **Billing** menu item in **Settings**.

When `/settings` contains a `billing` query parameter, the shell opens the **Billing** section on
mount. Checkout and portal return URLs use this route so the admin lands in the right place.

If `stralos-api-billing` is not configured for the environment (`configured: false` in the summary response), the billing panel shows a "Billing is not configured" state and does not offer a checkout button.

### Billing access boundary (`BillingAccessBoundary`)

The shell wraps the authenticated product in `BillingAccessBoundary`. It calls `GET /api/billing/access` on mount, every 60 seconds, on window focus, and after a session projection change. Each answer is scoped to `user.id:user.organisationId`, so state from one organisation cannot leak into another.

Navigation between product routes does not trigger a re-check. The three standing triggers (poll, focus, session projection) keep the answer current without a per-click request. The only navigation that re-checks is crossing the billing-recovery boundary: when an admin leaves `/settings/billing`, the boundary re-checks immediately so a fixed subscription takes effect before the user returns to the product rather than waiting for the next poll or focus event.

| Mode | Behaviour |
| - | - |
| `full` | Product renders normally. |
| `read_only` | Product renders with a warning bar and, when present, the `readOnlyEndsAt` deadline. The boundary switches to blocked at that deadline without waiting for another request. |
| `blocked` | Product is replaced with an **Access blocked** screen. Admins can open billing recovery; members are told to ask their admin to update billing. |

When `enforced` is false, the product renders regardless of `mode`.

**Failure handling.** How a failed check behaves depends on when it occurs and why it failed.

* A first check with no previous answer, or a definitive failure (4xx other than 408 or 429, or a parsed but invalid response body), applies at once: the product is replaced with **Billing is unavailable**.
* A transient failure — a network rejection, timeout, 5xx, 408, 429, or JSON parse error — on a background poll after a known answer keeps that answer and schedules a retry in 10 seconds. Only three consecutive transient failures (\~20 seconds) replace a known answer with **Billing is unavailable**.
* Any 200 answer, including a denial, always applies at once.

**Billing is unavailable** is shown with a **Check again** button. It does not leave product content mounted.

**Provisional first frame.** Organisation tokens can carry `{ mode, reason?, seats, readOnlyEndsAt?, coveredBy? }` in a signed `billing` claim (`src/billingClaim.ts`). The boundary decodes it as a UI hint while the endpoint request is pending, so a covered company gets the covered-company rules below from the first frame. A missing claim renders the product immediately. The endpoint answer then replaces the provisional state, and product APIs enforce billing independently on every request.

An admin with `reason: "subscription_required"` is redirected to `/settings/billing`. The recovery routes `/settings/billing` and `/settings?billing=required` remain available during blocked access and suppress product chrome so project/client reads are not mounted. They show the billing panel with account actions and a **Back to app** link.

**Covered companies.** When the billing answer carries a non-empty `coveredBy`, the company was created by a contractor company and its billing is that contractor company's. Nobody at the covered company can fix it from their own **Settings → Billing**, so the boundary changes three things:

* An admin is never redirected to `/settings/billing` for `subscription_required`.
* No **Manage billing** link is shown, for any role.
* The `read_only` bar and the `blocked` screen name the contractor company, for example "Acme Contracting's billing needs attention, so this company is read-only for now." The blocked screen adds "Please ask an admin at Acme Contracting to check Settings → Billing."

The contractor's name comes from `GET /api/global/org/context` (`src/contractorContext.ts`), read only after the billing answer reports `coveredBy`. Any failure returns `null` and the messages fall back to an unnamed form ("Your company's billing needs attention…"), so a failed read never blocks a screen.

**Contractor access ended.** When any product API answers `403` with `code: "contractor_access_ended"`, `authFetch` dispatches the `stralos:contractor-access-ended` window event (`src/contractorAccessEvent.ts`). The person reached this company through a contractor company and that link, or the contractor company itself, has ended. The billing boundary replaces the product with **Your access to this company has ended** and the message "Your access to this company has ended. Please ask your company admin if you need it back.", with the account actions still available. Refreshing would not help, because `api-login` refuses to mint a token for that company again. The screen is scoped to the identity it was raised for, so switching to another company clears it.

**Free beta ended.** `GET /api/billing/access` can carry `beta: { endsAt, daysLeft, seats }` (api-billing returns `null` for a company not on the beta; an older billing service leaves the field out, which reads the same). A beta company has an `active` billing row with no card that ends 14 days after sign-up; after that the normal rule applies, `read_only` for 7 days and then `blocked`. `src/betaAccess.ts` parses the marker: anything that is not an object with a string `endsAt` and numeric `daysLeft` and `seats` counts as no marker. The boundary treats the beta as ended when billing is enforced, the mode is not `full`, the company is not covered, and the marker is well-formed. It then changes the wording only:

* The `read_only` bar is labelled "Free beta ended". Admins see "Your 14-day free beta has ended. Please add a card to keep working in Stralos. Everything you've set up is safe." with an **Add a card** link to `/settings/billing`. Members see "Your company's free beta has ended. Please ask your admin to add a card. Everything you've set up is safe." with no link.
* The blocked screen is titled **Your free beta has ended**, with "Please add a card to restore access." for admins and "Please ask your admin to add a card to restore access." for members, each followed by "Everything you've set up is safe."
* An admin is redirected to `/settings/billing` once per sign-in, keyed on the `stralos.auth.signInId` stamp in `localStorage`. Reaching a billing recovery route, by the redirect or directly, records that stamp under `stralos.billing.betaEndedShownFor`, so the redirect does not repeat in that sign-in. With no stamp (storage blocked, or a session from before the stamp existed) there is no redirect; the banner link still goes to billing.

The provisional billing claim in the token does not carry `beta`, so the beta wording appears once the endpoint answers.

### Projects setup lock

`SetupAccessBoundary` (`src/SetupAccessBoundary.tsx`) locks the Projects area while a company is still being set up. It sits inside `BillingAccessBoundary`: billing can block the whole product, while the setup lock only blocks Projects.

The lock is behind the `onboarding-setup-lock` PostHog flag (`ONBOARDING_SETUP_LOCK_FLAG` in `src/organisationSetup.ts`), read with `fallback: false`. Until PostHog answers `true` (still loading, missing, inactive, blocked or failed), the setup status is never requested and nothing is locked. Turning the flag on mid-session starts the check as a page load would. Turning it off clears the lock and restores navigation without a reload. The onboarding welcome dialog and the dashboard's setup empty state use the same flag.

**Status check.** With the flag on, `useOrganisationSetup` calls `GET /api/global/organisation-setup` once per identity (`userId:organisationId`). A complete answer is not asked again in that session. While the answer is incomplete, the hook asks again on every pathname change, so Projects comes back as soon as the admin finishes the last step on the Locations, Companies or Rates page, without a reload.

`fetchOrganisationSetup` fails open: a `404`, any other non-2xx response, a network error, a timeout after 10 seconds, or a body without a boolean `complete` returns `null`, which the shell treats as complete. The lock is a guide through setup, not an access control. Every API still enforces its own permissions.

The response is `{ complete, exempt, steps: { office, client, rate }, nextStep }`. `api-global` decides from real data:

| Step | Done when | Next-step page |
| - | - | - |
| `office` | The company has an active location of its own (not a company's branch) | `/locations` |
| `client` | The company has an active client company | `/companies` |
| `rate` | The Rates Library has at least one rate item (a failed rates read counts as done) | `/rates` |

`exempt` is `true` once the company has a live (non-archived) project, and an exempt company is always `complete`. The shell locks only on `complete: false`. A step missing from the answer is treated as done.

**While locked:**

* `isSetupLockedPath` matches `/projects` and every path under `/projects/`. Those routes render the locked panel instead of the remote page.
* `filterSetupLockedNavigation` removes Projects entries from the global rail, and the command palette drops destinations whose path template is in the Projects area (`isSetupLockedDestination`).
* The locked panel is titled **Projects open once setup is done**. An admin sees "Projects need your office, a client company and at least one rate. Next, please add …" and a **Do this next** button to the next step's page, taken from `nextStep` or else the first missing step in the order office, client, rate. A member sees "Your admin is still setting things up. Please ask them to add …", listing the missing steps, with no button.
* Arriving at a locked path while the answer is being re-checked shows an empty busy area rather than the panel, so finishing setup and then opening Projects never flashes the locked panel.

### Shell geocoding

The shared [location picker](https://help.stralos.app/internal/location-picker.md) uses Places Autocomplete (New) for address search. `VITE_STRALOS_GEOCODE_COUNTRY` is passed to the picker as `countryCodes` and defaults to `nz`.

### `window.__STRALOS_GOOGLE_MAPS__` seam

The shell installs Google Maps configuration on `window.__STRALOS_GOOGLE_MAPS__` during boot by calling `loadGoogleMapsRuntimeConfig()`. The value is read from the `googleMaps` block in `/runtime-config.json`. Feature remotes read this object to initialise the Maps SDK without fetching the runtime document themselves.

```typescript theme={null}
type GoogleMapsRuntimeConfig = {
  apiKey: string;
  mapId?: string;
  linzApiKey?: string;
  readonly countryCode?: string;
};
```

`countryCode` is a live getter. It reads the signed-in user's active organisation from the session on every access, finds the matching `countryCode` in the stored memberships, and returns it. The value therefore updates automatically after an organisation switch and clears to `undefined` after sign-out. Remote components pass it to the Maps SDK as a region hint without needing to subscribe to session events.

If `/runtime-config.json` does not include a `googleMaps` block, or if `apiKey` is missing or blank, the object is not installed. The `/auth/handoff` and `/login` routes skip the boot preload; `installGoogleMapsRuntimeConfig()` registers a lazy `window.__STRALOS_LOAD_GOOGLE_MAPS_CONFIG__` function for those routes so their first map render can trigger the same load on demand.

## Auth contract

Feature clients read the shell-owned browser session keys and attach bearer tokens. The central login host owns sign-in; after hand-off, the regional shell owns browser persistence, logout, and the refresh bridge. Feature packages should not implement their own refresh loops.

### localStorage session keys

The following `localStorage` keys are the stable cross-bundle interface. Feature bundles read these directly — do not rename them without updating every consumer.

| Key | Type | Description |
| - | - | - |
| `stralos.auth.accessToken` | string | Current user JWT (EdDSA); sent as `Authorization: Bearer` on API calls. |
| `stralos.auth.refreshToken` | string | Opaque 256-bit rotating refresh token. |
| `stralos.auth.user` | string | JSON of `{ id, name, email, organisationId, role, memberships?, impersonatedBy?, impersonationExpiresAt? }`. The optional `memberships` field is an array of `{ organisationId, organisationName?, countryCode?, role, status }` entries. `countryCode` is an ISO 3166-1 alpha-2 string or `null`, stored at signup and used as a region hint for Google Maps. Sessions started through `api-login` receive no `memberships` from the server; the shell fills the cache at startup via `ensureOrganisationNamesCached` when the current organisation has no stored name. Central hand-off, switch, and refresh DTOs do not include `memberships`. Session writes preserve an existing list when a later response omits it. `impersonatedBy` is the admin operator ID when the session is an employee support session; `impersonationExpiresAt` carries that session's absolute ISO 8601 expiry. Both are absent on ordinary sessions and preserved across token refresh and organisation switching. Feature bundles that only read `organisationId` or `role` can ignore these optional fields. |
| `stralos.auth.signInId` | string | Random id stamped once per sign-in by `markSignIn()`, after hand-off redemption (`src/handoff/redeem.ts`) persists a new session. `clearSession()` removes it. `setSession` never writes it, so token refresh and organisation switching leave it unchanged. `stralos-feature-onboarding` reads it, read-only, to open the setup guide once per sign-in. A failed write only means the guide does not open on its own. |
| `stralos.nav.lastProjectPath` | string | Retired unscoped shell key. Remembered project navigation is owned by the workspace remote and published to the shell through `OwnerChromeSnapshot.rememberedProjectPath`. |

### Employee support session notice

When `impersonatedBy` is set on the current session user, the shell renders `EmployeeSupportSessionNotice` — an orange banner — above the application header for the duration of the session. The banner identifies the session as an audited employee support session and names the account being viewed.

When `impersonationExpiresAt` is present and parseable as a finite timestamp, the banner shows a live countdown (`Ends in HH:MM:SS` or `MM:SS`). The timer updates every second and stops at zero, showing `Session expired` once the absolute expiry has passed. When the field is absent or unparseable, the banner shows a static `Expires within one hour.` fallback.

The banner also renders an **End session** button that calls `signOut()` when clicked.

The notice is suppressed on the route that replaces the session, `/auth/handoff`. On that route, `stralos.auth.user` still describes the outgoing session until `setSession` completes, so showing the notice would misidentify a completing hand-off as an ongoing employee support session. The shell tracks this state with `sessionIsBeingReplaced` and excludes that route from the render condition.

The notice renders outside the `AppFrame` header so it cannot be obscured by the application chrome; the DOM position is enforced in tests. The `role="status"` and `role="timer"` attributes are set on the relevant elements for accessibility. The timer uses `aria-live="off"` — screen readers read the initial value but are not interrupted by each second-by-second update; the full accessible label (`aria-label`) is kept current for assistive technology that polls the element.

### Non-production environment banner

The shell renders `EnvironmentNotice` at the top of every page when the browser hostname identifies a non-production environment. The banner does not affect routing or session behaviour. The development banner has a close button (**Hide development environment banner**) that hides it for the current page load; it returns after a reload. The test banner has no close button.

`environmentFromHostname` classifies the hostname:

| Hostname pattern | Environment | Banner colour |
| - | - | - |
| `localhost`, `127.0.0.1`, `::1` | Development | Blue |
| `dev.stralos.app`, `*.dev.stralos.app` | Development | Blue |
| `test.stralos.app`, `*.test.stralos.app` | Test | Purple |
| Any other hostname | Not shown | — |

The development banner reads **Development environment** with the description **Changes and data here do not affect production.** The test banner reads **Test environment** with the description **Use this environment to verify changes before production.**

When a newer deployment is detected, **New version available — Reload to pick up the latest release** appears inside the environment banner alongside **Reload** and **Dismiss** controls. The outdated-version message is in a live region so screen readers announce it when it appears. Dismissing hides the message for the current page load but does not cancel the automatic reload that fires on the next navigation.

If the development banner has been hidden, `ShellNotices` shows the standalone `NewVersionNotice` instead when a newer deployment is detected. The same standalone notice is used when no environment banner applies.

The classification logic requires the suffix to include the Stralos registrable domain, so a hostname such as `dev.stralos.app.example.com` is not classified as a Stralos development environment and no banner is shown.

The notice is mounted through `ShellNotices` in `App.tsx`; the component itself returns `null` for production hostnames.

### Shell notices and AppFrame height

Both the employee support session notice and the non-production environment banner are grouped in `ShellNotices` and rendered in a `Box` above the `AppFrame` slot. Because `AppFrame`'s root has `min-height: 100vh`, a page was previously that combined notice height taller than the browser window when any notice was visible. List pages that own their own scroll (from ui 0.69) had no content to scroll but gained an unwanted scrollable area equal to the banner height.

The shell measures the combined height of the notices container using `useElementHeight` and constrains the `AppFrame` slot to `min-height: calc(100vh - <noticesHeight>px)`. The measurement is reactive: adding, removing, or resizing a notice updates the frame's minimum height without a page reload.

The sticky project rail (`aside[aria-label="Project menu"]`) is shortened by the same amount: `calc(100vh - var(--stralos-sticky-top, 41px) - <noticesHeight>px)`. On a page that scrolls, the notice scrolls away while the rail stays stuck under the app bar, so the rail would end `noticesHeight` above the bottom of the window. Growing the rail back would make every non-scrolling page taller again, so `appFrameSlotCss` (`src/appFrameSlotCss.ts`) paints the gap instead. When `noticesHeight` is above zero it adds two box shadows offset downward by `noticesHeight`: one in the rail's surface colour, 1px narrower, and one in the border colour that shows as the rail's right edge. A shadow is paint, not layout, so it never adds to page height or scroll.

### Login client configuration

Sign-out's hosted-logout leg and the **Security** menu link read configuration from the **`auth` block of `/runtime-config.json`**, fetched same-origin at boot. The `/login` redirect does not use this block. There is no `VITE_*` build-time variable for it — a build-once-promote-many bundle cannot carry a per-environment value.

```jsonc theme={null}
{
  "auth": {
    "hostedDomain": "auth.dev.stralos.app",       // Cognito ceremony host; bare hostname
    "clientId": "1a2b3c4d5e6f7g8h9i0jklmnop",
    "loginOrigin": "https://login.dev.stralos.app" // full origin of the login host; controls the Security menu link
  }
}
```

`src/runtimeDocument.ts` fetches the document **once** (`cache: "no-store"`, 1500 ms ceiling) and shares it with observability and Google Maps configuration. A boot that needs all three makes one request. The `auth` and `googleMaps` blocks are additive, so omitting either does not affect the other consumers.

`auth.loginOrigin` is a full origin pointing to the central login application (`login.<env>.stralos.app`). The shell uses it to build the **Security** link in the user menu. It must not be derived from `auth.hostedDomain`, which is the Cognito ceremony host on a different subdomain. A deployment that omits `auth.loginOrigin` keeps all other behaviour and loses only the Security menu item. The injected-config escape hatch for local development is `window.__STRALOS_LOGIN_HOST_ORIGIN__`.

`window.__STRALOS_LOGIN_ISSUER_ORIGIN__` and `window.__STRALOS_OIDC_CLIENT_ID__` are an injected-config escape hatch for a host that writes them into `index.html`. They take precedence over the document and skip the fetch entirely. The window seam carries a full **origin**; the document carries a bare **hostname** — both are normalised through the same `resolveOrigin` validator, so neither form can carry a path, query, or credentials.

Neither source is `localStorage`: the issuer decides where sign-out navigates, so it may not be repointed from writable storage.

`/login` stores a validated return path in the regional origin's `sessionStorage`, derives the sibling `login.<envdomain>` origin from the browser origin, and hard-navigates there. It does not use `auth.hostedDomain`, which identifies the Cognito ceremony host. The cross-origin URL remains bare; `/auth/handoff` consumes the stored return path after the central service establishes the session.

`/login` has two states:

| State | Condition | Rendered UI |
| - | - | - |
| **Configured** | The browser is on a valid regional Stralos origin | **Taking you to sign in**, then a hard redirect |
| **Unconfigured** | No central login origin can be derived | **Sign-in isn't available** and no redirect |

The same route handles explicit sign-out and failed-refresh redirects from the shell and feature bundles.

### Google Maps runtime configuration

The shell loads Google Maps browser configuration from the `googleMaps` block of `/runtime-config.json` and publishes it on `window.__STRALOS_GOOGLE_MAPS__` before any remote component renders.

```jsonc theme={null}
{
  "googleMaps": {
    "apiKey": "<browser-safe API key>",  // required; whitespace-only values are rejected
    "mapId": "<Map ID>",                 // optional
    "linzApiKey": "<LINZ Basemaps key>"  // optional; enables the LINZ aerial layer
  }
}
```

All values are trimmed before publication. A missing or empty `apiKey` means the block is absent from the window global. `mapId` and `linzApiKey` are omitted from the published object when absent, blank, or not a string; without `linzApiKey` the LINZ aerial layer stays hidden. Remotes that render maps read `window.__STRALOS_GOOGLE_MAPS__` directly. If the property is absent, the maps block has not been configured for this environment and the remote should degrade accordingly.

There is no `VITE_` build-time variable for these values. A build-once-promote-many bundle cannot carry a per-environment key, so the document is the only configuration path.

**Local development.** The Vite dev and preview servers synthesise `/runtime-config.json` in `vite.config.ts`, reading `GOOGLE_MAPS_*` and `LINZ_BASEMAPS_*` variables from `.env` and the process environment. With `GOOGLE_MAPS_API_KEY` set, the `googleMaps` block carries `apiKey`, plus `mapId` from `GOOGLE_MAPS_MAP_ID` and `linzApiKey` from `LINZ_BASEMAPS_API_KEY` when set. Without `GOOGLE_MAPS_API_KEY` the block is omitted, so maps and the LINZ aerial layer stay off locally.

The fetch is memoised by `src/runtimeDocument.ts` and shared with auth and observability. Preloading at boot costs one request regardless of how many consumers read it.

**Bootstrap behaviour.** The shell calls `installGoogleMapsRuntimeConfig()` at startup, which installs a lazy reader at `window.__STRALOS_LOAD_GOOGLE_MAPS_CONFIG__`. The normal boot path also preloads `loadGoogleMapsRuntimeConfig()` directly, so the window global is populated before the first remote renders on standard page loads. The `/auth/handoff` and `/login` routes skip the boot preload to avoid delaying those time-sensitive paths. A remote on those routes can call `window.__STRALOS_LOAD_GOOGLE_MAPS_CONFIG__()` when it first needs the configuration.

### Login email prefill (removed at IDP-M2)

The `/login#email=<encoded-address>` handoff, `src/loginPrefill.ts`, and the `stralos.login.lastEmail` key were removed at IDP-M2. They prefilled the email+OTP form, which no longer exists. The OIDC ceremony collects the address at the issuer. A browser signed in before IDP-M2 may retain the orphaned `stralos.login.lastEmail` key in storage; it carries only a normalised email address and no credential.

### Regional provider hints (removed)

The shell's `?idp=` allowlist resolver and its `__STRALOS_IDP_PROVIDERS__` / `__STRALOS_IDP_PROVIDER_ALIASES__` window seams were removed; `stralos-api-login` owns the moved copy. `/login` redirects to a bare central-login URL and does not forward provider hints. Treat provider selection as owned by the central login application; do not link to the regional shell with `?idp=`.

### Cross-bundle refresh bridge

The shell publishes a refresh bridge on `window.__STRALOS_AUTH__` at bootstrap (before any remote loads) so that feature remotes can delegate token refresh, org switching, session teardown, and organisation name refresh to the shell's single-flight auth serialiser.

```ts theme={null}
window.__STRALOS_AUTH__.refreshAccessToken(): Promise<string | null>
window.__STRALOS_AUTH__.switchOrg(organisationId: string): Promise<SwitchOrgResult>
window.__STRALOS_AUTH__.refreshOrganisationNames(): Promise<boolean>
window.__STRALOS_AUTH__.clearSession(): void
window.__STRALOS_AUTH__.logout(): void
window.__STRALOS_AUTH__.signOut(): Promise<void>
```

**`refreshAccessToken`** — when a feature remote receives a `401`, it calls this method after detecting the seam rather than attempting its own refresh. All callers delegate to the same `refreshSession()`, which calls `POST /api/login/auth/refresh`, so concurrent bundles coalesce onto one refresh rotation and do not race the rotating refresh token. The method resolves to the new access token, or `null` if refresh is impossible and the shell has cleared the session.

Token rotation is serialised across browser tabs as well as within a single tab. The shell acquires the Web Locks API lock `stralos.auth.rotation` before executing any token-rotating mutation (refresh or organisation switch). Tabs that do not support the Web Locks API fall back to the existing within-tab serialisation. Tabs that request a rotation while one is already in flight in another tab wait for the origin-wide lock to be released before proceeding; the waiting tab then reads the freshly written token from `localStorage` rather than issuing a second rotation. If an organisation switch in another tab has already triggered a page reload, waiting tabs park until the navigation replaces their JS context.

The current feature clients retry the original request once. They use the returned token when present, otherwise they re-read the access token from storage. This fallback supports a remote running without the shell and covers a bridge that could not refresh. A second `401` means the session is dead. The remote must clear the session before redirecting to `/login?reason=expired&from=<encoded-return-path>` — leaving the dead keys in storage causes a redirect loop because the shell's `/login` route treats a session as live on the presence of `stralos.auth.accessToken` and `stralos.auth.user` alone, ignoring `reason=expired`, and sends the visitor straight back to the page that just failed.

**`clearSession`** — clears the browser session and any session-related telemetry identity. The shell publishes it with `logout` as an alias of the same synchronous local teardown. Feature remotes call this method before redirecting to `/login` on a dead session. A standalone remote without the bridge falls back to removing the three documented keys (`stralos.auth.accessToken`, `stralos.auth.refreshToken`, `stralos.auth.user`) directly from `localStorage`.

**Bootstrap session refresh.** During bootstrap, before any remote renders, the shell awaits `refreshExpiringSessionBeforeFirstRequest()`. This call checks whether the stored access token is expired or within its expiry window and, if so, rotates it before the first render. Feature remotes read the access token directly from `localStorage` rather than calling `authFetch`, so without this preload a reopened tab with an expired token would send its first burst of requests with a stale bearer and receive `401` responses before any in-flight refresh could complete. The preload is skipped on the ceremony callback, hand-off, and `/login` routes, which write a new session of their own and do not benefit from rotating an outgoing one.

**Shell `authFetch` — proactive token refresh.** Before sending a request, `authFetch` reads the stored access token's `exp` claim and calls `refreshSession()` first when fewer than 30 seconds remain. Because `refreshSession()` is single-flight, concurrent calls crossing that window coalesce onto one rotation. The `401` path is kept as a fallback for early server-side revocation and for tokens whose `exp` cannot be read.

**Shell `authFetch` — stale-401 waterfall prevention.** The shell's own `authFetch` wrapper records the access token that was in storage when a request was issued. A slow endpoint can return a `401` after a faster concurrent request has already triggered a refresh and written a new token to storage. In that case `authFetch` detects the mismatch — the current storage token differs from the issued token — and retries with the already-refreshed token rather than calling `refreshSession()` again. Only a 401 whose storage token still matches the issued token triggers an actual refresh. This prevents a page load that issues several API calls simultaneously from serialising into a chain of sequential refresh round-trips when the session expires mid-load.

**Shell `authFetch` — one automatic retry for busy GETs.** When a GET request receives `503` with a `Retry-After` header of 10 seconds or fewer, `authFetch` waits the requested delay and retries the request once. A second `503` is returned to the caller without a further retry. Write requests (`POST`, `PUT`, `PATCH`, `DELETE`) are never replayed: a `503` does not confirm the write did not happen. A `503` with no `Retry-After` header, or with a value above 10 seconds, is also returned immediately without retrying. Session mutation endpoints (`/refresh`, `/switch-org`, `/logout`) call `fetch` directly and are unaffected.

**`switchOrg`** (org-users-directory M3) — switches the active organisation for the live session. It shares the same single-flight serialisation lock as `refreshAccessToken` because both `/api/login/auth/refresh` and `/api/login/auth/switch-org` rotate the refresh token; a concurrent pair would trip family-reuse revocation. This lock extends across browser tabs via the Web Locks API: a switch in one tab waits for any in-flight refresh in another tab to complete before it sends the switch request, so it always sends the most recently rotated refresh token. On success the shell persists the new session and hard-reloads the entire application so every feature bundle re-reads the new active-org token. The `SwitchOrgResult` resolves to `{ ok: true }` on success, or `{ ok: false, reason }` where `reason` is one of:

| Reason | Session state | Cause |
| - | - | - |
| `forbidden` | Intact | `403` — membership was revoked since login; the refresh token was not consumed. The `OrgSwitcher` popover stays open and shows the inline error message. |
| `unauthenticated` | Cleared, redirected to login | `401` or no refresh token present. |
| `network` | Cleared, redirected to login | Network failure or an unreadable `200` response that may have already rotated the token server-side. |
| `error` | Cleared, redirected to login | Any other non-`2xx` response. |

The shell's `OrgSwitcher` popover surfaces `forbidden` outcomes as an inline message and stays open so the user can choose another organisation. `network` and `error` outcomes clear the session and redirect to login inside the flow. `unauthenticated` redirects are also handled inside the flow before the component receives the result.

**`refreshOrganisationNames`** — fetches the live list of organisations the signed-in user belongs to (`GET /api/global/org/organisations`) and writes the result into the membership name cache. A feature remote that has displayed an organisation name and needs to reflect a rename can call this method. The method resolves to `true` when the cache was updated and the session is still valid, or `false` when the session has changed or the fetch failed. Two guards prevent a stale response from overwriting a newer session:

* The session identity is captured before the async module import. If a teardown happens during that import, the identity will have changed and the method returns `false` without making a network call.
* After the fetch completes, the session identity is checked again. A response that arrived after a teardown-and-new-session is discarded.

The method fails soft on network error or API refusal — the existing cache is not cleared.

**`clearSession`** — removes the three `stralos.auth.*` storage keys and resets the telemetry identity. The call is synchronous and local: it does not POST to a logout endpoint. Feature remotes that receive a second `401` (meaning redirect to login) call this before navigating to `/login` so the login route does not read a dead session as authenticated and bounce the user back. Feature-detect the seam before calling (`window.__STRALOS_AUTH__?.clearSession?.()`).

**`logout`** — an alias for `clearSession`, published under the name that `stralos-feature-organisation`'s second-`401` branch calls. Both names are bound to the same function, so `clearSession` and `logout` always behave identically. Do not call the shell module's own `logout()` export from a remote — that function posts to `/api/login/auth/logout` and the navigation that follows routinely races the request.

**`signOut`** — runs the host's complete awaited sign-out sequence for shell controls and remote calls. It attempts `POST /api/login/auth/logout` under a two-second ceiling, clears the local session, resolves the login configuration under a 1.5-second ceiling, then hard-navigates to hosted logout or `/login`. Each leg is guarded so one failure cannot prevent the final navigation. A remote calls this member rather than rebuilding the host-owned sequence.

**Consumer rules:**

* Retry the failed request at most **once**. A second `401` means the session is dead — clear the session (via `clearSession` if available, otherwise remove the three `stralos.auth.*` keys) and redirect to `/login?reason=expired`. Never trigger another refresh for that request. Skipping the clear causes a redirect loop.
* **Never run a second refresher of your own** alongside this call. A parallel refresher races the rotating refresh token and invalidates the session for every bundle.
* Do not call `switchOrg` while a refresh is in flight, and do not call `refreshAccessToken` while a switch is in flight. Both serialise through the same lock, so queuing is safe, but the caller must not assume the token state is stable between the two calls.
* Feature-detect each member before calling it. A member present on the `StralosAuthBridge` type but absent from the published object means the shell version pre-dates that member.

The bridge is installed by `installAuthBridge()` in `main.tsx`.

### Organisation name in the app header (UI consistency review rule 1)

A ghost button carrying the current company's name and a building icon sits in the app header between the project selector and the user menu. Clicking it opens the `OrgSwitcher` popover, the same popover that the **Switch company** item in the user menu opens. Both entry points control the same `Popover.Root`; a test pins this so they cannot drift.

The displayed label is `currentOrganisationName(user)` trimmed, falling back to `orgShortLabel(user.organisationId)` (the first eight characters of the organisation ID, prefixed with `Business …`). This is the same fallback the switcher's own list rows use, so the header and the modal cannot disagree about what an organisation is called before its name has loaded.

At viewport widths below `md` the button shows only the building icon (no text label). The full `aria-label` — `Switch company — currently <name>` — is always present.

### Organisation switcher (org-users-directory M3)

The shell renders the active organisation's name as a button in the application header, between the project selector and the user menu. Clicking it opens the `OrgSwitcher` popover, anchored on the org pill. The button shows the organisation name when loaded, or falls back to `orgShortLabel` (the first eight characters of the organisation ID prefixed with `Business …`) before the name resolves. On screens narrower than the `md` breakpoint the button shows only the building icon to preserve header space; the full name appears from `md` upward.

The same popover is also reachable from **Switch company** in the user menu. Both entry points control the same `Popover.Root` state and both are always present in the authenticated layout.

The **Switch company** item in the user menu opens the `OrgSwitcher` popover. It is always present in the menu regardless of what the persisted session contains.

The popover dismisses on Escape and on an outside click. It does not have a Close button. The popover cannot be dismissed while a switch is in flight — the panel reports its busy state upward through `onBusyChange` and the root refuses `onOpenChange` until the switch completes. This guard matters because `switchOrg` clears an uncertain session and redirects on every failure except the recoverable 403, and that 403 message is rendered inside the panel. A dismissal mid-flight would discard the only report the user gets.

`Popover.Root` and its trigger belong to the header chrome because the pill is the anchor and the user menu's **Switch company** item is a second entry point onto the same controlled state. `OrgSwitcher` renders content only; it does not own its own visibility.

#### Behaviour

When the popover opens, it calls `GET /api/global/org/organisations` via `listAvailableOrganisations()` to fetch the live list of organisations the signed-in user belongs to. It also calls `cacheAvailableOrganisations` to write the name list into the stored membership cache, so a subsequent hard reload retains the correct list and name data. The same cache is written at startup by `ensureOrganisationNamesCached` and after a rename by `refreshOrganisationNames`; both use the same `cacheAvailableOrganisations` function so the stored shape cannot drift between callers.

The popover uses the same shape as the project selector: flat ghost-button rows with a trailing **Current** badge on the active entry. A filter field appears above the list when the loaded membership count is three or more (`FILTER_FIELD_MIN_ROWS`). Below that threshold the field is hidden because there is nothing to narrow. The filter field is cleared each time the popover opens, so a leftover query from the previous session cannot hide entries on the next open.

The filter field is rendered inside a `SectionCard` with `stickyHeader` so it remains visible as the user scrolls the list. The list scroll area is capped at `min(60dvh, var(--available-height, 60dvh))` so the popover respects the available viewport height on short screens and in landscape mobile layouts. The `--available-height` custom property is set by `AppFrame` when it measures the space below the app bar; it falls back to `60dvh` when the property is absent.

Each organisation row shows its display name and role (**Admin** or **Member**). When the signed-in person reaches a company through a contractor company, the name is followed by a muted ` · via <contractor name>` label, from the optional `viaContractorName` field on each `AvailableOrganisation` in the `GET /api/global/org/organisations` response. Clicking another organisation calls `switchOrg()` on the auth bridge. On success, the shell persists the new session and hard-reloads the application. A muted line below the list reads "Switching reloads Stralos with that company's projects and settings." The popover blocks dismissal while a switch is in progress.

#### Failure states

| Failure | Behaviour |
| - | - |
| Load error | Inline error message with a **Try again** button: "We couldn't load your companies. Please try again.", or "Your companies are taking too long to load. Please try again." on a timeout. The list is empty until the retry succeeds. |
| Empty list or changed session | A list that comes back empty shows "We couldn't find your companies just now. Please try again.", and a list read for a session that has since changed shows "Your session has changed. Please try again.", both with **Try again**. There is no "no companies" state, because a signed-in person always has at least one. |
| `403` on switch | Membership was revoked since the list loaded. The popover stays open, refreshes the list, and shows: "You no longer have access to that company." |
| `401` or network failure on switch | `switchOrg()` clears the session and redirects to `/login` before the component receives the result. |
| Other non-`2xx` response on switch | `switchOrg()` clears the uncertain session and redirects to `/login`. |

#### Organisation labelling

`orgLabel()` resolves a display name for each entry:

1. If the `AvailableOrganisation` carries a non-empty `organisationName`, that name is used verbatim.
2. Otherwise it falls back to `orgShortLabel()` — the first eight characters of the organisation ID prefixed with `Business …`.

#### `parseMemberships` validation

`parseMemberships()` in `auth.ts` normalises the raw `memberships` array from the OIDC exchange response or persisted session storage. It:

* Rejects any entry missing a non-empty `organisationId`.
* Accepts `organisationName` only when it is a string with at least one non-whitespace character — a whitespace-only value is treated as absent and the field is omitted entirely.
* Accepts `countryCode` only when it is a two-uppercase-letter ISO 3166-1 alpha-2 string or explicit `null`. Any other value is omitted entirely.
* Normalises `role` to `"admin"` or `"member"` with a safe default.
* Falls back to `"active"` for `status` when the field is absent.
* Accepts `viaContractorName` only when it is a string with at least one non-whitespace character; otherwise the field is omitted. It is present only for a company the person reaches through a contractor company.

This validation runs both at session write (`setSession`) and at session read (`getUser`). The persisted memberships are a cache and provide current-organisation label data; the switcher's list itself comes from the live `listAvailableOrganisations()` response.

### User menu rendering

The application header is sticky and z-indexed, so it is its own stacking context. Content rendered in place inside it can never paint above a page-level layer higher than the header's z 10, whatever its own z-index. The `floating-end` widgets, such as the onboarding guide panel at z 1100, would otherwise cover the menu's lower items, including **Sign out**.

The user menu therefore renders its content through `Portal` to `document.body`, the same strategy as the `OrgSwitcher` popover, with `positioning={{ strategy: "fixed" }}`. The trigger stays in the header. Escape still closes the menu and returns focus to the trigger. `src/UserMenu.portal.test.tsx` pins this by asserting that the open menu content is not a descendant of the header.

### Organisation name cache (`organisationNames.ts`)

The organisation's display name is stored in the `memberships` array of `stralos.auth.user`, keyed by `organisationId`. Consumers of that display name — `currentOrganisationName`, `internalOwnerLabel`, the assistant's org group, and `organisationNameOf` in `feature-rates` — resolve it from that key. No new storage key or bridge function is involved. Organisation-scoped pricing uses this current name directly.

The shell fills the cache at startup via `useCachedOrganisationNames`. The hook calls `ensureOrganisationNamesCached` once per session identity, which is a no-op when the active organisation already has a name in the stored list. When it does not — the session was started through `api-login`, which builds the user record with no `memberships` — the function calls `GET /api/global/org/organisations` (`listAvailableOrganisations`) once and writes the result into the cache. The cost is zero extra requests for OIDC sessions; one request, once, for `api-login` sessions.

After the startup fill completes, `useCachedOrganisationNames` triggers a re-render so the repaired name appears without waiting for an unrelated render to occur.

`cacheAvailableOrganisations` is the single write function used by all three callers: the startup fill, the switcher open, and the post-rename refresh. Keeping the mapping in one place prevents the stored shape from drifting.

After an organisation rename, `saveOrganisationName` in `GlobalSettingsPage` calls `refreshOrganisationNames`, which fetches the live list from `listAvailableOrganisations` and writes it into the cache unconditionally. Because this endpoint reads from `stralos-admin-api` — the service that just accepted the rename — the switcher, the Locations register, and organisation-owned pricing groups in `feature-rates` can show the new organisation name immediately. The `organisation-settings` projection on this region lags the admin event and updates asynchronously; the settings panel shows a message to this effect while the lag resolves.

The whole-list approach is what makes the cache survive an org switch. `switchOrg` hard-reloads the page and changes `user.organisationId`. Because the cache holds a name entry for every organisation the person belongs to, the switched-to organisation is already named and the previous organisation's entry cannot be returned for it.

The fill degrades silently on refusal, outage, or unconfigured relay. Every consumer keeps its existing fallback label until the cache is populated.

## Personal security settings

Personal security settings — TOTP, passkeys, password, and sign out everywhere — are served by `stralos-login-web` on the login host (`login.<env>.stralos.app`) against a server-side broker in `stralos-api-login`. The previous regional paths (`/settings/security` and `/settings/security/callback`) are no longer served by the shell or `stralos-feature-organisation`. Both paths now redirect to the login host's `/security` page. The redirect uses `window.location.replace` so the browser's Back button does not return to a URL whose only behaviour is to leave again. If `auth.loginOrigin` is absent the path falls through to the unmatched-page view instead of guessing at a destination.

The **Security** item in the user menu is an anchor to the login host's `/security` path. It is not a client-side navigation: a `navigate()` to another origin does not exist in a single-page application, and the router would render the unmatched-path page instead.

The page allows a person to manage their authenticator-app factor, passkeys, and password, and shows linked sign-in providers read-only. Its **Sign out everywhere** action calls the security broker under the page's live step-up cookie and CSRF echo. The regional shell retains a separate refresh-token-authenticated revoke-all endpoint. These controls apply to the person's own identity and do not configure organisation-wide SSO.

The link is built from `auth.loginOrigin` in `/runtime-config.json` (see [Login client configuration](#login-client-configuration) for how the shell reads this document). The shell resolves the URL at runtime from `securityHome.ts`:

* When `auth.loginOrigin` is present and valid, the menu shows **Security** as a link to `<loginOrigin>/security`.
* When the key is absent, the menu item is hidden — not disabled. A deployment that has not yet set the key keeps sign-in and settings but loses this one menu item.

`auth.loginOrigin` is a full origin (`https://login.dev.stralos.app`), not a bare hostname. It is read from `auth.loginOrigin` explicitly and must not be derived from `auth.hostedDomain`, which is the Cognito ceremony host — a different service on a sibling subdomain.

The regional shell no longer runs a settings OAuth ceremony, scrubs a settings callback, or publishes `window.__STRALOS_SECURITY__`. Its only bootstrap scrub is the hand-off ticket scrub (`handoff/bootstrapScrub.ts`).

The pre-auth allow-list (`UNAUTHENTICATED_PATH_PREFIXES` in `remotes.ts`) is now empty: a remote descriptor cannot create an unauthenticated route. There is no longer a host-authored exact-string list of security paths: every URL on this host requires a session except `/login` and `/auth/handoff`.

### Email prefill on the step-up form

Both the **Security** menu link and `SecuritySurfaceRedirect` append the signed-in user's email address as a `?email=<encoded>` query parameter to the security URL. The login host's step-up form reads this parameter and opens pre-filled, so a user who is already authenticated to the regional shell does not need to re-enter their address.

The parameter is appended by `withStepUpEmail` (`src/securityStepUpEmail.ts`). Key properties:

* The parameter is omitted entirely when the shell has no email address for the current session. `?email=` (an empty value) is not written.
* `URL.searchParams.set` performs the encoding. A plus-addressed mailbox (`user+tag@example.com`) arrives as `%2B` rather than a literal `+`, which a form decoder would misread as a space.
* The URL is a navigation target only. It is not logged, not captured as an analytics property, and not stored. The anchor element carries `data-ph-no-capture` so session replay does not record the attribute value.
* The field is editable on the login host and is never auto-submitted. It is a convenience, not an automatic authentication step.

## OAuth relying-party login (IDP-M1)

The central login application runs the OAuth 2.0 / OIDC ceremony and redeems the code on the login host. The email and OTP form has been removed, and the regional shell's `/login` route redirects to the central application. The session comes back through the [hand-off receiver](#idp-m7c-hand-off-receiver).

The shell's regional `/auth/callback` page and its ceremony code (`AuthCallbackPage`, state and PKCE storage, the code exchange, the region chooser, at-login invite acceptance, and the `/login?authError=` cards) were retired after production showed no traffic to them. `/auth/callback` is no longer a route of its own: a stale callback URL falls to the ordinary guards. api-global's `POST /api/global/auth/oidc/exchange` still exists, but no web client calls it.

The shell still reads the login client configuration (`auth.hostedDomain` and the app client id, or the `window.__STRALOS_LOGIN_ISSUER_ORIGIN__` and `window.__STRALOS_OIDC_CLIENT_ID__` seams) for one purpose: sign-out's hosted-logout leg (`src/hostSignOut.ts`, `src/oidc/logout.ts`) must name the same client.

### `safeInternalPath` open-redirect guard

The `from` return destination survives a round trip through `sessionStorage` while the user signs in on the central login application. Before IDP-M1, the shell accepted any path starting with `/` (but not `//`). That predicate was an open redirect: `/\evil.com` passes the prefix check but the WHATWG URL parser treats a backslash in the authority position as `/`, resolving it to `https://evil.com/`.

`safeInternalPath` in `src/safePath.ts` replaces the old helper for all `from`-destination validation. It parses the candidate against the current origin using the URL constructor and checks that the result is same-origin — defeating backslash variants, tab characters, and other non-obvious spellings without enumerating them. It also validates that the normalised output is itself same-origin (fixed-point check), since URL normalisation can in principle manufacture a hostile value from a same-origin input. The function returns the normalised path rather than the caller's original string.

## IDP-M7c hand-off receiver

The shell registers `/auth/handoff` before its authentication guard. The route can therefore establish a session when none exists and replace a stale session that is still present in browser storage. It receives a single-use ticket in the URL fragment as `#h=<token>`.

This receiver is the only return path from the central login application.

### Bootstrap scrub and observability gate

`main.tsx` imports `handoff/bootstrapScrub.ts` before every other module. The import captures the ticket and removes the fragment with `history.replaceState` before React, feature modules, or observability initialise. `main.tsx` contains no JSX so the compiler cannot inject `react/jsx-runtime` ahead of that import.

If the browser refuses `replaceState`, the shell does not redeem the ticket and does not initialise observability for that page load. The **Sign-in can't complete here** card is best-effort: `BrowserRouter` also calls `replaceState` during initialisation, so the same restricted browser can fail before React renders the card. The security decision does not depend on the card appearing; the ticket remains unredeemed and expires within 60 seconds.

### Redemption and navigation

The page consumes the scrubbed ticket once and sends it in the JSON request body:

```http theme={null}
POST /api/login/auth/handoff
Content-Type: application/json

{ "token": "<JWS>" }
```

In a deployed build the request is pinned to the serving origin. A Vite development build can use `VITE_STRALOS_LOGIN_API_URL`, which defaults to `http://localhost:3400`.

Before sending the POST, the shell calls `clearSession()` to end any previous regional session. This step runs after confirming a ticket is present but before the network call, and it applies regardless of what the redemption returns. A ticket has been presented, so whoever is arriving arrives as its subject; the session in `localStorage` belongs to whoever was here before and no redemption outcome can turn it back into the right answer. Without this step, every failure arm would leave the old session intact, and the only path off the error screens (`Back to sign in` → `/login`) redirects an authenticated visitor straight to the dashboard, silently returning them to the previous identity — which may be an employee support session — after a sign-in they watched fail.

The clear runs after the `missing` guard (when no token is present on the URL). A reload of `/auth/handoff` after a hand-off has already consumed its token arrives with no ticket; clearing there would sign out the session that just succeeded. `clearSession()` drops the old refresh token from browser storage rather than revoking it at the server. The ticket expires in under 60 seconds, so a revocation round trip before the POST would spend that budget and could itself fail; the family expires on its own.

On `200`, the shell validates the session response, stores the access token, refresh token, and user, and reads each value back. It then performs a document navigation so all feature bundles start with the new session. The receiver consumes an optional same-origin destination from `sessionStorage` under `stralos.handoff.from`; an absent or invalid value falls back to `/dashboard`.

The ticket is single-use, so the shell never retries it automatically.

### Hand-off outcomes

| Outcome | Shell behaviour | User action |
| - | - | - |
| Ticket missing or `401` | Shows **This sign-in link has expired**. | Click **Sign in** to start again. |
| Session cannot be stored | Shows **We couldn't save your session** without a retry control. | Allow site data or free browser storage, then sign in again. |
| `503`, network failure, or unusable response | Shows **Sign-in is temporarily unavailable**. | Click **Try again**. |
| Fragment cannot be scrubbed | Does not redeem the ticket or start observability. The explanatory card may not render in the restricted browser. | Reopen the site in a normal window or allow site data. |

## AI copilot chat panel (`stralos-ui`)

The `ChatPanel`, `DraftReviewCard`, and supporting components in `stralos-ui` form the
prop-injected presentational layer for the AI copilot. The assistant remote owns network calls, state, route
gating, and the 401-refresh seam. The shell owns the toggle button, availability bridge, and toggle-state persistence. See [AI copilot frontend](https://help.stralos.app/internal/ai-copilot-frontend.md) for the
component and review-card contract, and
[AI copilot shell draft controller](https://help.stralos.app/internal/assistant-shell-draft-controller.md) for the shell-side toggle and bridge.

## Estimate feature module structure

`stralos-feature-estimate` splits the estimate workspace across per-tab modules. Each panel is a self-contained file; shared types, constants, and primitives live in dedicated modules.

| Module | Responsibility |
| - | - |
| `EstimatePage.tsx` | Top-level route component and orchestration across panels. Owns shared state hooks (`useWorkItems`, etc.) and renders the correct panel for each tab. |
| `EstimatePageHeader.tsx` | Estimate workspace header: name, status badge, working-version badge, lifecycle action bar, **Versions** button, and **Estimate settings** button. |
| `EstimateVersionsModal.tsx` | Estimate versions modal: list saved working-estimate snapshots, save the current state as a new version, and create a working estimate from a historical version. |
| `SummaryPanel.tsx` | Overview tab: resource/category aggregation tables and the commercial build panel. |
| `ProjectDocumentsPage.tsx` | Project Documents page (`/projects/:projectId/documents`): document list, folder tree navigation, drive sync strip, upload, per-area import, and the suggestion review flow. Replaces the former Documents tab. |
| `FolderPathField.tsx` | Breadcrumb combobox for assigning a document to a folder path. Autocompletes against existing folders; offers to create the path when it matches none. |
| `FolderTreePanel.tsx` | Left folder-tree rail on the project Documents page. Every node is a router link; selection lives in the `?folder=` query parameter. |
| `ImportDataModal.tsx` | Per-area import modal: upload one or more files directly into a specific estimate processing area. Navigates to the project Documents review page once all uploads are queued. |
| `TakeoffPanel.tsx` | Takeoff tab: drawing viewer, measurement editor, loupe, and page navigator. |
| `WorkItemsPanel.tsx` | Work items tab: bulk action bar, grid header/navigation/format helpers, and the grid and component rows. |
| `WorkItemDetailPane.tsx` | Work item detail pane: expanded build-up editor, rate-source picker, pricing controls, inline component editors, and the quick-add row. Extracted from `WorkItemsPanel.tsx`. |
| `WorkItemNavigation.tsx` | Left navigation rail for the work items split view: section headings, item rows with status and totals. |
| `WorkItemUnitSelect.tsx` | Catalogue-constrained unit combobox for work item unit fields. Shows the symbol in the closed field; full label (symbol) in the open list. |
| `WorkItemsPricingPanel.tsx` | Pricing tab wrapper: renders `WorkItemNavigation` + `WorkItemsPanel` wired through `usePricingControls`. |
| `VariationsPanel.tsx` | Variations section: contract-variations cluster including panel, list, detail view, assessment, lines table, and editor modals. |
| `ProjectedPanel.tsx` | Projected final account panel: frozen contract baseline with live variation adjustments folded in, item by item, plus pure additions and rolled-up totals. |
| `TenderItemsPanel.tsx` | **TAGs / Departures** tab (tender allowances, assumptions, and clarifications): panel, clause group list, and clause editor modal. |
| `ProgrammePanel.tsx` | Programme tab: Gantt wiring and programme date helpers. |
| `SubcontractsPanel.tsx` | Subcontracts tab: packages, bid portal links, and contractor management. |
| `SubcontractorPortalPage.tsx` | Contractor portal page (`/contractor-portal/:portalToken`) and legacy deep-link redirect components (`SubcontractsRedirect`, `VariationsRedirect`, `SummaryRedirect`). |
| `ProjectRatesPage.tsx` | Project rates page (`/projects/:projectId/estimate/rates`): per-project rate overrides, import, and editor modal. |
| `SuggestionsReview.tsx` | Document suggestions review and accept-pipeline orchestration. |
| `pricingEngine.tsx` | `usePricingControls` hook, pricing persistence types and helpers, and pricing calc/trench glue functions. |
| `lifecycleActions.ts` | Pure gating logic for estimate-page lifecycle actions: derives the available action set from estimate status, project status, and admin flag. No React or API client imports. |
| `lifecycleUi.tsx` | Lifecycle action UI: estimate and project status transition dialogs and the lost-reason form. |
| `workItemEditors.tsx` | Work-item editor modals and inline grid editors (rate source picker, markup editor, component editor). |
| `workItemsSplitView.tsx` | Work items split-layout primitives and pure helpers: `SelectionResolution`, `WorkItemsSplitLayout`, drag-reorder helpers, and the `runMutationThenRefetch` / `persistWithRollback` utilities. |
| `workItemLibrary.ts` | Work-item name suggestions. `useWorkItemLibrary` loads suggestions alongside the complete project and client-company registers, then builds the `client · project` sublabel shown in the name combobox. A register failure leaves the suggestion usable but omits its sublabel. |
| `quarrySupplierRoutes.ts` | Route-failure wording for the quarry panel. `describeQuarryRouteFailures` groups the sites a measurement could not answer by remedy and returns one `QuarryRouteFailureNotice` per remedy, naming the affected sites. Distance is measured per supplier site by `measureProjectLocationRoutes` in `api.global.ts`. |
| `quarrySupplierAnalysis.ts` | `useQuarrySupplierAnalysis` hook. Drives the **Closest quarry suppliers** panel on the estimate Overview tab. Collects every site a quarry price names from the pricing engine state, calls `measureProjectLocationRoutes` (an all-locations price sends its company id so api-global adds the company's sites), names each unmeasured site from the rate option, and exposes the resolved `QuarrySupplierAnalysisState` — including `routeFailures`. |
| `importBuildUp.tsx` | Build-up import UI: `ImportBuildUpChoices` (inline create-time checkboxes) and `ImportBuildUpModal` (into-existing-item dialog with period picker). Both produce a `WorkItemImportRequest`. |
| `InlineNameEditor.tsx` | Inline rename control used for work items, activities, rates, and section titles. Shows past-work-item name suggestions when `suggestWorkItems` is enabled. |
| `takeoffGeometry.ts` | Takeoff quantity calculations: `calculateTakeoffQuantity`, polyline length, polygon area, and `formatTakeoffUnit`. Extracted from `TakeoffPanel.tsx`. |
| `takeoffSubComponents.tsx` | Takeoff sub-components: `TakeoffLoupe` and related UI fragments. Extracted from `TakeoffPanel.tsx`. |
| `suggestionAccept.ts` | Suggestion acceptance logic: `AcceptContext`, `AcceptExtras`, draft helpers, and the accept-pipeline functions. Extracted from `SuggestionsReview.tsx`. |
| `suggestionCard.tsx` | `SuggestionCard` and related display components. Extracted from `SuggestionsReview.tsx`. |
| `similarWorkItems.tsx` | `copyCompositionComponents` and the similar-work-items picker modal. Extracted from `SuggestionsReview.tsx`. |
| `estimateTypes.ts` | Shared domain type aliases derived from the API client. No runtime exports. |
| `estimateConstants.ts` | Shared configuration constants (grid templates, fallback lists, sub-navigation, rate-refresh badge text). |
| `estimateShared.tsx` | Shared modal/form primitives (`ModalFrame`, `ModalError`, `ModalSuccess`, `ModalActions`, `Panel`, `Field`, `useIsDirty`, `ConfirmActionModal`). `ModalError` is an assertive live region (`role="alert"`, `aria-live="assertive"`, `aria-atomic="true"`). Render it unconditionally — pass `message` as `undefined` or `null` when there is no error; never guard it with `{error ? <ModalError/> : null}`. The region is empty (out of flow, not `display:none`) when `message` is blank, so the browser registers the node before any failure lands. A live region inserted at the same time as its content is not reliably announced by screen readers. Do not nest one `ModalError` inside another live region. |
| `guardedLoad.ts` | Navigation-safe async load guard: `useGuardedLoad` and `useVisitGeneration`. See [Load guard](#estimate-feature-load-guard) below. |
| `navContext.ts` | `publishActiveEstimate` — publishes or clears the active estimate id on `window.__STRALOS_NAV__` so the AI copilot can bind its advisory premise to the open record. |
| `CalculatorsPanel.tsx` | Project-level calculator list and editor panel. Renders the grid of calculators for the `/estimate/builder/calculators` route, inline rename and copy/delete actions, and the `CalculatorAssignmentsSheet` reached from a split destination. Also mounts `CalculatorModalHost` for the project-level calculator editor and passes `renderLayerAssignment` so the **Used by** column appears on every layer. |
| `CalculatorAssignmentsSheet.tsx` | Side-sheet frame for the shared layer-destination editor. It is used from the Calculators page and calculator workbench, while the work item's **Calculations** tab embeds the same body. The sheet keeps the calculator geometry visible, applies all changed destinations with **Save destinations**, and guards dirty or in-progress closes. |
| `CalculatorRename.tsx` | Inline rename control for a project-level calculator. It trims the value, refuses a blank name, and sends a name-only update through `saveCalculator`; names are not unique. |
| `calculatorsApi.ts` | Project-level calculators API client (`calculatorsClient`). Covers CRUD, copy, and assignment-link (`/calculators/:id/links`) endpoints. Defines `CalculatorDestination`, `CalculatorLink`, `CalculatorLinkInput`, `ProjectCalculator`, and the `CALCULATOR_OUTPUT_KEYS` / `CALCULATOR_OUTPUT_UNITS` constants for five named physical outputs (`inSituM3`, `looseM3`, `tonnes`, `areaM2`, `lengthM`). |
| `calculatorAssignments.ts` | Pure assignment-control logic (D62/D64/D66/D67). `groupDestinations` builds the grouped destination list shown in the **Used by** picker: the focus work item first, then all other work items and their eligible activity containers grouped by estimate section in the outline's own order, then unsectioned items last. `layerCatalogue` derives the layer list from a stored calculator config using the same engine builders the server runs, so only layers that actually emit a row are offered. `describeRefusal` converts the server's `422` refusal codes into the heading-and-detail sentences rendered inside the modal. |
| `layerAssignment.ts` | D72 per-layer **Used by** slot decisions. `layerAssignmentView` maps the current register to one of three states — `unassigned`, `fed` (one destination), or `split` (a stratum partitioned across multiple destinations). `layerAssignmentChoices` prepends an **Unassigned** row to the destination list so a mistaken assignment can be cleared. A pick is not written when it is made. It is held as a `PendingAssignment` until the calculation is saved. On **Save**, `partitionPending` judges each held choice against the saved calculator's layer catalogue: applicable, producing no quantity, or deleted. `applyPendingLinks` then folds every applicable choice into the whole register and sends it as one `PUT /links` body. A held **Unassigned** becomes an empty row set for that layer, and a layer split across destinations refuses the change. Bulk-dig layers (`pit-excavation`, `vol-excavation`) are never offered: `layerCatalogue` in `calculatorAssignments.ts` drops them. |
| `LayerAssignmentControl.tsx` | The per-layer **Used by** cell rendered inside calculator modals. `LayerAssignmentControl` renders a combobox for unassigned or singly-assigned layers; a read-only allocation summary with a link to the D67 split editor for stratum-split layers; and a statement (not a picker) for jsonb-backed calculators whose layers feed only the item they are stored on. `useLayerAssignments` manages the link register state for an open modal without touching the calculator draft, so an assignment save never closes or re-seeds the editor. |
| `calculatorProvenance.tsx` | `CalculatorProvenanceLink` — the visible name-and-link rendered on every calculator-fed component row in the work-items grid, mobile cards, and detail pane. Reads `calculatorId` and `calculatorName` from the server-enriched component; falls back to a `"Calculator"` label when the name is absent. `stopPropagation` is load-bearing: without it, clicking the link inside the detail pane's row would also trigger the inline name editor behind the dialog. |

### Layer assignment control

The **Used by** column in a calculator modal is a per-layer slot injected by the host through the `renderLayerAssignment` prop on `CalculatorModalHost`. The UI package takes a slot — not a fetch — and renders whatever node the host returns inside each layer's row.

`WorkItemsPanel` mounts `CalculatorModalHost` for project-level calculators, which pass `renderLayerAssignment`. Legacy mounts for the embedded component-stored and work-item-level jsonb calculators remain in place for any surviving unconverted data, but the Estimate API no longer writes those columns — all embedded configs were converted before migration 0058 deployed.

**Destination grouping.** `groupDestinations` in `calculatorAssignments.ts` builds the destination list in the following order:

1. The focus work item (the item whose row was clicked to open the modal), if any.
2. All other work items and their eligible activity containers, grouped by the estimate's own section headings in the outline's order.
3. Unsectioned items last.

Previously every non-focus item appeared under a single "Other work items" heading regardless of section. Items within each section group are sorted by item number.

**Unassigned row.** `layerAssignmentChoices` prepends an **Unassigned** row as the first real option in the picker. Selecting it holds a clear for that layer, like any other pick. Nothing is written until the estimator saves the calculation. `flushPendingAssignments` in `useLayerAssignments` then runs after the config write, and `applyPendingLinks` sends that layer with no links. The result is an explicit unassignment that persists, rather than a layer re-seeded from the calculator's default. This replaces the previous placeholder behaviour where the unassigned state was not selectable.

**Split strata.** A stratum that D67 has partitioned across more than one destination shows its allocations as a read-only summary and links to the split editor instead of offering a whole-layer combobox. Offering a combobox there would silently collapse the partition to a single destination with no undo.

### API client modules

`api.ts` is a named-export barrel. The public surface it previously exported is now implemented across flat sibling modules:

| Module | Responsibility |
| - | - |
| `api.ts` | Barrel re-exporter. Re-exports by name so every `from "./api"` import stays stable and Module Federation can statically enumerate the surface. |
| `api.client.ts` | `ApiError`, `ApiErrorBody`, and the internal request helpers (`authedFetch`, `parseResponse`, `apiGet`, `apiJson`, `apiDelete`, `apiBase`, `withListQuery`). |
| `api.types.ts` | Feature-local type aliases: `WorkItemItemKind`, `EstimateWorkItemComponent`, `EstimateWorkItem`, `EstimateBidRate`, `EstimateSettings`, `EstimateWithSettings`. |
| `api.global.ts` | Global project and contractor endpoints (`getProject`, `getProjectContractProfile`, `listSettingListItems`, `listContractors`, `upsertContractor`). It reads client and supplier companies from the Companies register: `listAllClients` pages `GET /api/global/parties?role=client`; `listAllSuppliers` pages `GET /api/global/parties?role=supplier` and `GET /api/global/locations` to assemble `SupplierCompany` records with their earliest active site. `measureProjectLocationRoutes` calls `POST /api/global/projects/:id/location-routes/resolve` with `partial: true`, falling back to the cached `GET …/location-routes` read. The retired `/clients` and `/suppliers` register routes are not used. |
| `api.rates.ts` | Project rates and activity endpoints (rate categories, rate items, project rate overrides). |
| `api.estimate.ts` | Estimate-scoped endpoints: work items, components, sections, programme, subcontracts, pricing, and snapshot. |
| `api.variations.ts` | Variations and contract variation endpoints. |
| `api.assessment.ts` | Variation assessment endpoints and types (`AssessmentVerdict`, `AssessmentResult`, `EstimateAssessmentRun`). |
| `api.versions.ts` | Estimate working-version endpoints: `listEstimateVersions`, `saveEstimateVersion`, `createEstimateVersionFrom`. |

## Estimate feature load guard

`guardedLoad.ts` provides structural protection against stale async loads when the user navigates between projects or estimates. The same staleness defect was found in eight hand-written loaders across multiple review rounds; the guard centralises all three required checks.

### Why a visit and not an id

The first version of the guard keyed on the project or estimate id. That is wrong because **an id recurs**: after navigating A → B → A there are two distinct visits to A, but the id is identical in both. A stale invocation captured during the first visit can fire its callback after the return to A; an id check accepts it because the id matches again. `useVisitGeneration` issues a strictly increasing counter per navigation key change — a visit generation never recurs, so both stale responses and stale invocations are rejected.

### `useVisitGeneration(key)`

Call once at the top of the route component. Bumps the visit counter during render (not in an effect) whenever `key` changes, so callbacks created in the same render already carry the new visit number.

```ts theme={null}
const { visit, currentVisitRef } = useVisitGeneration(projectId);
```

| Return | Description |
| - | - |
| `visit` | The current visit number. Pass this to `useGuardedLoad` calls and capture it in callbacks. |
| `currentVisitRef` | Stable ref whose `.current` always reflects the current visit. Pass this to `useGuardedLoad`. |

### `useGuardedLoad(currentVisitRef)`

Call once **per loader** — each call owns its own monotonic token so a documents load and a summary load cannot cancel one another.

```ts theme={null}
const loadSummary = useGuardedLoad(currentVisitRef);

// Inside an event handler or effect:
loadSummary(
  visit,          // the visit the CALLER was created in
  () => fetchSummary(estimateId),
  (data) => setSummary(data),
  (err) => setError(err),
);
```

The guard enforces three checks in order:

1. **Refuse at issue time** — if the caller's `visit` differs from `currentVisitRef.current`, the load is not started. Checking only after the await would bump the shared token and cancel the current load before discarding the stale one.
2. **Per-loader monotonic token** — each issued request increments an internal counter. An earlier response from the same visit cannot overwrite a newer one.
3. **Visit check on commit** — the response is discarded if the visit changed while the request was in flight.

For the current Activities remote routes, package pins, and list scroll behaviour, see [Activities frontend](https://help.stralos.app/internal/activities-frontend.md).

## Activities feature module structure

`stralos-feature-activities` splits the activities library by concern. Shared types, constants, and primitives live in `activitiesShared.ts`.

| Module | Responsibility |
| - | - |
| `ActivitiesPage.tsx` | Owns route data, paged and whole-library activity reads, and row actions. |
| `ActivityCalculatorPage.tsx` | Full-page library calculator workbench used by stored-calculator and new-draft routes. Nothing is written until Save. |
| `activitiesPages.ts` | Re-exports `ActivitiesPage` and `ActivityCalculatorPage` as one lazy module so the list and workbench build into one chunk. |
| `ActivityRows.tsx` | Renders the desktop table (`ActivityTable`) and mobile cards (`ActivityCard`) from the same labels, controls, quantity formatting, and calculator provenance. The desktop table uses `DataTable` with a `pagination` footer for load-more; the mobile arm uses `InfiniteList`. Both receive `totalCount`, `isLoadingMore`, and `onLoadMore` from `ActivitiesPage`. |
| `ActivityEditorModals.tsx` | Edits activities and components, including production profiles and basis-unit confirmation. |
| `PackagePricingSection.tsx` | Manages package pricing, margins, computed sell, and publish or unpublish confirmation. |
| `ProfilesEditor.tsx` / `profilesShared.ts` | Render, validate, order, and persist production profiles. |
| `activitiesShared.ts` | Holds shared models, display constants, validation, and action-error wording. |
| `api.ts` | Implements Activities, Rates, Global, and calculator HTTP calls used by the feature. |
| `calculatorRegistry.tsx` | Selects and hosts trench, pit, and volume modals (imported from `@stralos-co-nz/calculator-ui`), loads their catalogues, and handles create, save, rename, and remove actions. |
| `calculatorRouting.ts` | Defines the workbench route paths and helpers: `ACTIVITY_CALCULATORS_PATH`, `activityCalculatorPath`, and the `ActivityCalculatorReturn` state type used for the back-navigation link. |
| `calculatorLeaveGuard.tsx` | `useCalculatorLeaveGuard` prompts **Discard changes?** through `useBlocker` when leaving the workbench and through `beforeunload` on reload. The Save path bypasses the guard through `allowLeave`. |
| `activityDestinations.ts` | `loadAllActivities` pages through the whole library for **Used by** pickers, independently of the search-filtered grid. |
| `calculatorLibrary.ts` | Calls the library-calculator routes: list, read, create, update (including rename), replace links, and substance usage. There is no copy or delete. |
| `libraryCalculatorEditor.ts` | Normalises stored configurations for the shared modals and derives the engine's layer vocabulary. |
| `calculatorAssignments.ts` | Builds and validates assignment drafts, destinations, outputs, shares, warnings, and refusal messages. |
| `CalculatorAssignmentsDialog.tsx` | Edits calculator layer assignments and names. |
| `layerAssignment.ts` / `LayerAssignmentControl.tsx` | Read and update one layer's **Used by** assignment without replacing the geometry draft. |
| `fillMaterials.ts` | Maps Bulk Materials summaries to `FillMaterialOption[]`, preserving both density bounds. |
| `calculatorRows.ts` | Materialises selected rate options into a calculator before save; the server owns row generation and reconciliation. |
| `activityComponentOptions.ts` | Builds component choices from rate items and child activities. |
| `RateSheetsPage.tsx` | Lists and manages saved rate sheets; opens the sheet detail side panel. Gated behind the `rate-sheets` PostHog feature flag. |
| `NewRateSheetPage.tsx` | Package selection step when creating a new rate sheet from the Rate sheets page. Gated behind the `rate-sheets` flag. |
| `CreateRateSheetPage.tsx` | Sheet details form (name, client, adjustment, logo, terms) for creating a new rate sheet. Gated behind the `rate-sheets` flag. |
| `RateSheets.tsx` | Shared sheet preview, duplicate form, and refresh-changes components used across rate sheet pages. |
| `RateSheetRefreshDialog.tsx` | Confirmation dialog that shows per-line diffs before writing a sheet refresh. |
| `rateSheets.ts` | Error mapping and presentation helpers for rate sheet actions. |
| `rateSheetRouting.ts` | Route path constants and helpers: `RATE_SHEETS_PATH`, `NEW_RATE_SHEET_PATH`, `rateSheetPath`. |
| `rateSheetsFeatureFlag.tsx` | `useRateSheetsFlag` hook and `withRateSheetsFlag` HOC that redirect to `/activities` when the flag is off or unresolved. |
| `ActivityRatesSection.tsx` | Inline rate-items panel rendered within an activity editor. |

## Activities list scroll containment

From `@stralos-co-nz/ui` 0.69.1, the Activities list and the New rate sheet package picker scroll within their own bounded box rather than scrolling the full page. Each list owns its scroll container, so the page header and navigation remain fixed while the list scrolls independently.

Sections that opt out of this containment and retain page-level scroll (`scroll="page"`) are:

* Rate sheets list and detail (shown together on one page)
* Activity rates and production profiles sections
* Sheet preview and the rate sheet refresh report

The three rate-sheet pages (`RateSheetsPage`, `NewRateSheetPage`, `CreateRateSheetPage`) are gated behind the PostHog feature flag `rate-sheets` (fallback: `false`).

`withRateSheetsFlag` wraps each lazy page component. The HOC holds the page unmounted until the flag resolves, then either renders it or redirects to `/activities`. This prevents the wrapped pages from issuing data-loading requests before the flag is known.

Redirect behaviour by flag state:

| Flag state | Outcome |
| - | - |
| Loading (PostHog not yet settled) | Page stays unmounted; no redirect. |
| `true` | Page renders normally. |
| `false` | Redirect to `/activities`. |
| PostHog unavailable (`window.__stralosPosthog` is `null`) | Redirect to `/activities`. |
| Flag evaluation fails (`errorsLoading`) | Redirect to `/activities`. |

Enable the flag in PostHog targeting for an organisation to make the Rate sheets page accessible to its members.

## Activities calculator save model

Nothing about a calculator reaches the server until the estimator presses **Save** in the modal. Opening the modal does not create a calculator record. All choices — geometry edits, **Used by** assignments, and renames made inside the modal — are held in client state and flushed in a single request sequence when Save is pressed.

`CalculatorEditorState` tracks what the modal is open on. `stored` is `null` until a calculator has been saved; `config` is the geometry as last saved (or the engine's defaults for a new calculator); `name` is the estimator's typed name, or `null` if untouched (omitted from the create so the server allocates "Calculator n").

On Save, `saveCalculator` runs:

1. Calls `POST /activity-calculators` (create) or `PUT /activity-calculators/:id` (update) with the geometry and, when changed, the name.
2. If any **Used by** choices are held (`pendingRef`), calls `flushPendingAssignments` to write only the applicable choices via `PUT /activity-calculators/:id/links`.
3. Closes the modal only when both steps succeed. A refused create leaves the estimator's geometry on screen.

A rename made via the header pencil inside the modal is held the same way — it rides the geometry PUT rather than sending a name-only request at the moment of typing.

## `CalculatorAssignmentsDialog` layout and warning behaviour

`CalculatorAssignmentsDialog` (opened from an activity's action menu) lists every calculator feeding the activity and lets the estimator assign or reassign each layer.

**Single footer Save (F-CALC-01):** one **Save destinations** button sits in the side-sheet footer beside **Cancel**. It saves every calculator whose destinations differ from the stored register in a single action. The button is disabled while nothing has changed. Previously the button appeared inside each calculator's card; moving it to the footer keeps the save action consistent across the editor.

**Warning display:** a successful save that returns allocation warnings (`warnings` from the service, `engineWarnings` from the engine) keeps the dialog open so the estimator can read them. Warnings are displayed as orange-bordered notices. The dialog does not auto-close after a warned save; the estimator closes it manually once they have read the messages. Common warning messages:

* `"<Layer> is N% allocated; the other N% lands nowhere."` — a partitioned stratum whose shares sum to less than 100%.
* `"N m³ of <layer> is not allocated anywhere."` — the engine's own under-allocation report.
* `"The reinstatement stack asks for N m³ more material than this excavation produces, so that much has to be bought in."` — reuse exceeds available excavation volume.

**Multiple-calculator saves:** when more than one calculator is shown, each is attempted in sequence. A refused calculator keeps its drafts on screen beside the refusal that names it. A calculator that saved successfully still triggers a row refetch even if another was refused.

## Activities layer assignment control

`LayerAssignmentControl` (`src/LayerAssignmentControl.tsx`) implements the **Used by** column inside each calculator modal row. It holds choices in host state and applies them when the calculator is saved.

### Assignability states

`layerAssignability` (from `src/layerAssignment.ts`) decides what the control renders for a given layer:

| State | Condition | Control renders |
| - | - | - |
| `ready` | Layer is saved and the engine emits rows for it | Activity picker — choice is held until Save |
| `unsaved` | Layer is not in the saved config (`layerPresence` returns `absent`) | Activity picker with "Applied when you save this calculation." message — choice held until Save |
| `no-quantity` | Layer is in the saved config but the engine emits no row (`no-output` or `declared-only`) | Reason: "produces no quantity" — no picker |

The `no-quantity` state covers two distinct sub-cases detected by `layerPresence`:

* `no-output` — the layer is in the saved config and the engine emits a row for it, but the row carries no outputs (zero depth, or a bedding layer fully displaced by a pipe). The layer is saved; the geometry simply produces nothing.
* `declared-only` — the layer appears in the stored config but the engine emits no row for it at all (e.g. the engine could not derive a measurement from the current geometry). This is "saved but produces nothing", not "not saved yet".

The distinction matters for error messages: `absent` means "not saved yet" (correct for a just-added layer); `declared-only` means "saved, but produces no quantity" (which previously read as "not saved yet" before this fix, misleading the estimator about what they needed to do).

`configDeclaresLayer` performs a generic recursive walk of the stored config object, matching any `id` field value — rather than a per-arm enumeration of layer arrays. This ensures new singleton layer fields added to future calculator types do not silently fall through to the "absent" arm.

### All choices are held until Save

Every **Used by** choice — whether the layer is new, existing, or being cleared — is held as a `PendingAssignment` in host state until Save. The previous model wrote immediately when the layer was already saved; this caused the estimator to write partial state before pressing Save and made the two paths (saved vs unsaved layer) diverge silently over time.

`flushPendingAssignments` runs after the config write. It calls `partitionPending` against the post-save vocabulary and classifies each held choice:

* **`applicable`** — the layer exists in the post-save vocab and the choice can be written. Pending clears (`activityId: null`) are applicable whenever the layer is still present.
* **`noQuantity`** — the layer is saved but the engine emits no row. The choice is not applied; the estimator is told which layer and why. The cell also shows this reason, but the modal that contains it closes on a successful Save, so the message is raised at the save boundary.
* **`missing`** — the layer no longer exists in the post-save config. The choice is silently dropped; a deleted layer's links are gone with it.

Dropped choices (`DroppedChoices` exception) are raised at Save, not in the cell, because a deleted layer has no cell left to render into. The shared modal catches the throw and shows the message inline without closing.

### `layerPresence`

`layerPresence` is the shared decision for whether a layer is known to the saved calculator. Both `layerAssignability` (at the cell) and `partitionPending` (at the flush) call it. The four values are:

| Value | Meaning |
| - | - |
| `emits` | In the saved config; the engine produces rows with non-empty outputs |
| `no-output` | In the saved config; the engine produces a row but with no outputs |
| `declared-only` | In the stored config object but the engine emits no row at all |
| `absent` | Not in the stored config — a layer just added and not yet saved |

## Library calculator workbench routing

Library calculators open as full-page workbench routes instead of a cover dialog. Two routes are registered:

| Route | Description |
| - | - |
| `/activities/calculators/:calculatorId` | Opens a stored library calculator by ID. This is the deep-linkable address; a pasted link uses the list as its fallback back destination. |
| `/activities/calculators/new/:calculatorType` | Opens a local draft for the selected calculator arm. Nothing is written until Save. The optional `?activity=` query parameter carries the activity whose row launched the draft, which Save records as the default destination. |

Both routes are keyed by calculator. The activity ID appears only as the draft's default; a stored calculator's address does not name an activity.

`ActivityCalculatorPage` renders the calculator workbench. `useCalculatorLeaveGuard` intercepts navigation away from the page and browser reloads, asking **Discard changes?** when unsaved work is present. Save calls `allowLeave` before navigating so the guard does not block a completed save.

When a user arrives from the Activities list, router state records the list as the back destination, preserving its scroll and search state. A direct or pasted link has no prior list state, so leaving replaces the page with `/activities` instead of stepping backward through unrelated browser history.

The **Used by** pickers load the whole activity library independently of the filtered list. If that whole-library read fails, they fall back to the first page instead of appearing empty.

## Calculator row ownership

Calculator engines publish physical layer outputs without price or classification fields. The Estimate and Activities services reconcile those outputs through project or library assignment registers. Each destination work-item or activity-component row owns its rate item, rate option, unit cost, category, and rate group, so recomputing geometry does not overwrite the estimator's pricing selection.

The embedded `calculator` columns on estimate work items and components are retired (migration 0058). The Estimate API now ignores the `calculator` key on write. Rows previously generated from embedded calculators survive as ordinary components and are no longer cleared by a `null` write or by a quantity reaching zero.

## Rates input formatting

The Rates feature's `formatNumberInput` returns an empty string for `null` or `undefined`. Legacy rate items with absent nullable numeric values therefore render an empty editor field rather than the text `null`.

## Work-items-capture M2: pricing snapshot and autosave

The `usePricingControls` hook in `pricingEngine.tsx` implements the M2 pricing snapshot and autosave:

* **Live preview** — `computeWorkItemTotals` from `@stralos-co-nz/domain` runs in the browser for each work item row. Component inputs carry the same materialised fields the server uses, so preview totals match server totals.
* **Pricing snapshot (§5.0)** — on each pricing change, `toMaterialisedOverride` builds a `WorkItemPricingComponentOverride` carrying `rateOptionId`, `unitCost`, `pricedUnit` and `conversionFactor` alongside the standard override fields, plus the PRD 04 provenance block (`provenance`, `resolvedTier`, `resolvedLocationLabel`, `resolvedRule`, `resolvedRuleOutcome`). The frozen `pricedUnit` and `conversionFactor` pair is owned by the atomic pricing-rule route. Generic autosave uses sparse `PATCH /work-items/:id/pricing`, which strips that pair and preserves the server's stored values. `density` and `materialPricingUnit` are retired: the conversion is the chosen option's own `conversionFactor`, and the priced unit is `pricedUnit`.
* **Freeze rule** — once a component has a persisted snapshot, its materialised fields are read verbatim from the snapshot rather than recomputed from live rate data. A re-pick or component edit drops the snapshot version so the next flush hydrates fresh values.
* **Autosave** — a debounced flush (\~500ms) compares each work item's next materialised state with `lastSavedPricingRef`, the last state the server accepted. It sends only changed client-owned component entries, explicit `remove` entries for components that have been deleted from the work item, and `pricingSource` only when that value changed. Components absent from the patch are preserved. A `remove` entry is sent only when the component itself is gone — a component that is merely absent from the current local snapshot does not trigger a remove. Only one flush runs at a time; a flush arriving while a prior one is in flight is queued. The `snapshotDropVersionRef` guards against a slow patch resurrecting a snapshot that was already replaced, and a successful atomic selection advances the same saved baseline.
* **Input clamping** — all numeric values are clamped to declared decimal-place scales via `clampToScale` before being passed to the domain helpers. This defends against `RangeError` from `@stralos-co-nz/domain` v0.2.1's strict scaled-integer validation.
* **Subcontract bid display (§4.5)** — unit-rate bids use `bidEffectiveAmountCents` (half-up) for display totals; the raw per-unit rate is kept separately for the domain's ranking logic.
* **Explicit-pick unit-switch guard** — `selectInternalOptionWithResolutionAndSave` ignores a stale `conversion_missing` mark only when a unit-switch caller is moving the row to the selected option's own unit. `toAtomicPricingSelection` still judges the option against that target unit, so every other unpriceable selection remains blocked. `WorkItemComponentPricingSelection` declares the optional `unit` field sent by this atomic write.

## Inline grid editor (InlineQuantity / InlineMarkup)

`InlineQuantity` and `InlineMarkup` are in-place input cells used in the work-items grid and takeoff option rows. Both share the same state model.

### State model

* **`dirtyRef`** — set on the first keystroke; cleared on commit or revert. Gates the `[value]` prop-sync: a dirty cell ignores incoming prop updates so a background refresh cannot overwrite a live edit.
* **`propGenRef`** — incremented on every `[value]` effect run. A completing save checks whether the generation advanced during the await; if it did, the save does not rewind `committedValueRef` to the saved value because a fresher prop already landed.
* **`revertingRef`** — set by Escape before the blur it triggers fires, so the blur's `commit` call exits early and the edited draft is not persisted.
* **`savingRef` / `saving`** — set during the async save. The input becomes `readOnly` while saving; `aria-busy` is set to `"true"`.

### Commit rules

* Blur and Enter both call `commit`.
* A blank field or a non-finite parse is treated as a revert to `committedValueRef`, not a zero-save.
* A value equal to `committedValueRef` is a no-op (no `onSave` call).
* `InlineMarkup` treats a blank field as `null` (inherit estimate default), not zero.

### Grid keyboard traversal

Cells opt into cross-cell movement by passing a `gridCol` prop. The nearest `[data-grid-nav-scope]` ancestor is the traversal scope.

| Key | Behaviour |
| - | - |
| Enter | Commits and moves focus one row down in the same column. At the grid edge, commits in place without blurring so focus stays on the cell. |
| ↑ / ↓ | Moves between rows in the same column. |
| Tab / Shift-Tab | Moves to the next / previous editable cell in DOM (row-major) order. |
| Escape | Reverts the draft and blurs without saving. |

**Busy-cell navigation** — `aria-busy` is read via `getAttribute`, not the reflected `.ariaBusy` property (which is undefined under non-reflecting engines such as happy-dom). The current (anchor) cell stays in the candidate list while mid-save; only destination candidates are screened, skipping disabled or `aria-busy` cells so focus never lands on one mid-save.

## Project settings feature module structure

`stralos-feature-project-settings` owns the `/projects/:projectId/settings` route and is registered as a Module Federation remote. It is composed of flat sibling modules:

| Module | Responsibility |
| - | - |
| `ProjectSettingsPage.tsx` | Top-level route component. Wraps `ProjectSettingsContent` in `SettingsDraftProvider`. Loads the project and client list, renders the project-detail form with a scoped **Save project details** / **Discard** button pair and an **Unsaved changes** status line, the status badge, standalone transition action bar, admin override dialog, and the `ContractBasisCard`. Owns the `isSaving` / `pendingAction` busy gate that prevents concurrent PUTs and transition POSTs. The override side sheet uses `confirmOnDirtyClose` and `suppressDismiss` to guard the typed audit reason. |
| `SettingsDraftGuard.tsx` | Shared unsaved-changes infrastructure. `SettingsDraftProvider` maintains a registry of `{ scope, dirty }` records; any section registers via `useSettingsDraft(scope, dirty)`. When at least one scope is dirty, a `beforeunload` handler fires on hard navigation and `RouteDraftGuard` blocks same-app route transitions with a **Discard changes?** `ConfirmDialog` listing the dirty scope names. |
| `ContractBasisCard.tsx` | Self-contained card for loading and saving the contract profile (`getContractProfile`, `saveContractProfile`). Resolves the project's estimate id (`getProjectEstimate`) and fetches documents (`listProjectDocuments`) independently so a documents outage cannot block contract-form editing. Uses a monotonic `loadTokenRef` to discard responses from superseded loads. |
| `contractBasis.ts` | Pure domain module: `CONTRACT_FORM_GROUPS` (all accepted `ContractForm` values grouped for the contract-form picker), `isDeepGuidanceForm`, `isCustomForm`, `contractFormLabelError`, and `parseOverheadPercent` / `formatOverheadPercent`. No React imports. |
| `projectTransitions.ts` | Lifecycle domain module: `ProjectStatus` union, `STANDALONE_ACTIONS` map, `availableActions`, and `ADMIN_SETTABLE_STATUSES`. Compile-time drift alarm against `@stralos-co-nz/domain`'s `Project["status"]` union. |
| `scheduleColour.ts` | `isValidHexColour`, used to validate the schedule colour on the project detail form. |
| `sameOriginBase.ts` | Terminal API base-URL fallback. Exports `sameOriginFallback(devDefault)`, which returns `window.location.origin` in a browser production build and the `devDefault` localhost URL under a Vite dev server or in a Node context. Consumed by `api.ts` at the end of the four-stage URL resolution chain. |
| `api.ts` | API client for the project settings feature: `getProject`, `upsertProject`, `transitionProject`, `listClients`, `getRatePreferenceCatalogues`, `listSettingListItems`, `getContractProfile`, `saveContractProfile`, `getProjectEstimate`, `listProjectDocuments`. Exports `ApiError` (carries `status` and optional machine-readable `code`), the `Party` type (Companies register list row), and input/response types. `listClients` reads `GET /api/global/parties?role=client`. `getRatePreferenceCatalogues` reads active supplier companies from `GET /api/global/parties?role=supplier` and locations from `GET /api/global/locations?owner=all`; location ownership comes from `partyId`. The legacy `/api/global/clients` and `/api/global/suppliers` routes are removed; a call to either 404s immediately. |
| `routes.ts` | Single route registration: `{ path: "/projects/:projectId/settings", Component: ProjectSettingsPage }`. |

### API wire invariants

* **Status is server-owned.** `upsertProject` (`PUT /api/global/projects/:id`) never includes a `status` field. Status transitions go exclusively through `transitionProject` (`POST /api/global/projects/:id/transition`).
* **Nullable-clear fields.** `endDate` and `colour` on the project record, and `specialConditionsNote` on the contract profile, follow the nullable-clear pattern: an explicit `null` clears the stored value; omitting the key leaves it untouched.
* **`end_date_before_start_date` 422.** The server returns 422 with `code: "end_date_before_start_date"` when the submitted end date falls before the start date. `ProjectSettingsPage` surfaces this inline on the **End date** field and clears it when either date field is edited.
* **Contract profile GET is 404-free.** `GET /api/global/projects/:id/contract-profile` always returns a payload (with `exists: false` when no profile has been saved). The `ContractBasisCard` does not treat a missing profile as an error.
* **Concurrent-write gate.** A single `busy` flag (`isSaving || pendingAction !== null`) prevents a metadata PUT and a lifecycle transition POST running simultaneously. Both target the same project resource and their responses would race if concurrent.
* **Companies register — `role` filter is mandatory.** `listClients` sends `?role=client` and `getRatePreferenceCatalogues` sends `?role=supplier`. Omitting the filter returns all parties (clients, suppliers, and subcontractors) and silently fills the picker with companies that do not belong. The test in `api.companies.test.ts` pins this contract. A supplier is available for pricing only when `isSupplier` is true and the company `status` is `active`.

## Project status label resolution

Every surface that shows or names a project status resolves its label through the `project_statuses` setting list, using `resolveProjectStatusLabel` from `@stralos-co-nz/domain` (`projectStatusLabels.ts`). Nothing in the suite carries a value→label map; the words themselves are seeded into the list.

The list is **platform-scoped** (`organisation_id` NULL) and relabel-only — it is edited in the admin console (`PLATFORM_ENUM_KEYS` in `stralos-admin-api`), not by an organisation admin, who sees it in **Settings** but whose writes return `403`. A relabel is therefore suite-wide, not per-organisation.

`useSettingList` seeds a fallback options array while the real list is in flight — and again if the fetch fails or the list is empty — whose entries are `{ value, label: value }`, i.e. raw lowercase enum values. Echoing those verbatim puts "estimating" on a badge. `resolveProjectStatusLabel` therefore takes the hook's state as one `StatusLabelSource` object rather than a loose options array, so its `isFallback` flag travels with the options and cannot be dropped at a call site. When the flag is `true` every status goes through `formatStatusLabel`; when it is `false` the stored platform label wins.

`PROJECT_STATUS_VALUES` (same module) is the eight lifecycle values in order — `lead`, `estimating`, `submitted`, `awarded`, `active`, `completed`, `lost`, `archived` — and doubles as the built-in fallback and as the option list for status pickers.

**Usage rule:**

* Use `resolveProjectStatusLabel` whenever copy names a project lifecycle status. Pass the complete `StatusLabelSource`, including `isFallback`.
* Use `formatStatusLabel` for a status enum that has no authoritative display-name list and for unknown legacy values. If another status becomes setting-list-backed, add or use its domain resolver instead of flattening it with `formatStatusLabel`.
* Search the current source for `resolveProjectStatusLabel` and `formatStatusLabel` when auditing callers. That is the canonical caller inventory; a hard-coded list in this document would drift.

`ResourceScheduler` in `stralos-ui` remains presentational and has no setting-list loader. Its `meta.projectStatusLabel` field therefore accepts an already-resolved string, and each feature-side mapper resolves the project status before handing it to the component.

### `useSettingList` broadcast to live consumers

`useSettingList` (in `@stralos-co-nz/ui`) caches each resolved setting list in a per-session `Map` so multiple components that consume the same list key issue only one fetch. Prior to 0.14.0, a successful load (or a successful retry after an earlier failure) only reached the component that triggered it. Components mounted before the successful load — for example the header project selector — would therefore keep showing humanised fallback values for the rest of the session while a concurrently mounted page showed the platform-managed labels. The two would disagree without any visible error.

**Fix (0.14.0):** after a successful load, `broadcastSuccess` pushes the result to every currently mounted consumer of that list key. Only successes are broadcast; broadcasting a fallback could move a consumer that already holds real labels backwards. Each mounted `useSettingList` instance registers a subscription on mount and removes it on unmount; the broadcast checks the subscription set to avoid updating an unmounted component.

**Consumer rule:** no change is required in consumer code. The fix is internal to `useSettingList`. The existing `isFallback`-aware label resolution (described above) remains the correct pattern for any new consumer.

### Unit-catalogue presentation values

`SettingListOption` can preserve optional presentation metadata supplied by a
loader alongside the stored value and full label:

| Field | Type | Description |
| - | - | - |
| `value` | `string` | Stored key, such as `cubic_metre`. |
| `label` | `string` | Full human-readable label, such as `Cubic metre`. |
| `displayValue` | `string` (optional) | Compact notation for constrained contexts, such as `m³`. |

The setting-list API itself now returns only values and labels. Estimate,
Activities, and Rates instead load the immutable `GET /unit-catalogue` endpoint
and adapt `symbol`, `category`, and `usage` to the shared grouped selector's
`displayValue`, `groupLabel`, and `useDescription` option metadata.

**Consumer rule:** use `displayValue` for compact unit labels, badges, or narrow
cells, and fall back to `label` when it is absent. Do not expect presentation
metadata on ordinary setting-list responses.

The Estimate remote's `useWorkItemUnits` helper applies that rule to work-item
units. Grid and card labels use the matching catalogue `displayValue` when one
exists, then fall back to `formatShortUnit(value)` for familiar built-in notation.
The editor uses `formatWorkItemUnitOption` to format the open-list option as
`label (symbol)` — for example, `Cubic metre (m³)`. This matches the format used
by the rates surfaces.

## `UnitCombobox` component

`UnitCombobox` (exported from `@stralos-co-nz/ui` from 0.72.0) is a typeable combobox for unit fields. In the activity editors it replaces `GroupedSettingSelect` at both the activity **Basis** site and the component **Unit** site; the rates editor and rates import use it for their unit cells.

Before 0.72.0, `stralos-feature-activities` and `stralos-feature-rates` each kept a local copy, and the two disagreed on a unit with no symbol: rates fell back to `formatUnit(code)`, activities showed the raw code. The shared component takes the rates behaviour. Each remote moves to it when it pins `ui` 0.72.0 or later. `ui` has no API client, so load the catalogue with `useSettingList` and pass it in as `options`.

The closed field shows the unit symbol only (e.g. `m³`). Opening the list or typing shows the full `name (symbol)` form (e.g. `Cubic metre (m³)`, `Tonne (t)`). Typing filters by symbol, full label, or stored code. The filter is inactive when the field is first opened without typing — the full catalogue is shown, matching the behaviour of a native select.

Only a catalogue option can be committed. Typing alone does not write a value; anything typed but not selected is discarded on blur, and the field reverts to showing the last committed unit.

Featured values (`metre`, `square_metre`, `cubic_metre`, `each`, `hour`) are pinned in a **Most Used** group at the top of the open list, then appear again under their own category group.

### Props

| Prop | Type | Default | Description |
| - | - | - | - |
| `value` | `string` | — | Currently committed unit code (stored value). |
| `options` | `SettingListOption[]` | — | Full catalogue, typically from `useSettingList` via `unitCatalogueLoader`. |
| `featuredValues` | `string[]` | `[]` | Values to pin under the **Most Used** heading. |
| `featuredLabel` | `string` | `Most Used` | Label for the featured group header. |
| `ariaLabel` | `string` | — | Accessible label for the combobox input. Required. |
| `disabled` | `boolean` | `false` | Disables the control. |
| `busy` | `boolean` | `false` | Stops input but keeps focus, unlike `disabled`. For an entry row that types in a loop. |
| `size` | `"xs" \| "sm" \| "md" \| "lg"` | — | Chakra UI size token. |
| `width` | `string` | — | Explicit width (Chakra token or CSS value). |
| `onValueChange` | `(value: string) => void` | — | Called with the stored unit code when an option is selected. |
| `onKeyDown` | `(event) => void` | — | Receives only keys the list did not handle, so a grid's Enter-commits and Escape-clears behaviour still works inside the field. |

## `KeyCombobox` component

`KeyCombobox` (in `stralos-shell/src/KeyCombobox.tsx`) is the shell's standard pick-from-a-fixed-list control (PRD 09, wave 6). It replaces the five native `<select>` elements the shell previously owned: the Projects page status filter, the Global Settings project editor client and status pickers, the Drive Sync settings provider picker, and the Register Drive dialog provider picker.

Typing is a way to search the list, never a way to write a new value. Opening the list always shows every option first — the filter is inactive until the user types.

The component is generic over the stored value type so callers that hold a union (such as `DriveSourceProvider` or `ProjectStatusFilter`) receive that union back from `onValueChange` rather than a bare string.

The main difference from shared `SelectCombobox` is the caller contract. `KeyCombobox` is generic over string keys and reports the chosen key through `onValueChange`; `SelectCombobox` accepts choice objects and returns the whole object through `onSelect`. Both controls fold accents when filtering. `KeyCombobox` always renders an unknown stored key as blank, while `SelectCombobox` makes token-versus-blank display configurable.

### Props

| Prop | Type | Default | Description |
| - | - | - | - |
| `value` | `string` | Required | The stored value — a choice's `id`, not its label. Widened to `string` because "nothing chosen" and "a value the list no longer contains" are both real states. |
| `options` | `KeyOption<V>[]` | Required | The options to offer. Each carries a `value` (stored key) and a `label` (display text). |
| `label` | `string` | — | Accessible name as `aria-label`. Omit when a `FormField` with the same `id` already names the control. |
| `id` | `string` | — | Wire to the enclosing `FormField`'s `id` so its label reaches the input. |
| `required` | `boolean` | `false` | Preserves the native `<select required>` form-validation guard. |
| `busy` | `boolean` | `false` | Stops input while the control keeps focus. Sets `aria-busy="true"` and `readOnly`. Distinct from `disabled` — Chromium drops focus to `<body>` on `disabled`, which breaks save-while-focused flows. |
| `w` | Chakra token | `"100%"` | Width applied to the wrapper. |
| `emptyMessage` | `string` | `"No matches"` | Message shown when a query matches nothing. |
| `onValueChange` | `(value: V) => void` | Required | Called with the stored key when the user selects an option. |

### Where `KeyCombobox` is used

| Site | Stored type |
| - | - |
| Projects page — status filter | `ProjectStatusFilter` (`Project["status"] \| "all"`) |
| Global Settings — project editor status picker | `ProjectStatusFilter` |
| Drive Sync settings — provider picker | `DriveSourceProvider` |
| Register Drive dialog — provider picker | `DriveSourceProvider` |

### Relationship to shared `SelectCombobox`

`SelectCombobox` is exported by `@stralos-co-nz/ui` and is used across remotes for choice objects. `KeyCombobox` remains a shell-local adapter for string keys. Both controls use accent-insensitive filtering; the differences are their caller value contracts and unknown-value display. Choose the component that matches the caller's value contract; do not recreate a remote-local combobox.

## `GroupedSettingSelect` component (removed)

`GroupedSettingSelect` was a native `<select>` wrapper for database-backed setting lists in `@stralos-co-nz/ui`. It had no callers, was deprecated in 0.72.0, and is removed in 0.73.0. Use [`UnitCombobox`](#unitcombobox-component) for unit fields.

## Dashboard lifecycle stats (lifecycle M4)

`stralos-feature-dashboard` owns `/dashboard`. The page combines project and active-estimate totals, an infinite project list, a lifecycle funnel, and a window-selectable win rate tile. Project rows link to the estimate workspace; the empty-state action delegates project creation to the workspace-owned `/projects?action=add-project` route.

The project list, active-estimate count, and lifecycle stats are independent requests. A list error shows the page's retry banner; retry reloads all three. A stats failure never sets that banner.

The lifecycle request uses `GET /api/global/projects/stats` and supplies the funnel and win rate tile.

### Stats endpoint contract

```ts theme={null}
// GET /api/global/projects/stats?window=30d|90d|12m
// GET /api/global/projects/stats?from=YYYY-MM-DD&to=YYYY-MM-DD
{
  countsByStatus: Record<string, number>; // non-archived projects only; absent = zero
  winRate: { window: string; won: number; lost: number };
}
```

The `window` query parameter defaults to `90d` server-side. Explicit `from`/`to` calendar-date bounds override `window`; when both are present, the server ignores `window`. The response echoes the computed window in `winRate.window` (or `"custom"` for an explicit date range); the dashboard always labels the tile with the echoed window rather than the locally selected value to avoid labelling stale counts with a new selection.

The dashboard's `getProjectStats` client (in `src/api.ts`) accepts all three parameters — `window`, `from`, and `to`. The tile UI exposes only the three window buttons; `from`/`to` are available for programmatic use.

### Graceful degradation

If the stats request fails for any reason, the dashboard falls back quietly:

* The pipeline funnel shows a dash (—) for every status count.
* The win rate tile shows **Stats unavailable**.
* The project list and active-estimate count are unaffected (they use a separate `listProjects` call).

Never treat a failed stats fetch as a fatal error. The error banner in the project list belongs exclusively to `listProjects` failures.

### Status label resolution

Status labels for the pipeline funnel, project list badges, and the Active estimates tile are resolved via `resolveProjectStatusLabel` from `@stralos-co-nz/domain`. `DashboardPage` loads the `project_statuses` setting list via `useSettingList` and passes the full list state (including `isFallback`) to `resolveProjectStatusLabel`, so labels are always humanised — never the raw lowercase enum token — even while the list is loading or unavailable.

`project_statuses` is a platform-scoped list. It can be relabelled in the admin console but cannot be modified by organisation admins. A relabel is suite-wide.

### Win rate display rule

The **Win rate by current value** tile in `PortfolioSummary.tsx` renders `valueWinRates` from `portfolioValues.ts`: won and lost outcomes in the window, valued at each project's current estimate value and grouped by currency. A window switch refetches stats but reads only the portfolio values not already cached by project id; **Retry** clears that cache. The older count-based helpers (`buildWinRateDisplay`, `activeEstimatesHelper` and the `WinRate*` types) were test-only and were deleted in feature-dashboard #170.

## Calculator modals (`@stralos-co-nz/calculator-ui`)

The `@stralos-co-nz/calculator-ui` package supplies trench, pit, and volume calculator modals over the domain calculator engine. Hosts inject disposal substances, Bulk Materials with both density bounds, and disposal rate items where that calculator surface supports them. Calculator screens, diagrams, and presentation helpers come from this package. General controls come from `@stralos-co-nz/ui`; model types and arithmetic come from `@stralos-co-nz/calculators`. Base UI 0.51.0 removes the former calculator exports from `@stralos-co-nz/ui` and the `ui/calculators` subpath.

Each modal edits geometry and physical snapshots. The engine returns layer rows with stable keys and optional outputs: `inSituM3`, `looseM3`, `tonnes`, `tonnesMin`, `tonnesMax`, `areaM2`, and `lengthM`. The two density-bound outputs are display-only; assignment registers link to the single-value output vocabulary.

Project and library calculators render **Used by** controls for assignable layers. A save updates geometry and reconciles linked destinations. A name-only update renames without saving the draft. See [Trench calculator](https://help.stralos.app/internal/trench-calculator.md), [Pit / Structures calculator](https://help.stralos.app/internal/pit-calculator.md), and [Volume calculator](https://help.stralos.app/internal/volume-calculator.md) for the engine contracts.

### Package source

The `stralos-feature-estimate` remote imports `TrenchCalculatorModal`, `PitCalculatorModal`, and `VolumeCalculatorModal` from `@stralos-co-nz/calculator-ui` 0.3.4.

The `stralos-feature-activities` remote imports the same modals from `@stralos-co-nz/calculator-ui` 0.3.4. The activities remote also imports the `UNASSIGNED_LABEL` constant and the `DisposalSubstancesState`, `FillMaterialsState`, `FillMaterialOption`, `RenderLayerAssignment`, and `DisposalSubstanceUsage` types from that package. Hosts inject disposal substances, Bulk Materials with both density bounds, and disposal rate items where that calculator surface supports them. Version 0.3.4 is the first release that admits `@stralos-co-nz/ui` 0.69.

## Project schedule: M6 resource grading (stralos-feature-project-schedule)

`stralos-feature-project-schedule` owns the `/projects/:projectId/delivery/schedule` route (Module Federation remote `projectSchedule`, dev port 4180).

### Resource grading in the assignment drawer

When the drawer opens for a create or edit action, `loadProjectResourceOptions` (in `src/resourceOptions.ts`) calls `GET /api/scheduler/projects/:projectId/recommended?from&to` and classifies each rostered resource as `recommended` or `other`.

**Classification rules** (`classifyResource`):

* If the project has **no requirements** of the resource's kind (`person`/`plant`) → `recommended`, no reasons.
* If **at least one** requirement of that kind lists the resource in its `recommended` array → `recommended`.
* Otherwise → `other`, with reasons taken from the requirement of the same kind that has the fewest reasons (the closest match). If the recommendation payload omits the resource entirely (e.g. inactive user) → `other` with an empty reasons array.

People and crews are judged against `person`-kind requirements; plant against `plant`-kind requirements. Matching uses `(resourceType, resourceId)` — a bare ID match is intentionally avoided to prevent UUID collisions across kinds.

**Degraded state:** if the recommendation fetch fails, `onDegraded` is called and `buildResourceOptions` falls back to all-`recommended` with an empty requirements array. The page surfaces a non-blocking toast: **Showing all resources — Couldn't load recommendations — resources aren't graded right now.** The warning fires at most once per drawer session (gated by `recDegradedRef`), and resets each time the drawer is opened.

**Incomplete window:** if `projectId`, `startDate`, or `endDate` is absent from the draft, no fetch is attempted and all resources are returned as `recommended`.

Saves are never blocked by a grading failure — the drawer stays fully functional in the degraded state.

### Project navigation

**Programme** and **Schedule** are both mounted in the **Delivery** nav group (navigation-migration M3). **Programme** is the first entry in the group; **Schedule** follows it.

## Current risk

The `EstimatePage.tsx` split is complete. The remaining shared hooks and orchestration logic in `EstimatePage.tsx` are intentional: it acts as the top-level route component and state coordinator. Further decomposition should target extracting individual hooks if the file grows significantly.

`api.ts`, `TakeoffPanel.tsx`, `SuggestionsReview.tsx`, and `WorkItemsPanel.tsx` have been split into flat sibling modules. The barrel in `api.ts` enumerates re-exports by name; do not switch it to `export *` because Module Federation cannot statically enumerate star barrels.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.