> ## 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.

# Workspace frontend

> Module Federation remote for the projects, companies, and locations surfaces

`stralos-feature-workspace` is the `workspace` Module Federation remote. It owns the projects list and project overview, the companies register and company detail, the locations register and location detail, and the `/clients*` / `/suppliers*` redirects.

Current shell source hands these routes to the `workspace` remote. `stralos-core` includes the `workspace` descriptor in the manifest seed and dev reconciliation set, and the shell no longer carries duplicate page implementations.

## What this remote owns

| Route | Component |
| - | - |
| `/projects` | `ProjectDirectoryPage` (via `ProjectsRoute`) |
| `/projects/:projectId` | `ProjectOverviewPage` |
| `/companies` | `CompaniesPage` |
| `/companies/:id` | `CompanyDetailPage` |
| `/locations` | `LocationsPage` |
| `/locations/new`, `/locations/:id` | `LocationDetailPage` |
| `/clients`, `/clients/:id` | Redirect to `/companies` filtered to clients |
| `/suppliers`, `/suppliers/:id` | Redirect to `/companies` filtered to suppliers |

The `/projects` and `/projects/:projectId` routes are eager (no `lazy()`); the four register pages are lazy-loaded so a user who never opens Companies or Locations never downloads them.

## Controller

`WorkspaceControllerProvider` (in `workspaceController.tsx`) reads the project a route addresses by id with `getProject`, and its client with `getClient` for the name. It also owns project and client creation. It does not read the client or project registers: the project selector reads the project directory itself. **Retry** clears a load failure or a refusal and checks the route project again.

The `persistentChrome` export keeps one `WorkspaceControllerProvider` alive across authenticated route changes. Its `Header` renders the project selector, its optional `Notice` renders workspace load and missing-project messages, and the provider also owns the active workspace route outlet. This gives the selector and pages one data set, editor state, and toast scope.

The export also provides two optional rail components, which the shell portals into its project rail slots (see [Frontend architecture — Project rail slots](https://help.stralos.app/internal/frontend-architecture.md#project-rail-slots-shell)):

* **`RailHeader`**: the open project's name, linked to its overview, then the project's status as a `StatusBadge`, then the client name, at the top of the rail. The status label comes from `useProjectStatusLabel` (the platform-managed project statuses list), and the badge is kept to one line without an ellipsis. Because the controller re-reads the project after every reported write, a status change made on another page shows here. Nothing is rendered until the project has loaded, so the shell's fallback (an **All projects** link) shows in the interim. Implemented in `WorkspacePersistentChrome.tsx`.
* **`EstimateSwitcher`** (`RailEstimateSwitcher`) — the estimate dropdown beneath the **Estimate** rail label; see below.

### Estimate rail switcher (`RailEstimateSwitcher`)

`RailEstimateSwitcher` (`src/RailEstimateSwitcher.tsx`) is the estimate-switching dropdown in the project navigation rail. It sits as `leadingContent` in the shell's **Estimate** rail section — that is, it appears directly beneath the **Estimate** label row.

**Visibility.** The switcher replaced the PostHog `multi-estimates` flag. It is shown only when `multipleEstimatesEnabled` is `true` on the response of `listRailEstimates`, which reflects the project's **Multiple estimates** setting. When the setting is off, the list has not loaded, the load failed, or the list is empty, the component renders nothing and the **Estimate** label stands alone.

**Data loading.** `listRailEstimates` is called on mount and again whenever:

* The `stralos:estimates-changed` event fires (estimate created, renamed, made primary, or deleted). The event detail carries `projectId`; events for other projects are ignored.
* The `stralos:estimate-settings-changed` event fires (the project's **Multiple estimates** setting was saved).
* The user opens the dropdown menu (to ensure the list is fresh before picking).

If a canonical estimate path names an estimate the loaded list does not include (for example, one just created in another tab), the switcher requests one more load before rendering.

**Display.** The trigger is a full-width outline button showing the name of the currently open estimate, or the primary estimate's name outside a canonical estimate path. A star (★) precedes the name when that estimate is the primary. The menu lists the live tender estimates, primary first, each with its status badge. Not-awarded estimates are grouped behind a **Show not awarded (N)** / **Hide not awarded (N)** toggle and carry a **Not awarded** badge.

**Switching.** Selecting an estimate navigates to the same screen within the new estimate. Builder screens keep the section (for example `builder/rates`); deeper ids that belong to the current estimate, such as a calculator or subcontract package, are dropped to the section root. Selecting the current estimate is a no-op.

**Manage estimates.** The **Manage estimates…** menu item dispatches `stralos:manage-estimates` (`OPEN_MANAGE_ESTIMATES_EVENT`, detail `{ projectId }`), which opens the Estimates sheet over the current page.

### Estimates sheet (`ManageEstimatesSheet`)

`ManageEstimatesSheet` (`src/ManageEstimatesSheet.tsx`) is mounted once in the persistent chrome, so it is available on every project page. Anything may open it by dispatching `stralos:manage-estimates` with `{ projectId }`: the rail's **Manage estimates…**, the estimate overview's **Manage estimates** button, and Project settings' **Manage estimates** button. It opens over the page that asked and never navigates on its own. One sheet has three views: the list, **New estimate** and **Rename**. Delete and Make primary refusals are computed client-side to mirror the server's 409s, and a duplicate is offered the name "Copy of …".

A change (new, rename, make primary, delete) fires `stralos:estimates-changed` (`{ projectId, estimateId? }`), on which the page under the sheet refreshes its data in place. The only navigations are the user's own: choosing an estimate opens it on the same screen, and deleting the open estimate moves to the primary on the same screen. The legacy `?manage=estimates` query parameter is still honoured by the estimate remote, which strips it and dispatches the same open event.

The project editor (`ProjectEditorModal`) is opened by the controller and shared between the Projects page and the project selector. Both the **New project** button on the Projects page and the **New project** button in the selector header open the same sheet, backed by the same controller state. A client created inside the sheet is reflected in both the editor and the selector without a page reload.

The remote remembers the last valid project route in a user-and-organisation-scoped `stralos.workspace.lastProjectPath.v1:*` entry. It publishes that path and the current `projectMissing` fact to the shell through the version-1 `HostChromeInput` contract. The shell validates and scopes those facts before using them for the **Projects** navigation destination or a missing-project mask. The old unscoped `stralos.nav.lastProjectPath` key is not adopted.

## Contract-v2 model

This remote follows the thin-host PRD contract v2 (D1/D3). The host provides `react`, `react-dom`, and `react-router-dom` as strict singletons; `@stralos-co-nz/ui` and `@stralos-co-nz/domain` are bundled inside the remote as exact pins from this repository's own `pnpm-lock.yaml`. A ui or domain release is a pull request here, not a suite-wide pin wave.

The singleton list lives in `.ci/shell-federation-contract.json`. `vite.config.ts` derives `SHARED_SINGLETONS` and `FEDERATION_SHARED` from it; `scripts/verify-federation-contract.mjs` refuses a `shared` entry for ui or domain, an exact pin where a range belongs, or a vite config literal outside the contract file.

UI is pinned at `0.75.4`. `@stralos-co-nz/maps-ui` is also bundled as an exact pin at `0.10.0`; it supplies the `LocationPicker` used in `LocationFields` and `ProjectForm`. Fonts are the shell's: nothing under `src/` may import a `@fontsource/*` path or ui's fonts subpath, and `build:remote` fails if `dist-remote` carries a font file.

`scripts/check-package-compat.mjs` runs before `pnpm install` in CI and refuses a lockfile that resolves a known-incompatible first-party package pair. The current guards are: when ui `>=0.57.0` is resolved, maps-ui must be `>=0.3.0`; when ui `>=0.73.0` is resolved, maps-ui must be `>=0.5.0`. maps-ui 0.1.x and 0.2.0 import `SupplierEditorModal`, which ui 0.57.0 removed, and maps-ui 0.4.0 and earlier import rate-reference exports that ui 0.73.0 removed; pnpm resolves that pair without error because the published peer ranges of those maps-ui releases still admit the newer ui. The check is a pre-install lockfile read and needs no install itself. When a ui release removes something a published downstream package imports, add the pair to `INCOMPATIBLE` in `scripts/check-package-compat.mjs`, and in the same file in every other remote that carries it.

## Build and release pipeline

The pipeline follows the same produce-gate-activate model as other contract-v2 remotes (see [Micro-frontends](https://help.stralos.app/internal/micro-frontends.md) and [Release model](https://help.stralos.app/internal/release-model.md)).

* A push to `main` triggers `deploy-dev.yml`: builds the remote, writes the `built-against.json` sidecar, publishes the by-SHA bundle to the dev remotes bucket at `remotes/workspace/<sha>/`, and nudges the stralos-core reconciler with the manifest key `workspace`.
* `build-remote.yml` builds and verifies on every push and pull request and publishes nothing.
* `deploy-test.yml` produces a version-addressed candidate for test under `/remotes/workspace/<version>/`. When called by the central release with `from_dev: true`, it reuses dev's archived bundle for the release commit through `stralos-core/frontend-dev-bundle.yml`. A manual dispatch builds a new bundle.
* `deploy-prod.yml` never builds. It promotes the bytes test already served: stralos-core's `promote-frontend-version` action reads `/remotes/workspace/<version>/` from the test origin, proves it against test's `complete.json` (version, prefix, and commit, so the `v` tag must be on the commit test produced from) and the stored claim (each object's size and SHA-256), then copies it write-once to the prod remotes bucket. A version test never completed, or completed from another commit, is refused. Every job is gated on the `PROD_DEPLOY_ENABLED` repository variable.
* `release.yml` binds a `vX.Y.Z` tag to the by-SHA bundle already produced for that commit; it never rebuilds. It passes `--vite-config vite.config.ts` and `--federation-contract .ci/shell-federation-contract.json` to `archive-remote.mjs release`, so the federation contract is re-validated against the contract file at the tag before any bytes are written. A bundle whose produce-time sidecar disagrees with the contract at the tag is refused.

Activation (making the bytes live in a manifest) is stralos-core's gate, not this repository's act.

### Routes contract

`build:remote` writes `dist-remote/routes-contract.json` alongside `built-against.json`. Every publisher syncs `dist-remote/` to `remotes/workspace/<sha>/`, so the routes contract is available at that prefix without cloning this repository. The shell can fetch it by the manifest-named version (Stage 3 of the shell-reduction federation-routes-contract programme).

The committed contract file is `.ci/routes-contract.json`. It is code-only: `remoteKey`, `contract`, `exposes`, and `routes[].path` in code order. Presentation metadata (`navLabel`, `order`, `group`, and so on) is the shell/seed's responsibility and is not emitted here.

The contract is derived and verified in `src/routes.contract.test.tsx`. That test imports the real `./routes` module, runs the vendored `deriveRoutesContract` helper over it, and asserts byte-identity with `.ci/routes-contract.json`. The vendored helpers (`scripts/derive-routes-contract.mjs` and `scripts/verify-routes-contract.mjs`) are copied byte-identical from `stralos-core`; `scripts/routes-contract-vendor.test.mjs` keeps them in sync by running a self-test that detects a one-byte drift. Adding, removing, or reordering a route fails `src/routes.contract.test.tsx` — this remote's own CI — before `build:remote` runs.

`build-remote.yml` re-asserts the published artifact carries `routes-contract.json` and that it is contract version 2 before upload.

### Size gate

`.ci/remote-size-baseline.json` holds the accepted per-file JS gzip sum (rebased for ui 0.65.0). `build:remote` fails a build more than 10 % over it. CI refuses a baseline bumped past 10 % of the base branch's unless the pull request body carries `remote-size-baseline: <new number>`.

### Deterministic builds

`ROLLDOWN_WORKER_THREADS=1` is set on the CI build step so Rolldown's parallel graph traversal produces the same chunk partition on every run. The by-SHA artifact is byte-addressed; a non-deterministic partition would produce a different valid bundle from the same commit.

## Project membership access

Projects use explicit membership. `ProjectDirectoryEntry` carries three access flags:

* `canEnter`: true when the current user is a member of the project team. The projects list shows a lock icon (`Lock` from ui) beside the name when false. The project command palette and project selector also show "Access required" and suppress the job and quote numbers for inaccessible projects. For an accessible project they show the job number, or the quote number when there is no job yet.
* `canManage`: true when the current user can administer the project team (project lead or org admin). Drives the **Manage team** button in the directory and the **Manage project team** link in the access teaser.
* `canSelfJoin`: true when the user is not on the team but the project's office is one of the user's self-assign locations. Drives **Join project** in the directory row (in place of **Request access**) and in the access teaser. Optional on the wire; absent means false.

`listProjectDirectory` returns `membershipCount` (projects the user is on), `organisationCount` (total projects) and an optional `canSeeAllProjects`, read as `true` when absent. It is `false` for a contractor person in a company they do not manage: `api-global` then lists only the projects they are on, so there is no "all" scope to show. The Projects page subtitle reads "You're on N of M projects." when `canSeeAllProjects` is true, and "You're on N projects." (or "1 project") with no company total when it is false.

### Access check window

Project-scoped reads use `createProjectAccessChecks` (in `projectAccessChecks.ts`) to decide when to send a `GET /projects/:id` access check. A confirmed check answers for `READ_CHECK_WINDOW_MS` (30 seconds): a read that arrives within that window is not treated as new evidence and does not schedule another check. This prevents a page that triggers several parallel reads from firing two to four trailing checks after the entry check has already confirmed access.

The window is voided by any outcome that is not a confirmation: a 403 or 404 refusal, a failed document load, or a route change each trigger an immediate check regardless of how recently the last one confirmed. A check that does not confirm (for example an outage 503) also voids the window so the next read checks again.

### Self-join

`SelfJoinConfirm` is the one confirm dialog ("Join this project?") shared by the directory and the access teaser. Confirming calls `selfJoinProject` (POST `/api/global/projects/:id/self-join`), which adds the caller as a member. api-global allows it only when the project's office is one of the caller's self-assign locations, returning `403 self_join_not_permitted` otherwise, and it is idempotent for someone already on the team. When `officeLocationName` is present, the dialog's consequence line reads "It is run out of \{office}, one of the offices you can join projects at." On success a toast ("Added to the project team") is raised after the dialog closes.

In `ProjectDirectoryPage`, clicking the name of a project with `canSelfJoin` opens the confirm instead of navigating to the teaser; joining then opens the project. If the server refuses the join (any `ApiError`), `onRefused` re-reads the directory, because the offer was stale.

### Access teaser

`ProjectAccessTeaser` is shown when navigating to `/projects/:projectId` and `canEnter` is false. It displays the project name, client, office ("Not set" when absent), project lead, key dates, and a request form. The user can:

* Join the project (when `canSelfJoin` is true) through `SelfJoinConfirm`; `onJoined` then re-evaluates access.
* Submit a new request via `requestProjectAccess` (POST `/api/global/projects/:id/access-requests`).
* Withdraw a pending request via `decideProjectRequest` with state `"withdrawn"`.
* Re-request after a declined request (the form re-enables when no pending request exists and the most recent request was declined).

The teaser note for a pending request reads: "Your request is pending. A project lead will decide; unanswered requests go to organisation admins after five working days." This matches the escalation window enforced by api-global.

### Project team sheet

`ProjectTeamSheet` is a `SideSheet` (size `lg`) that loads team and request data in parallel via `getProjectTeam`, `listProjectRequests`, and `getProjectAccessHistory`.

`getProjectTeam` returns `items` (current `ProjectMember[]`) and `candidates` (org users not yet on the team). `ProjectMember.provenance` is `"migration"` for members migrated at rollout; those rows show "Added at rollout" instead of a grantedBy name and date.

Write operations use `setProjectMember` (PUT `/api/global/projects/:id/team/:userId`) and `removeProjectMember` (DELETE). Both accept an optional `replacementUserId` for the last-project-lead guard: when removing or demoting the sole project lead, a replacement must be chosen before the operation fires.

Org admins who are not yet on the team see an **Add myself to project** banner at the top of the sheet (rendered when `!project.canEnter && !members.some(m => m.userId === ownId) && user.role === 'admin'`).

Pending `ProjectAccessRequest` items (state `"pending"`) are shown at the top of the sheet. Deciding a request calls `decideProjectRequest` (PATCH `/api/global/projects/:id/access-requests/:requestId`).

Access history is loaded via `getProjectAccessHistory` (GET `/api/global/projects/:id/access-history`) and shown on demand behind an **Access history** toggle.

### Access requests inbox

`ProjectAccessInbox` is a cross-project inbox rendered in the Projects page header. It calls `getProjectAccessInbox` (GET `/api/global/project-access-inbox`), which returns `ProjectAccessInboxItem[]` — a `ProjectAccessRequest` extended with `projectId`, `projectName`, `userName`, and `canDecide`. The button label includes the count of items where `canDecide` is true. Deciding an item calls `decideProjectRequest` in the inbox context.

### Project team action

`ProjectTeamAction` renders a **Project team** button in the project overview header (via `ProjectOverviewPage`). It is only shown when `project.canManage`. It opens `ProjectTeamSheet` for the current project.

### Entry point for scope=all

Navigating to `/projects?view=access` with an admin session initialises the directory scope to `"all"` so an admin responding to an access escalation notification lands on the full organisation list.

## Projects page search

`ProjectDirectoryPage` carries a search box via `RegisterPage`. The search runs server-side: the page holds one page of 25 rows at a time, so client-side filtering would search a twenty-fifth of the register. The server matches project name, client name, quote number, and job number.

The search box debounces keystrokes by 200 ms before sending the request.

## Projects page count and range

The total matching count is displayed as a mint pill beside the page title via `RegisterPage`'s `count` prop. A subtitle shows membership ("You're on X of Y projects", or "You're on X projects" without the total when `canSeeAllProjects` is false). A range line (for example "1–25 of 27") appears in the bottom pager row. Neither the count pill nor the range line is shown while a read error is active.

## Projects page pagination and scope

`ProjectDirectoryPage` paginates in pages of 25. **Previous** and **Next** buttons appear in a single row below the table, together with a range label. When `canSeeAllProjects` is true, the page also provides scope toggle buttons (**Mine** and **All in company**); a contractor person without it sees no toggle. The page provides dropdown filters for status, office, client, and project lead. Changing scope or any filter resets pagination to page one.

The **Office** column shows `officeLocationName`, or "Not set". The **Office** filter replaced the old region text field; it sends `officeLocationId` to `listProjectDirectory`, and its options are the distinct offices across every project in the current scope (ignoring the other filters), sorted by name.

The **Office**, **Client**, and **Lead** filter choices are loaded lazily, not on every page load. The page waits until the pointer enters the filter row, a filter control takes focus, or a filter is already set (so the selected option must be present in the list). When the table's unfiltered first page (up to 200 rows) already covers the whole scope, it is reused as the options source with no further request. A larger scope pages through the directory to collect all choices, capped at ten pages of 200 rows each.

Scope defaults to **Mine**. An admin landing on the page with `?view=access` in the URL opens on **All in company**.

The empty state message ("No projects match. Browse all organisation projects to request access.") guides members who have no projects in scope.

A user can pin individual projects. Pins are stored in `localStorage` under `projectPreferences` and appear first in the project selector.

## Project navigation

`ProjectOverviewPage` reads the resolved project navigation from the host's reactive `window.__STRALOS_NAV__` snapshot. It does not rebuild links from a local manifest copy. This keeps the overview aligned with the routes the current host actually resolved, including degraded compositions.

The overview page header carries a **Back to projects** link (via `PageHeader.backTo`) to `/projects`.

## New project sheet

`ProjectEditorModal` opens as a `SideSheet` at size `lg` (640 px). The projects list stays visible beside it. The form uses a `confirmOnDirtyClose` guard: closing with unsaved changes prompts the user by name before dismissing.

### Office field

**Office** is required on create; the create action stays disabled until one is chosen. `listOfficeLocations` reads every page of `GET /api/global/locations?owner=ours&status=active`, so only the organisation's own active locations are offered. Once both reads land, the sheet pre-selects the person's own default office (`GET /api/global/me/default-office`) if it is in that list, or the only location when there is exactly one. The organisation's `defaultLocationId` is retired and no longer read. The pre-selection updates the form's baseline, so it does not count as an unsaved change.

A **Prefer resources from this office** checkbox sits beside the picker and defaults to ticked. Both `officeLocationId` and `preferResourcesFromOffice` are sent on create.

* When the office read fails, the field shows the error with a **Retry offices** button.
* When the organisation has no active locations, the picker is replaced by a note linking to the **Locations** page (`/locations`).
* When the save is refused with `422 office_location_invalid` (the save failure carries `field: "officeLocationId"`), the message appears under **Office**, the field is cleared, and the office list is re-read. Picking another office clears the message.

### Suppliers for this project step (`SearchRadiusStep`)

Once create succeeds, the sheet becomes the **Suppliers for this project** step (`src/SearchRadiusStep.tsx`), with **Save** as the primary action and **Skip for now** as cancel. The project now exists and has an address point, so supplier sites can be measured. **Skip for now** saves nothing, and pricing then steps through 50, 100 and 150 km by itself. The rules and wording live in `src/searchRadius.ts` and `src/projectSuppliers.ts`; the same distance rules are written separately in stralos-feature-project-settings's `SearchDistanceField`, so change both.

**Search distance.** A radio group offers `RADIUS_CHOICES` (5, 10, 20, 35, 50, 100, 150 km) and **Other** with a **Distance in km** input (1 to 2000 km, at most one decimal place). The choices are wrapping radios, not a `SegmentedControl`, because eight choices do not fit one line at phone width. `suggestedRadius` still steps only through 50, 100 and 150 km (`RADIUS_STEPS`), with 50 km when nothing could be measured.

**Site list.** Sites within the radius are listed by `siteLine` (company and site label, then the road distance in whole km), nearest first. `useRowCap` (`src/rowCap.ts`) caps the list at `MAX_LIST_ROWS` (8) rows, measured from the drawn rows; past that the list scrolls inside itself, becomes a focusable labelled region, and a line below it reads `scrollCue` ("Scroll to see all N sites."). A polite live region carries `countSentence`, `NO_RATES_NOTICE` or `MEASURE_FAILED_NOTICE`.

**Supplier groups.** `SUPPLIER_GROUPS` asks the same question for **Bulk materials** (`quarry_materials`) and **Disposal**: "Let Stralos pick the cheapest within N km" (selected first, saves no rule) or **Use a preferred supplier**. The picker lists suppliers with a rate in the group within the radius, nearest first (`pickerSuppliers`, which always keeps the picked supplier), with **Show all suppliers** for the rest. A group with no supplier rates disables the preferred option. A pick saves one group `source` rule with the supplier and no site (all its sites) through `PUT /api/rates/project-rate-settings/rule-set`, keeping every stored rule in order.

**Save order.** The distance is saved first; a distance failure keeps the person on the step with the error inline. A rule-set failure does not hold them: the step finishes and shows `RULES_NOT_SAVED` ("Preferred suppliers not saved"), pointing to **Project settings → Rate preferences**.

## Project selector

The project selector in the persistent chrome header shows a searchable dropdown with two panels: **Pinned** on the left and **Recent** on the right. When searching or viewing all projects, the right panel switches label to **Results**.

The trigger button always reads **Projects** with a chevron. It does not show a project name. The selected project context is tracked internally by the controller and used for smart navigation (switching projects while inside a workspace keeps the same sub-route).

### Project switch destination rules

`projectDestination(projectId)` computes the destination at render time so each row can be a real `<a>` element. The rules, in order:

1. **Inside a project route, switching to a different project** — keep the sub-path but replace the project id. If the current sub-path contains an open estimate id (the `estimates/:estimateId/…` form), strip it to the bare `estimate/…` form before swapping the project id. An estimate id belongs to one project; carrying it to another would attempt to open that project's pages under a foreign estimate id. The bare path is the estimate remote's primary resolver and lands on the destination project's own primary estimate (PRD 210 §12.2).
2. **Inside a project route, re-selecting the current project** — return the current pathname as-is, including any open estimate id.
3. **Outside a project route** — return the per-project detour stored in the in-memory history map (the last path recorded for that project this session), or that project's workspace home if no detour exists.

If the computed destination fails `isProjectChromePath` validation, the destination degrades to the project's workspace home. This keeps the row's `href` valid even when the current route carries a fragment or query string that cannot be decoded.

The `withoutOpenEstimate` function strips the estimate id: `/projects/p1/estimates/e1/takeoff` → `/projects/p1/estimate/takeoff`. It matches the `estimates/:estimateId` segment and replaces it with `estimate`, leaving the remainder of the path intact. It does not match `estimates` without a following id segment, or segments that merely start with the word `estimate`.

The popover header holds a **Mine** / **All projects** `SegmentedControl` and a **New project** button that opens the shared editor. The toggle renders only when `canSeeAllProjects` is not `false`; a contractor person sees only their own projects, so the header shows just **New project**. Scope defaults to **Mine** when the popover opens. The search input sits below the header and is always visible. The status line reports the number of results and how many are loaded, or shows "Your projects: X pinned and Y recent" when not searching.

Pinned projects come from the user's stored preferences and are resolved against the directory in bounded batches. Stale pin ids (projects the user can no longer access) are silently omitted. Pins can be toggled from both the selector and the `ProjectDirectoryPage` register.

A project row is a real `<a>` element so Ctrl/Cmd-click and middle-click open the project in a new tab rather than switching the current tab. The row for `currentProjectId` (the selected project, or the most recently opened one when none is selected) shows a teal **Current** badge, whose text is part of the link's accessible name.

The result list uses virtual scrolling (`VirtualProjectRows`). The viewport is fixed at 320 px tall and only the visible rows (plus a two-row overscan) are mounted in the DOM. The active row is always mounted regardless of scroll position, so keyboard navigation keeps it reachable. A further page is requested automatically when the user scrolls or arrows within one row of the loaded end and more results exist. Keyboard navigation via **ArrowDown** triggers the same load-more path when the active index approaches the end of the loaded set.

The footer of the popover shows **Browse projects**, an outline button linking to `/projects`, and the count "X of Y projects" ("X projects" when `canSeeAllProjects` is false).

## Project editor client picker

The **Client** field in `ProjectEditorModal` uses `SelectCombobox` from `@stralos-co-nz/ui` with two mutually exclusive create paths:

* A typed create row: when the user has typed something, the list shows `Create client "<typed text>"`. Selecting it carries the typed text into the new client's name field.
* A standing create row: when the field is empty, **New client** appears at the bottom of the list and opens the client editor with a blank name.

Selecting either row opens the client editor as a step inside the project side sheet. The client step has its own dirty guard, inline error path, and primary action.

When the client step saves, the new client is merged into the list and selected in the project form, and the sheet returns to the project step. There is no nested modal or separate dialog.

Inline outcomes are used rather than toasts: an error that fires while the sheet is open is displayed above the action row, not in a floating toast. A toast under an open sheet layer is outside the modal stack and is neither reliably visible nor read out by screen readers.

A staleness guard (`clientReadRevision`) prevents a concurrent list refresh from overwriting an optimistic client merge. The `createdClient` ref and the `onClientsChanged` callback carry the new row out to the controller on success.

### New client location

The New client step requires a location with a picked address (latitude and longitude set) and a kind. `LocationFields` is given `askKind`, which renders the **Kind** combobox empty and required with the placeholder "Choose a kind"; the caller holds the answer in `addressKind` and the draft's own kind is unused. The location name follows the chosen kind (`locationKindLabel`) until the person types their own. `clientProblem` returns "Please add the client's location." with no address and "Please choose what kind of location it is." with no kind, which keeps **Create client** disabled. The location is sent as `address` on the client create request with the company and its main contact. Only the project sheet passes `askKind`; the Companies pages and standalone location forms are unchanged.

## Project editor date fields

**Target submission** and **Proposed start date** in `ProjectEditorModal` use `DateField` from `@stralos-co-nz/ui`. The previous `<input type="date">` rendered in the browser locale — a US-locale machine showed and parsed `mm/dd/yyyy`, so a date typed as `03/04/2026` meaning 3 April stored 4 March.

`DateField` displays dates as `d MMM yyyy` and parses day-first (`3/4/2026`, `7 Sep 2026`, `2026-09-07`). It hands the form an ISO date string; a blank field stays `""` so an untouched form is not dirty and sends no date in the `upsertProject` payload.

## Project location geocoding

`ProjectForm` uses `LocationPicker` from `@stralos-co-nz/maps-ui` and passes the project's lowercased `countryCode` as `countryCodes`. The shared picker uses Places Autocomplete and restricts results to that country. A new project defaults to `NZ`; changing the country code field in the editor updates the restriction immediately.

## API error text

All screens in this remote use `userFacingError` (in `errorText.ts`) to turn a caught error into a sentence. The rules:

* An `ApiError` with a `detail` field shows the server's sentence. When the server also returns a `reference`, it is appended as `Reference: <value>`.
* When `ApiError.code` is `non_json_response`, the server sent a non-JSON body. The caller's fallback sentence is used instead.
* A browser `TypeError` (for example `Failed to fetch`) uses the caller's fallback sentence.
* Any other `Error` whose `.message` is non-empty passes through as-is.
* `ApiError.message` carries the HTTP status and the full API URL for logging purposes. It is never shown on screen.

## Cross-remote shared reads

`src/sharedReads.ts` implements a short-lived in-page cache for GET requests that multiple federated remotes may issue independently during one page load. Each federated remote bundles its own HTTP transport, so two remotes that need the same resource would otherwise send two identical requests to the server.

The cache lives on `globalThis` under the key `__stralosSharedReadsV1`. An entry stores the in-flight or settled promise and the time it was started. Callers pass a scope string (`${organisationId}:${userId}` from the session) and the exact request URL; a read with no scope bypasses the cache entirely. A hit that is younger than 30 seconds is reused: the reusing caller receives a `structuredClone` of the result so mutating the response is safe. If the shared promise rejects (for example, a 403 from a different remote's transport), the reusing caller re-issues the request through its own transport so every remote sees its own `ApiError` class and HTTP status.

Callers that must hit the network pass `force: true` (access checks, reads after a write, explicit refreshes). A forced read still stores its result in the cache, refreshing the entry for other callers.

After a write, callers call `invalidateSharedReads(scope, urlPrefix)` to drop every cached entry whose key starts with that prefix.

This file is copied verbatim into every participating remote. To change the contract, bump the global key to `__stralosSharedReadsV2` (or higher) in every copy simultaneously so mixed deploys from different releases bypass each other's entries instead of misreading them. Never edit one copy alone.

## Busy and transient failure handling

A `GET` request that receives HTTP 503 with a `Retry-After` header of 10 seconds or less is retried once, after the delay the server specified. The retry fires in the same `listAll*` call so callers see it as a single promise.

Only `GET` requests are retried. Write operations (`POST`, `PUT`, `DELETE`) are never replayed automatically: a write that received a 503 raises `ApiError` immediately so the caller can decide whether to surface it or retry explicitly.

A second 503 on the retry attempt raises `ApiError` and is not retried again.

## Save and refresh behaviour

Saving a project makes a single write to api-global (`POST /api/global/projects` or `PUT /api/global/projects/:id`) and nothing else. The estimate is not provisioned during save. The estimate, documents, and project-settings pages each call api-estimate when they open; api-estimate creates the estimate on first GET. The organisation's margin ladder and hours per day freeze into the estimate at that point, not at project creation.

This decoupling means an api-estimate outage does not cause a project save to fail or report a "Could not save the project" error. Previously the save also awaited a GET to api-estimate to trigger estimate creation; an api-estimate outage would mark a successfully-saved project as failed, and pressing **Save** again created a duplicate.

When a project save succeeds but the follow-up list refresh fails, this remote reports the save as successful, navigates to the new project, and sends the refresh failure to the retryable load-error banner. This prevents a successful create from leaving the editor open and prompting a duplicate create.

## Company and location detail pages

`CompanyDetailPage` and `LocationDetailPage` were rebuilt in row 33 of the design library backlog. The changes below apply to both records unless noted.

### Layout

Both detail pages are `SectionCard` + `PageHeader` over `SectionCard` panels. The old layout used an `HStack` of name and badges with a trailing outline back button and `SettingsPanel`s whose rows were hand-built `HStack`s.

### Back navigation

`PageHeader.backTo` renders the back link above the title. Previously there was no back link on either record; the browser Back button was the only exit. The `backTo` prop uses `onClick` rather than `href` to keep routing client-side — `PageHeader.backTo` renders a raw anchor when given an `href`, which would leave the SPA and re-bootstrap the shell. The loss is middle-click and open-in-new-tab.

### Save changes placement

**Save changes** is the single primary action in the page header (top right). On the company record it was the last control in the Details panel, below contacts, locations, and documents. On the location record it was at the bottom of the form. Moving it to the header keeps it visible without scrolling.

### Form field widths

`FormGrid` replaced `HStack … wrap` rows of self-sizing `FormField`s. The previous layout let every field size itself, resulting in four different field widths in one form. A `span` on the twelve-column grid is now the only thing setting a field width.

### Date fields

`DateField` replaced `<Input type="date">` for date inputs on both pages. The native date input renders in the browser locale — the captured machine was US, so the same value appeared as `08/31/2026` in the field and `31 Aug 2026` in the badge beside it. `DateField` shows and parses NZ dates and still reads and writes ISO 8601.

### Tables

Contacts, locations, and prequalification documents are rendered as `DataTable`s with labelled column headers, replacing rows of unlabelled facts.

### Status indicators

* Company and location roles use `KindChip`.
* Retired status uses `StatusBadge` on the `resource` domain.
* Document status uses `StatusBadge` on the `document` domain.
* A location's status is always a chip beside its name in the record header and does not appear as a separate row in the fact grid.
* A location with no coordinates shows `AttentionBadge` with the label **No coordinates** on the location record header.
* In the Locations register, locations with no coordinates show **No coordinates** as muted text beside the address. An amber chip is not used there: most supplier sites have no coordinates, and showing amber for the majority of rows removes its signal value.

### `SettingsPanel` removal

`SettingsPanel` had no remaining callers in this remote after the row 33 rebuild. Its module, `src/settingsShared.tsx`, was deleted in feature-workspace #109.

### Confirmed deletes for contacts and documents

Deleting a contact or a document from a company record requires confirmation. Selecting **Delete** from the row action menu opens a `ConfirmDialog` that names the record and uses a destructive primary action. The delete does not fire on the first click.

Both deletes are soft. The dialog reads "The contact is removed from {company}. It's kept for history." or "The document is removed from {company}. It's kept for history." in place of the default "This cannot be undone." The `reversibility` prop is `{ reversible: false, note: "It's kept for history." }` on both cases, so the default irreversibility sentence is suppressed. No estimates clause appears on the document variant: a company document can never be routed to an estimate.

### Location detail geocoding

`LocationDetailPage` reads `VITE_STRALOS_GEOCODE_COUNTRY` via `geocodeConfig()` and passes the result as `countryCodes` to `LocationPicker`. This biases address search toward the configured country. When the variable is unset the picker falls back to its own default of `nz`. See [Shell geocoding](https://help.stralos.app/internal/frontend-architecture.md#shell-geocoding).

## Navigation note

Companies and Locations are route-manifest entries. The shell derives their navigation from the resolved workspace descriptor and retains only the ordering and visibility declarations needed to place them in the global rail.

## Legacy client and supplier redirects

`/clients`, `/clients/:id`, `/suppliers`, and `/suppliers/:id` redirect to `/companies` rather than rendering their own pages. The redirect behaviour differs by path:

* `/clients` and `/suppliers` redirect to `/companies` with the matching role filter pre-applied (`?role=client` or `?role=supplier`) and an info banner explaining the move. Dismissing the banner clears it but keeps the filter.
* `/clients/:id` and `/suppliers/:id` attempt to open the corresponding company record directly by using the id segment as a company id. If the id resolves to a company, the company record opens. If the API returns a 404, the banner reads "That client link no longer matches a company — search below for the one you wanted." Any other failure shows the ordinary "Companies now cover clients, suppliers and subcontractors" banner (or "Companies now cover clients and suppliers" when `useMvpHidesSubcontractors` returns `true`), because a non-404 failure is not evidence the record is gone.

`CompaniesPage` reads its initial role filter from the `?role=` query parameter and ignores unknown values.

## Project selector chip

The project selector button always reads **Projects** with a chevron. It does not show the selected project name in the button text, and it does not fall back to the first project in the register when no project has been chosen.

The controller tracks a `selectedProjectName` internally (exposed to the selector as a prop). When no project has been visited, the value is `"Select project"`. The only project the controller names without a direct user choice is the one the user last navigated inside, recalled from the `stralos.workspace.lastProjectPath.v1:*` storage key. On a fresh session with no stored path, `selectedProjectName` is `"Select project"` and the visible button is unchanged.

The project switcher popover omits client groups that have no projects. Previously an empty group appeared with a "No projects for this client" placeholder, which caused the word "All" (a common client name) to appear above a large list of projects belonging to other clients.

## MVP flag: subcontractor visibility

`useMvpHidesSubcontractors` (in `src/mvpFlag.ts`) reads the PostHog `mvp` feature flag through `useFeatureFlag("mvp", { fallback: true })`. This is the same key, hook, and fallback the shell's `App.tsx` uses to gate the MVP navigation (see [Navigation order](https://help.stralos.app/internal/frontend-architecture.md#navigation-order)).

When the hook returns `true`, the following UI elements are hidden:

* The **Subcontractor** role checkbox and its help line in the company create and edit forms.
* The **Subcontractor** role chip on company rows in the Companies register.
* The **Subcontractors** role filter button in the Companies register (the `?role=subcontractor` deep-link value is still accepted so existing links continue to work, but the button does not appear in the filter row).
* The **Subcontractor** role in the company detail page's roles list.
* The Companies register subtitle changes from "One company can be a client, a supplier and a subcontractor." to "One company can be both a client and a supplier."

Because the fallback is `true`, subcontractor elements are also hidden while flags load, when the load fails, and when PostHog is unavailable. They appear only once a loaded flag says `mvp` is off. The shell nav behaves the same way, so both surfaces stay in sync.

**Display-only gate.** Stored `isSubcontractor` values are never changed by this flag. An existing subcontractor company retains its role on save; its `isSubcontractor: true` still satisfies the "at least one role required" check even though the checkbox is hidden. The flag controls what the user sees, not what is stored.

## Companies and Locations behaviour

### Register row display

Each row in the Companies register shows the trading name (`t/a …`), the NZBN labelled as `NZBN <number>`, and location and contact counts sourced from `GET /parties`. The name is a link to the company record, alongside the **Open** button.

Location and contact counts are optional on the wire. An absent count is not displayed rather than rendered as zero: a remote that predates the count fields in the API response would otherwise report every company as having zero locations, which is indistinguishable from an empty record and leads to duplicate entries.

### Location register owner bands

The Locations register groups rows by owner. When the visible row count exceeds 20 (`COLLAPSE_ABOVE`), every owner band opens collapsed by default so the page loads as a list of owner headings rather than one long table.

An **Expand all** / **Collapse all** button appears when there is more than one group. Clicking it overrides the default state for every group at once. Individual groups can also be toggled independently.

Applying a search or ownership filter resets any per-group overrides: a filter is a request to see the matches, so results open expanded.

### Company record back-navigation

`CompanyDetailPage` carries a **Back to companies** link to `/companies`. A company record is reachable from the register, from a project, and from a rate; previously all three were one-way.

### Scroll-to-top on navigation

`useScrollToTopOnNavigation` (in `scrollToTop.ts`) scrolls `window` to the top on every route change. It fires on `PUSH` and `REPLACE` navigations only, on a change of `pathname` only. `POP` navigations (browser Back/Forward) are excluded so that returning from a location record lands the reader at the Locations panel they opened it from, not at the top of the company record. Query-string changes (search box, filters) do not trigger it because only the pathname comparison gates the scroll.

### Location record back-navigation

`LocationDetailPage` derives its back-navigation target from `backPartyId`:

* On the create form: the `?partyId=` query parameter, which is set when **Add location** is invoked from a company record.
* On a saved record: `location.partyId` from the loaded location.

When `backPartyId` is set, the back link goes to `/companies/:partyId`; otherwise it goes to `/locations`. Previously the back link always read `ownerPartyId`, which is only populated on the create form, so a saved company location offered **Back to locations** — the one register it is not filed in.

### Company-owned location editability

Locations listed in the **Locations** panel on a company record are rendered as links (name plus **Open** button) rather than plain text. Previously the panel rendered location names as text, so a location created from that panel could not be re-opened from its owner to correct it. The record was always editable; only the navigation path from the owner panel was missing.

### Locations register foldable groups

The Locations register groups rows by owner and each group is collapsible. Toggling a group header collapses or expands its rows. Groups start expanded. A reader who wants only their organisation's own sites can fold the supplier sections without scrolling past them.

### Address display de-duplication

`locationAddress` builds the display address from `addressLine1`, `city`, and `region`. The geocoder can write a suburb with its postcode into `addressLine1` and the bare suburb name into `city`, producing a duplicate part (for example, "Lyttelton 8082, New Zealand, Lyttelton"). The function tracks normalised parts and strips any part whose text (after removing a trailing postcode) has already been shown.

### Location kind labels

Location kind values are stored in lowercase (`yard`, `depot`, `quarry`). The kind column in the register and the kind selector on the detail page show a human-readable label with the first character capitalised (for example, **Yard**, **Depot**, **Quarry**).

### Country select on location detail

The country field on the location detail form uses `KeyCombobox` with the full list of country options rather than a free-text input. A free-text country code field accepted values like `nz ` (with trailing space) or `New Zealand`, which did not match any geocoder country code.

### Location form dirty guard

The **Save changes** button on the location detail form is disabled until the draft differs from the saved record. The note beside the action row reads "No unsaved changes." while the form is pristine. The discard action is labelled **Discard** for an existing record and **Cancel** for a new location, which has no saved record to restore.

### Location form required fields

Required fields on the location detail form are marked with the standard required indicator. Optional fields carry no marker. The "Optional" helper text that appeared on optional fields is gone.

### Projects register row actions

The kebab menu on each project row carries three items: **Edit project** (opens `/projects/:id/settings`), **Project rates** (opens `/projects/:id/rates`), and **Project documents** (opens `/projects/:id/documents`). The name column remains the primary link to the project record.

The kebab is labelled by the project name. `DataTable` falls back to the row id as the accessible label when `getRowLabel` is absent; without it screen readers would announce "Actions for row 0f9c1a…" instead of the project name.

### Companies register row actions

Each company row carries an **Open** button and a kebab menu. The kebab holds **Company locations** (navigates to `/locations?partyId=<id>`) and **Add location**. The **Open** button is kept beside the kebab rather than folded into it: the name is the primary destination, and the kebab holds the row's other verbs.

**Add location** opens the `LocationCreateSheet` overlay in place rather than navigating to `/locations/new?partyId=<id>`. The sheet opens preselected to the company's owner and returns to the same page on create or cancel. The `/locations/new?partyId=<id>` deep-link route still works for direct links.

### Location register owner band counts

Each owner band in the Locations register displays its location count as a mint pill inside the band title. The pill is marked with `data-section-count=""` for test selection.

### Location address display in register

The address and the **No coordinates** note for a location row are two separate lines.

### Contract basis layout

The contract basis card on the project overview uses `RecordFacts`, with each label above its value and columns filling the card width.

## DataTable scroll behaviour

`DataTable` column headers are sticky by default. Loaded tables are windowed by default, including the Locations register with its owner bands.

## List page scroll ownership

As of ui `0.69.1`, each list register page owns its own scroll container. `RegisterPage` wraps its content in a bounded scrollport so the list scrolls independently of the browser window. This applies to:

* **Companies** (`CompaniesPage`)
* **Locations** (`LocationsPage`)
* **Projects** (`ProjectDirectoryPage`)
* **Project directory** (`ProjectDirectoryPage` via `ProjectsRoute`)

The company detail page's **Contacts**, **Locations**, and **Documents** `SectionCard` panels opt out of this by passing `scroll="page"` to `SectionCard`. Those panels scroll with the page rather than inside their own bounded box, so a reader scrolling through a company record moves continuously down without entering a nested scrollport.

## Register list region labels

As of ui `0.70.0`, `RegisterPage` names its scrolling list region after the page title. Previously the region was announced as "List" by screen readers regardless of which register was open. Now screen readers announce the region using the register name — for example "Projects" or "Companies".


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