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

# Estimate frontend

> Internal architecture of stralos-feature-estimate, the estimate workspace Module Federation remote.

`stralos-feature-estimate` is the Module Federation remote that owns the estimate workspace, project documents, variations, and subcontractor portal routes. It is registered in the shell as the `estimate` remote.

## Routes

| Path | Component | Notes |
| - | - | - |
| `/projects/:projectId/estimate/*` | `EstimatePage` | Estimate workspace with calculators, work items, takeoff, project rates, pricing review, programme, and subcontractor panels. |
| `/projects/:projectId/estimate/builder/calculators` | `CalculatorsPanel` | Project-level calculator list. The shell owns its **Calculators** rail entry. |
| `/projects/:projectId/estimate/builder/calculators/:calculatorId` | `CalculatorWorkbenchPage` | Full-page workbench for a stored calculation. The shell route catalogue exposes it as `calculator`; a cold link falls back to the Calculators list. |
| `/projects/:projectId/estimate/builder/calculators/new/:calculatorType` | `CalculatorWorkbenchPage` | Full-page workbench for a local draft (`calculatorType`: `trench`, `pit`, or `volume`). The shell route catalogue exposes it as `calculator-new`. Nothing is written until Save. The optional `?workItem=` and `?component=` query parameters carry the default destination. |
| `/projects/:projectId/estimate/builder/subcontractors/:packageId/review` | `SubcontractBidReviewPage` | Contractor return review for one subcontract package. The shell route catalogue exposes it as `subcontract-bid-review`; it is reached from Subcontractors or a work item's pricing. |
| `/projects/:projectId/documents` | `ProjectDocumentsPage` | Project documents list; declared as a descriptor `paths` entry on the `estimate` remote. |
| `/projects/:projectId/documents/review` | `ProjectDocumentsPage` | Document review surface. |
| `/projects/:projectId/documents/review/:documentId` | `ProjectDocumentsPage` | Individual document review; deep-link to a specific document. |
| `/projects/:projectId/commercial/variations` | — | Variations panel; declared as a descriptor `paths` entry on the `estimate` remote. |
| `/projects/:projectId/estimate/builder/work-items/export` | `WorkItemsExportPage` | Chromeless work item export page. |
| `/contractor-portal/:portalToken` | `SubcontractorPortalPage` | Unauthenticated contractor portal. The shell mounts this route on its chromeless branch; the page supplies its own `AppFrame` (no navigation entries, no project selector) so the Stralos header band and content container are rendered for every visitor, including those who open a dead or expired link. |
| `/projects/:projectId/delivery/compare` | `DeliveryComparePage` | Compare to tender — won tender revision beside the Delivery estimate. Dev only (`ESTIMATE_DELIVERY_ENABLED`). |
| `/projects/:projectId/delivery/sign-off` | `DeliverySignOffPage` | Delivery sign-off workflow. Dev only. |
| `/projects/:projectId/delivery/variations` | `DeliveryVariationsPage` | Approved variation rows applied to the Delivery estimate. Dev only. |

Routes are registered in `src/routes.ts` as a `RouteDefinition[]`. The Documents and Variations paths sit outside the remote's primary base path and are declared as entries in the `estimate` descriptor's `paths` array in the `stralos-core` seed. The shell's generic mount builder produces them; no bespoke shell constants are required. See [Micro-frontends — Split-mount nav pattern](https://help.stralos.app/internal/micro-frontends.md#split-mount-nav-pattern).

### Estimate rail active marking

Every estimate destination in the project workspace drawer owns its descendants for active-marking purposes, except the **Project overview** entry. The shell's `estimateNavItem` helper sets `end: false` (via `ownsDescendants: true`) for all estimate builder sections and tabs, and `end: true` for the overview only.

This means that when the user opens a route that is a descendant of a nav item — for example the calculator workbench (`/builder/calculators/:calculatorId`), the rates re-apply review (`/builder/rates/re-apply`), or a tender submission sub-section (`/tender-submission/margins`) — the parent nav item (**Calculators**, **Project rates**, or **Tender submission**) remains highlighted. Before this fix all destinations used `end: true`, so those descendant pages lit no rail item.

### Work Items import drawer

`WorkItemsImportDrawer` (`src/WorkItemsImportDrawer.tsx`) is opened from the **Import** button in the work-items toolbar. It hosts two sources: **file** and **project**, toggled by a `source` selector. The `FileImportFlow` component handles the file path; the project path is bespoke to this drawer.

**Project import flow:**

1. `listAllProjects` fetches every project the session can see. The current project is excluded.
2. Selecting a source project calls `previewProjectImport` (`POST /api/estimate/estimates/:estimateId/work-items/import-project/preview`, `src/api.projectImport.ts`). The preview returns `workItems`, `sections`, `calculators`, `currentWorkItems`, `overrides`, `tree`, and `warnings`. `tree` drives the **Choose sections and lines** picker.
3. The estimator chooses **What to import** (`scope`: the whole schedule, or a pick), then configures `mode` (`"append"` | `"replace"`), `includeDetails`, `calculators` (`"copy"` | `"rates_only"`), `pricing` (`"latest"` | `"source"`), and which `overrides` to keep.
4. Selecting **Import estimate items** calls `importProjectWorkItems` (`POST /api/estimate/estimates/:estimateId/work-items/import-project`). The request body is a `ProjectImportChoices` object including `requestId` (UUID) and `previewToken` (returned by the preview step).
5. The result (`ProjectImportResult`) carries `workItems`, `sections`, `calculators`, `removedWorkItems`, `refreshedRates`, and `warnings`.

**Idempotency guard:** on submit, the choices and preview are written to `sessionStorage` under `stralos.projectImport.<estimateId>`. On mount, the drawer reads this key. If a pending record is found, it enters an `uncertain` state, disables mode/source switching, and shows **Retry same import** to resubmit without re-importing. A confirmed response, a 4xx refusal, or a deliberate close clears the key. This prevents double-import after a network interruption.

A `ConfirmDialog` fires before a replace-mode import when the current estimate has work items (`preview.currentWorkItems > 0`).

**Choosing sections and lines (`src/ProjectImportPicker.tsx`, `src/projectImportSelection.ts`):** a pick always sends `mode: "append"` and a `selection` built by `buildImportSelection` (`sectionIds`, `workItemIds`, `linesTarget`, `sectionPlacements`). The mode and **Calculators** selects are hidden; when the chosen lines use calculators, `ProjectImportQuestions` asks whether to bring them, and the answer sets `calculators` to `"copy"` or `"rates_only"`. It also asks where each same-level title clash goes and where lines without their section land; `importProblems` keeps **Import estimate items** disabled until every question is answered. A `409 project_import_section_exists` adds the server's `sectionIds` to `askedByServer` so those sections are asked about, and the estimator resends.

**Whole-schedule title clash:** a `422 project_import_section_title_taken` on a whole-schedule import stores the clashing `sectionIds` and shows **Choose sections instead**. Selecting it switches to a pick with every section ticked, plus every top-level line with no section, and marks the clashing sections as asked.

### Per-area file import drawers

`FileImportDrawer` (`src/FileImportDrawer.tsx`) wraps `FileImportFlow` in a `SideSheet` and is used by three panels for area-scoped document uploads:

| Panel | Area | Button label |
| - | - | - |
| `TakeoffPanel` | `"takeoff"` | **Upload drawing** |
| `SubcontractsPanel` | `"subcontracts"` | **Import** |
| `ProjectRatesPanel` | `"project_rates"` | **Import quote** |

`FileImportFlow` (`src/FileImportFlow.tsx`) handles the upload state: `useDocumentUpload` is called with `{ estimateId, targetAreas: [area] }` so the upload is immediately routed to the panel's own area.

**In-place processing** (`processesInPlace`, Graeme 2026-10-05): `takeoff` and `work_items` uploads are processed inside the drawer — nobody is sent to the Documents page. `processesInPlace(area)` returns `true` for these two areas. For all other areas, a **Review \[area label] suggestions** button navigates to `projectDocumentsReviewPath(projectId, { area })` once all files succeed.

For in-place areas, `useWatchedDocuments` (`src/watchUploadedDocuments.ts`) polls each uploaded document's record until the file settles (no longer `isDocumentInflight`). The flow passes `onUploaded` (a host callback to refresh the estimate's documents) instead of `onImported` (the navigate-to-review callback).

`inPlacePhase(area, upload)` (exported from `FileImportFlow`) maps the combination of area, job file, and latest document record to one of four phases used to render each row in the drawer:

| Phase | Condition |
| - | - |
| `processing` | Document record not yet settled |
| `ready` | `work_items`: document processed; `takeoff`: `isTakeoffApproved(document)` is true (server stamped `docMetadata.takeoffAutoApprove`) |
| `attention` | Takeoff: non-PDF file (told immediately, before processing), or processed PDF that was not auto-approved, or processed non-drawing |
| `failed` | `status === "failed"` or `"infected"` or `"rejected"` |

For a non-PDF takeoff upload the `attention` phase reads immediately from the picked file (`isPdfFile(job.file)`) — the drawer does not wait for the server response to tell the user the file cannot be drawn on.

**Work-items suggestions sheet** (`SuggestionImportSheet`, `src/SuggestionImportSheet.tsx`): once a `work_items` upload settles, `WorkItemsImportDrawer` opens this sheet for that document's suggestions. The sheet is a full-width `SideSheet` (90vw, ui size `2xl`) so the description column is readable at 1280 px+. It lists suggestions in schedule order (`orderSuggestions`: sections in first-row-number order, rows within sections by item number, unsectioned rows last). Columns: No., Section, Description (editable), Qty (editable), Unit (editable), Kind, Confidence, Page. Ticked rows are imported one at a time in that order (`acceptSuggestion` then `acceptWorkItemSuggestion`); unticked rows are dismissed (`rejectSuggestion`). Edits are overrides passed to `buildWorkItemAcceptOverrides`. Each document is reviewed at most once — the drawer tracks which document IDs have been reviewed and does not reopen their sheet on subsequent polls.

Closing the sheet without importing leaves every suggestion in the `pending` state; they remain accessible from the project Documents page.

File types accepted by these per-area drawers: PDF, PNG, JPEG, TIFF, DOCX, XLSX, CSV. DWG and DXF are rejected client-side with an explanatory hint — drawings go through the project Documents page only (DWG→PDF §5.3).

The Work Items panel's own **Import** button opens `WorkItemsImportDrawer` (not `FileImportDrawer` directly); the file path inside that drawer delegates to `FileImportFlow` with `area="work_items"`.

### Project Rates page (S4)

The Project Rates builder section (`/estimate/builder/rates`) is served by `src/ProjectRatesPage.tsx`, which re-exports `ProjectRatesPanel` from `src/projectRates/`. It replaced the previous override split view (Active overrides / Regular rates). The deleted components are: the override split view, `ProjectRateEditorModal`, `formatProjectRateOverrideSource`, the header's **Re-resolve estimate pricing…** button, and `deleteProjectRateOverride`.

S6b retired the legacy project rate reads and the scalar resolver arm for overrides: `listProjectRateOverrides`, `loadAllProjectRateOverrides`, and `upsertProjectRateOverride` have been removed. The pricing engine no longer reads `GET /api/rates/project-rate-overrides`; all legacy overrides are now priced through their S1b mirror project options. `markProjectRateOptions` applies the `projectOverrideUnitPrice` / `projectRateOverrideId` pair from the mirror option's own cost and id, so stored component snapshots remain valid without any remap.

Scoped rate ownership uses `scope` / `supplierId` / `locationId` throughout the estimate remote: the pricing engine, project rates panel, supplier picker, and quarry supplier analysis all share this shape from `@stralos-co-nz/domain` 1.5.0. `rateScope.ts` owns the frontend identity rules and `projectRateCreatorSteps.tsx` owns the inline supplier and location creation steps. See [Rate ownership model](#rate-ownership-model-ratescope-ts) and [Supplier picker and sheet steps](#supplier-picker-and-sheet-steps) below.

`src/api.projectRates.ts` owns the page's HTTP calls across the Rates and Estimate services. Rates calls use `GET /api/rates/project-rates` (paged to exhaustion, with `pendingRepriceCount`), `POST /api/rates/project-rates/replacements`, `POST /api/rates/project-rates/project-only`, `PUT` or `DELETE /api/rates/project-rates/:optionId`, `POST /api/rates/project-rates/:optionId/promote`, and `POST /api/rates/project-rates/reprice-pending`; project-scoped calls include `projectId` in the query string or request body. Estimate calls use `GET /api/estimate/estimates/:estimateId/work-items/:workItemId/project-rates`, `POST` to the adjacent `project-rate-attachments` collection, `DELETE /api/estimate/project-rate-attachments/:attachmentId`, `GET /api/estimate/estimates/:estimateId/project-rate-usage`, and `POST /api/estimate/estimates/:estimateId/work-items/insert-project-rate`. Parsers validate server shapes so an additive field does not break the page.

The **Import rates** button is present in the page header whenever an `estimateId` is available. Clicking it navigates to `${builderSectionPath(projectId, "rates")}/import` — that is, `/projects/:projectId/estimate/builder/rates/import` — which is served by `stralos-feature-rates`' `RateImportPage`. The path stays within the estimate URL namespace so the shell keeps the estimate remote mounted. When the importer finishes and returns, it passes a `projectRateImport` entry in React Router's location state. `ProjectRatesPanel` reads that state on mount and after each navigation, applies the returned reprice result via `noteRemovedCompanions` and `onRateExceptionDecision`, requests a badge refresh, then replaces the current history entry with the state removed so a forward/back cycle does not re-apply the receipt.

`useProjectRatesData` reads all project rates plus the estimate's usage. Once per read it finishes any pending automatic re-price (PRD F.4); a still-failing delivery is reported and not retried. URL state (search, filters, chips, view) is held in prefixed query parameters via `useProjectRatesUrlState`, replacing history rather than pushing.

The work item panel (`WorkItemProjectRates.tsx`) is mounted from `WorkItemDetailPane` via `WorkItemsPricingPanel`. It shares `useProjectRatesData` and the same re-price logic as the page. `FROZEN_BANNER` lives in `projectRateDialogText.ts` (a pure text module) so the pane does not import the full page.

Rate exception badge refresh is coordinated through `rateExceptionBadgeRefresh.ts`: a write fires `requestRateExceptionBadgeRefresh()`, the publisher re-reads immediately rather than waiting for its 30 s poll. A request made while a read is in flight queues one more read behind it.

**Shared count store (`rateExceptionCountStore.ts`):** `RateExceptionBadgePublisher` and the estimate attention strips (which mount earlier, in the commit that resolves the estimate) each previously fired their own `GET rate-exceptions/count?shape=2` — two identical reads on every estimate route load. They now share a single module-level store. A reader that arrives while a read is in flight joins it. A reader that arrives within 30 seconds of a settled read takes that answer without a new request. The badge's first read for a route also checks `pendingStripRead` so it can join the strip's earlier in-flight read; subsequent reads (poll, focus, post-write refresh) always start their own request. The store is module-level inside the estimate remote, so test suites that reuse an estimate id must call `resetRateExceptionCountStoreForTests()` between tests to prevent cross-test cache pollution.

#### Rate exceptions panel (`RateExceptionsPanel`)

`RateExceptionsPanel` (`src/RateExceptionsPanel.tsx`) renders the list of unresolved rate exceptions on the Rates tab.

**Unit symbol formatting (`rateExceptionSentence`):** the server sends each exception's `sentence` with raw domain unit codes such as `square_metre`, `m2`, or `m3`. `rateExceptionSentence` rewrites those tokens to display symbols (`m²`, `m³`, `t`, `m`) before rendering. A unit the formatter does not recognise is left as-is, so the sentence never shows a raw API key to the estimator.

**Action ordering (`orderedExceptionActions`):** each exception card may carry both fixing actions (for example **Supply missing input**) and dismissing actions (for example **Accept**). `orderedExceptionActions` moves any fixing actions to the front of the list so the first button is always the one that resolves the underlying issue. When every action is dismissing, the server's order is preserved.

The first action receives `colorPalette="teal" variant="solid"` (primary); subsequent actions receive `colorPalette="gray" variant="outline"` (secondary). The hierarchy is determined by position, not by the token name, so it follows `orderedExceptionActions`' output directly.

**Read-only mode (`readOnlyReason`):** `ProjectRatesPanel` passes its read-only reason (from `projectRatesReadOnlyReason` in `projectRatesLock.ts`) to `RateExceptionsPanel`. When set, every decision button and every grouped "… for all N" button renders disabled inside a `DisabledReason` tooltip. `isExceptionDecision(action)` treats the tokens `keep_pin`, `accept`, `apply_rule`, `follow_rule`, and `clear_band_override` as decisions; the others (`choose_option`, `widen_policy`, `supply_input`) only open a picker or a price and stay active. If a decision races an estimate freeze and the server returns `409 estimate_frozen`, `frozenRefusalText` surfaces the server's message instead of the stale-exception fallback.

#### Rate ownership model (`rateScope.ts`)

`src/rateScope.ts` defines the ownership identity for a project rate. A `RateAuthority` carries a `scope` (`"organisation"` or `"supplier"`), `supplierId` (non-null for supplier-scoped options), `locationId`, and resolved `supplier` and `location` objects. This triple — `scope`, `supplierId`, `locationId` — replaces the retired `rateSourceId` / `rateSourceLocationId` pair as the canonical ownership key throughout the estimate remote (domain `@stralos-co-nz/domain` 1.5.0).

`rateOwnerName(option)` resolves the display name for any option: `"Your organisation"` (falling back to the current org name from `localStorage`) for organisation-scoped options, or the supplier's own name for supplier-scoped ones. It never appends `"(Internal)"` — the old source picker appended that label to the internal-source row, producing labels such as `"Internal (Internal)"`.

`validRateAuthority(v, active?)` is the runtime type guard. It enforces the constraint that `supplierId` is non-null exactly when `scope === "supplier"`, that a supplier object is present when `supplierId` is set, and that `location.partyId` matches `supplierId`.

#### Location owner catalogue (`listRateLocationOwners`)

`listRateLocationOwners()` in `src/api.global.ts` replaces the retired `rate_sources` / `rate_source_locations` reads as the data source for the owner and location picker. It issues two concurrent reads — all supplier companies and all locations (`owner=all`) — then partitions the locations by `partyId`:

* A place with `partyId: null` belongs to the organisation (`organisationLocations`).
* A place whose `partyId` matches a company in the supplier register is filed under that company.
* A place whose `partyId` names a company the supplier register does not list is dropped, not promoted to the organisation's own sites.

The result is a `RateLocationOwners` value: `{ organisationLocations, suppliers }`. The organisation's display name is the shell's to supply at render time; it is never stored in this result.

The single `GET /api/global/locations?owner=all` replaces the former two-pass `owner=ours` plus `owner=theirs` strategy. Two passes over the same table could observe different instants; a location reassigned between them would appear twice or not at all.

#### Supplier picker and sheet steps

`ProjectRateSupplierPicker` (`src/projectRates/ProjectRateSupplierPicker.tsx`) is a hook (not a component). It returns `content`, `locationName`, `steps`, `saving`, and `stepOpen`. The `steps` array feeds `ProjectRateSheet`'s own `steps` prop so that creating a supplier or location is a step inside the rate sheet — not a modal stacked above it. Returning the spec on every render rather than capturing it at open time keeps the step's title, footer, and back button live.

The picker uses `RateLocationPicker` from `@stralos-co-nz/ui-reference-data` (0.3.0 or later; it was in `@stralos-co-nz/ui` from 0.63.0 until 0.73.0 removed it) to let the estimator choose a scope (organisation or supplier) and optionally a location. Selected state is a `RateLocationPickerSelection`: `{ scope, supplierId, locationId }`.

The picker follows three ownership rules:

* **"+ Create new supplier"** is a link beneath the owner field, not a selectable row inside it. As a row it was a reserved string token, and a keyboard walk through companies could land on it and open a step nobody requested.
* **"Create our own source"** is gone. Organisation scope is a scope on the option row, not a company record. The admin-permission gate that previously guarded this option now gates whether the organisation row is offered in the picker at all.
* **Companies display as their own name.** The old source picker appended `"(Internal)"` to the organisation row, which produced `"Internal (Internal)"` when the organisation was named "Internal".

`projectRateCreatorSteps.tsx` (`src/projectRates/projectRateCreatorSteps.tsx`) contains the step body forms: `SupplierStepForm` and `LocationStepForm`. These replace the `SupplierEditorModal` and `LocationEditorModal` dialogs in the project-rate creation flow. `@stralos-co-nz/maps-ui` 0.10.0 removed `LocationEditorModal`.

`createProjectRateSupplier(name)` in `src/projectRates/projectRateReferences.ts` creates a company via `POST /api/global/parties` and returns a `RateSourceDefinition`.

`createProjectRateLocation(partyId, location)` writes the location to either `POST /api/global/parties/:partyId/locations` (supplier-owned) or `POST /api/global/locations` (organisation-owned). `partyId` carries three states: `undefined` means nothing chosen, `null` is the deliberate choice of the organisation, and a string is the supplier's company id. The route is determined from `partyId`, never from a field in the body — the API ignores a body-carried owner on `POST /locations`.

The "this supplier has no company record" guard that previously existed in `createProjectRateLocation` has been removed. It guarded against `rate_sources` rows that carried no `party_id`; those rows no longer exist.

`LocationEditorValues` is typed from `@stralos-co-nz/maps-ui` (0.5.0 or later; `@stralos-co-nz/ui` removes its copy in 0.73.0). Its `partyId` field (new in ui 0.63.0) replaced `rateSourceId` as the ownership field; maps-ui 0.10.0 removed `rateSourceId`.

`scripts/check-package-compat.mjs` enforces this at lockfile level: it refuses any lockfile that resolves `@stralos-co-nz/ui >=0.57.0` together with `@stralos-co-nz/maps-ui <0.3.0`. The check runs before `pnpm install` in CI. To add a new known-incompatible pair, add an entry to the `INCOMPATIBLE` array in that file across every repo that carries it.

#### Rate option conversion factor (`rateOptionFactor.ts`)

`src/projectRates/rateOptionFactor.ts` (formerly `quarryDensity.ts`) owns the field words, validation, and pre-fill logic for the conversion factor on a project rate option. The field is hidden unless `rateOptionConversionKind({ rateGroup, optionUnit })` (from `@stralos-co-nz/domain`) returns a non-null kind; which kind applies is determined by the rate group and the canonical unit of the option being priced.

Two conversion kinds exist:

* **Density** (`numeratorUnit === "tonne"`) — applies to quarry-materials options, whatever their unit. The `DENSITY_FIELD` constant holds the label `"Density (t/m³)"`, helper text, advisory ceiling (5 t/m³), and validation rules. api-rates refuses a quarry option without a density (422 `assertOptionDimensions`).
* **Stock coverage** — applies to options priced per `length`, `roll_length`, `roll_area`, or `sheet`. `stockField(label, denominatorUnit)` builds the field spec from `STOCK_COVERS`, which maps each unit to its coverage noun, measured-unit name, and example. Field labels: `"Metres per length"`, `"Metres per roll"`, `"m² per roll"`, `"m² per sheet"`. An option with no factor cannot price a metre or m² line.

`factorFieldFor(rateGroup, optionUnit)` is the single lookup entry point. It folds the unit onto its catalogue code before calling `rateOptionConversionKind`, so a quote import's `"Len"` is treated as `length`.

`replacedFactor(options, { unit, supplierId, locationId })` computes the pre-fill value for a new price. It searches current active library options at the given unit, preferring: this supplier at the chosen location → this supplier elsewhere → standard condition → cheapest. If no current option carries a factor, the field starts empty and is required.

`readFactor` validates the typed string; valid range is (0, 1 000 000] with at most 4 decimal places (api-rates' `isValidConversionFactor`). `factorHint` produces the `DENSITY_TOO_HEAVY_HINT` advisory for a density above 5 t/m³.

Only the scalar travels to the server. api-rates derives the factor orientation from the same `rateOptionConversionKind` call and rejects any client-sent orientation field.

#### Whole-unit stock ordering (`src/pricing/quantityRuns.ts`)

`src/pricing/quantityRuns.ts` is the browser's mirror of `stralos-api-estimate`'s `src/pricing/quantityRuns.ts` (PR 363 + PR 365). It must remain byte-for-byte equivalent to that file; a rule change goes there first and is reflected here.

`wholeUnitStockOrdering` is asked before `pairAwareConversionFactor` on every pricing path. It calls `orderedQuantityForPricedUnit` from `@stralos-co-nz/domain` after folding all units onto their catalogue codes via `canonicaliseUnit`. Three outcomes:

* A result → price at its `orderedQuantity`, pass `measuredPerWholeUnit` and `measuredRuns` through the `ChargeContext`, and use `conversionFactor: null` so the pair-aware logic does not double-apply the factor.
* `{ excluded: "conversion_missing" }` → option cannot price the line (count against a per-length price, missing or out-of-bounds factor); the option is excluded.
* `null` → not whole-unit stock (or already measured in the priced unit); the pair rules apply.

`measuredRunsForComponent` computes the `measuredRuns` array for one build-up line. The server stamps `quantity_runs` on a row when it splits a measured quantity across runs (link resolution, calculator reconcile). A typed quantity, an override, or a NULL column is one run. The function mirrors all pricing arms from api-estimate: whole-item, per-unit (with `perUnitBasisRuns` for `each`/`number` work items), resource-count repetition (`runsForResourceCount`), and the full activity/daily-production arms added in PR 365.

### Calculators page (`CalculatorsPanel`) and workbench (`CalculatorWorkbenchPage`)

The estimate remote declares `calculators` first in `BUILDER_SECTIONS`, while keeping `work-items` as the default for `/estimate/builder`. The shell independently declares the same builder section and renders **Calculators** immediately before **Estimate schedule** in its estimate navigation.

`EstimatePage` loads work items when either **Calculators** or **Estimate schedule** is active so the **Used by** pickers work on a direct visit to the Calculators section.

#### Calculator workbench route (D1)

Trench, pit, and volume calculators open on a full-page `CalculatorWorkbenchPage` instead of a cover dialog (modal/drawer review D1, 2026-09-16). Two routes sit under the Calculators section on the same `EstimatePage` instance:

| Route template constant | Path | Notes |
| - | - | - |
| `CALCULATOR_WORKBENCH_ROUTE_TEMPLATE` | `…/calculators/:calculatorId` | Stored calculation — the deep link. A cold direct link that cannot be confirmed falls back to `builderSectionPath(projectId, "calculators")`. |
| `CALCULATOR_WORKBENCH_NEW_ROUTE_TEMPLATE` | `…/calculators/new/:calculatorType` | Local draft. Nothing is written until Save. `?workItem=` and `?component=` carry the default destination (D64's `CALCULATOR_WORKBENCH_WORK_ITEM_PARAM` / `CALCULATOR_WORKBENCH_COMPONENT_PARAM`). |

`CalculatorWorkbenchPage` (`src/CalculatorWorkbenchPage.tsx`) owns the single copy of the save sequence that the former Calculators section and Work Items page each carried: nothing written until **Save**, one create-or-update, adopt, then one `PUT /links`.

`useCalculatorLeaveGuard` (`src/calculatorLeaveGuard.tsx`) intercepts every route leaving the page (`useBlocker`) and browser reloads (`beforeunload`), asking **Discard changes?** when the geometry or assignments are dirty. The guard lets Save's own navigation through by calling `allowLeave` before it navigates.

Every trigger that opens the workbench — **New calculation…** on the Calculators section, a row name on the Calculators section, **New calculation** from the Calculations tab on a work item row or detail pane, and a build-up activity's **Calculator** submenu — navigates with the page it came from as the back destination. A cold deep link replaces itself with the Calculators list fallback.

The legacy embedded editors keep the modal. `CalculatorModalHost` accepts a `page` prop to render either the workbench frame or the legacy modal frame with the same injected catalogues.

The calculator client follows the Estimate API contract: copy uses `/calculators/:id/copy`, delete requires `keepRows`, and mutations read the calculator plus warning envelopes returned by the server.

* `keepRows` is a required query parameter on delete. `CalculatorDeleteDialog` (`src/CalculatorDeleteDialog.tsx`) presents two explicit choices, **Delete the rows too** and **Delete, keep the rows**, so the estimator always answers the question rather than the client choosing a default. `CalculatorsPanel` (row action) and `CalculatorWorkbenchPage` (**Remove calculator**) share it. The workbench passes `onRemove` only when `editor.projectCalculator` is set; for an unsaved draft it passes `undefined`, so the editor's own clear runs instead.
* The client is rewritten against `api-estimate`'s zod schemas. `config` is required on create and must be engine-readable; an empty config (`{}`) returns `422 calculator_config_unreadable`. The destination is a flat `{ workItemId, componentId? }` pair, not a discriminated union.
* `rateSummaries`, `currency`, and `loadRateOptions` have been removed from all three calculator editors. They were passed through a JSX spread and silently ignored; the removal is verified by hand since excess-property checks do not apply to spread variables.
* `normalizeTrenchCalculator` now comes from `@stralos-co-nz/calculators` (the engine package) rather than being defined locally.

#### D3/R2 — Calculations tab (`WorkItemCalculations`)

`WorkItemCalculations` (`src/WorkItemCalculations.tsx`) is the **Calculations** tab of the work item detail pane (formerly `CalculationsDialog`). The row menu, the provenance chip, and the pane's own provenance links open the tab; **New calculation…** opens the workbench on a draft defaulted to the item. It replaces two earlier controls — the **Add calculation** radio menu and the separate **Calculation assignments** button — with one tab carrying two views:

* **Measurements** (default) — lets the estimator price any reading of a stored calculator (total length, saw-cut, layer area, etc.) by a rate item. A reading is a `CalculatorMeasurement`; selecting a rate triggers `PUT /calculators/:id/links` (to create the link and receive the generated `componentRowId`) then `updateWorkItemComponentRateLink` (to price it). Both steps always run in that order: reversing them prices nothing.
* **Layer destinations** — embeds the D67 assignment editor (`CalculatorAssignmentsBody` from `calculatorAssignmentsView.tsx`) as a second tab rather than opening a dialog inside a dialog. It loads every estimate calculator, including calculators with no destination on the current work item.

The tab opens on **Measurements** unless a `focusCalculatorId` is supplied (from a D69 provenance link), which selects **Layer destinations** to let the estimator see which calculator drives the row they selected.

Only the **Layer destinations** view has unsaved state (`editor.unsaved`). The **Measurements** view writes on its own **Save** and holds nothing afterwards, so it never arms the dirty-close guard. The write lock (`writing`) is shared across both views: switching views does not unmount a `PUT /links` in flight.

#### D67 — assignment control

`CalculatorAssignmentsSheet` (`src/CalculatorAssignmentsSheet.tsx`) is a modal `SideSheet lg` that opens over the workbench and over the Calculators list. Its body and all logic live in `calculatorAssignmentsView.tsx` (`CalculatorAssignmentsBody`, `useCalculatorAssignmentsEditor`) so that `WorkItemCalculations` can embed the same editor directly as its **Layer destinations** view without copying code. The sheet is the standalone frame used from the workbench and Calculators page; the Calculations tab renders the shared body without the sheet.

`layerCatalogue` filters the engine's rows through `nonMeasurementRows` so the assignment editor lists layers only. Measurement rows are excluded because they are read-outs, not layers. The bulk-dig rows (`pit-excavation` and `vol-excavation`) and their per-layer readings (`m:l:pit-excavation:*`) are also excluded — `isBulkDigLayerId` in `src/calculatorsApi.ts` covers both the row ids and any reading whose scope names them. The overall "Excavation volume" reading is still offered.

`layerCatalogue` uses the same current calculator builders as the server. An unassigned layer is offerable, only the outputs a layer states are offered (D66: absent is not zero), and D67's two regimes are determined from `layerKind`. Only excavation layers are partitionable across destinations; reinstatement, backfill, and pipe-run layers are whole-layer exclusive.

Each `LayerEntry` in the catalogue carries a `neutralVolume?: boolean` flag. It is set for non-bulk reinstatement layers — layers whose volume is a geometric quantity with no compaction physics (e.g. a concrete base or a layer backed by a non-bulk rate item). A neutral-volume layer's in-situ volume is plain m³, not compacted m³, so the quantity picker must not label it "m³ compacted".

`calculatorOutputUnitLabel(key, layerKind, neutralVolume)` in `src/calculatorsApi.ts` produces the material-context label shown beside the quantity picker for each offered output. The label varies by `layerKind` and the `neutralVolume` flag:

| Output key | Layer kind | `neutralVolume` | Label |
| - | - | - | - |
| `inSituM3` | `excavation` | — | `m³ in situ` |
| `inSituM3` | `backfill` or `reinstatement` | `false` / absent | `m³ compacted` |
| `inSituM3` | `backfill` or `reinstatement` | `true` | `m³` |
| `inSituM3` | other (e.g. `measurement`, absent) | — | `m³` |
| `looseM3` | `excavation` | — | `m³ loose spoil` |
| `looseM3` | `reinstatement` | — | `m³ loose to buy` |
| `looseM3` | other | — | `m³ loose` |
| `tonnes` | — | — | `t` |
| `areaM2` | — | — | `m²` |
| `lengthM` | — | — | `m` |
| `count` | — | — | `each` |

The share input for a partitionable (excavation) layer accepts a percentage or an empty value. An empty share means the whole layer is assigned to that destination. A share of `0` is refused — the register's database constraint rejects it. This distinction is enforced in `parseSharePercent` and is preserved through the dialog's `toLinkInputs` serialisation.

The server is the authority on the allocation total. The client shows an advisory running total as the estimator types, but the save is what refuses. Server refusal messages are rendered verbatim. There is no "you will lose the rate" warning: re-pointing a link moves the row, not the money.

Valid destination errors returned by the server: `calculator_destination_not_an_activity` (component is not an activity container) and `calculator_destination_wrong_work_item` (component belongs to a different work item).

#### Calculation views

`WorkItemCalculations` (`src/WorkItemCalculations.tsx`) lets a work item use as many calculations as the estimator creates. The type choice lives in the tab's **New calculation…** menu, which navigates to the workbench without selecting an existing calculation.

The tab carries two views, toggled by a button pair:

* **Measurements** (default) — prices a reading from a stored calculation by a rate item. The catalogue is computed in the browser from the stored config by `calculatorMeasurements`; no server arithmetic is duplicated. A reading the engine cannot state is listed disabled with the engine's reason (R12). A reading already priced shows which build-up line it feeds; a reading can reach exactly one destination. A reading of 0 warns that the next calculation save will remove it. The write is `PUT /calculators/:id/links` (whole register plus one link — the route REPLACES) followed by `updateWorkItemComponentRateLink` on the `componentRowId` the response carries, exactly as **Choose rate** does. A selected rate whose canonical unit does not match the reading's output key dimension shows an inline unit mismatch warning. Whole-unit stock options are exempt: `rateOptionConversionKind({ rateGroup: null, optionUnit })` is called with `rateGroup: null` (stock kinds only); when its `numeratorUnit` equals the reading's dimension the warning is suppressed, because the option's factor will order the correct stock count at pricing time. Quarry density options (`numeratorUnit === "tonne"`) do not cover length or area dimensions and still warn on those readings.
* **Layer destinations** — the D67 partition editor, embedded rather than nested in a second dialog (a dialog inside a dialog is not operable). It loads every calculator in the estimate, including calculators with no destination on the current work item.

The two views are mutually exclusive about which rows they list. `layerCatalogue` excludes measurement rows; `measurementCatalogue` (new) excludes layer rows. Neither view renders rows from the other.

R9: a build-up row whose quantity is derived from a calculation (`calculatorId` is set) renders its quantity as read-only text — "Set by \<calculation> — edit the calculation". The cell was an editable input whose value the next calculator save silently overwrote. The rate on the same row remains editable and survives re-derivation.

The write is serialised: one `PUT /calculators/:id/links` at a time across both views. Every write is built from the register the last response left, so two concurrent writes cannot overwrite each other from the same stale snapshot. A response is adopted into the layer drafts only when that calculator is clean, so a measurement write cannot discard shares typed while it was in flight.

`toLinkInputs` requires the calculator's current register as a second parameter and appends measurement links verbatim before writing, so a layer-assignment save cannot delete priced readings. A link naming a layer the engine no longer emits is still dropped; that is a separate rule and stays.

#### Save-all behavior and warning notices

The **Save destinations** button sends every calculator with unsaved edits in a single pass (`saveAll` in `calculatorAssignmentsView.tsx`). The pass runs as follows:

1. Every share is checked against the client's own share arithmetic before anything is sent. A share that is already malformed refuses the whole save; nothing is written.
2. Each calculator is then attempted independently. A server refusal on one does not stop the others — each `PUT /calculators/:id/links` is its own call, and a failure naming one calculator leaves the rest to continue.
3. A partial save never reads as success. The tab shows one alert per refused calculator. Successfully saved calculators re-seed clean; refused ones keep their drafts and remain guarded.
4. When the server returns warnings — a D67 under-allocation notice, or an engine-level note about geometry — the tab shows them. The embedded work-item tab stays open after every outcome, including a clean save; the standalone side sheet can close after a clean save with no warnings.

`describeWarnings` formats D67 service warnings; `describeEngineWarnings` formats the engine's own `CalculatorEngineWarning` shapes. Both lists are collected as `notices` and shown in the `AssignmentMessages` component alongside any refusals. Noticed warnings do not block subsequent saves; the estimator can press **Save destinations** again after reviewing them.

`neutralVolumeLayers` in `calculatorAssignments.ts` identifies layers that use material by volume instead of a bulk-physics conversion, allowing the **Used by** display to distinguish them. With `calculators 1.6.0`, pit reinstatement layers moved from the top-level `backfill` key into `layers[]`. A migrated pit keeps `pit-backfill` as the first layer's id. The helper now reads every normalised layer's `nonBulk` value from `layers[]`, so legacy non-bulk backfill is no longer classified as bulk.

For the `fill-layer-stratum-unspecified` warning, `describeEngineWarnings` now renders the engine-owned `FILL_LAYER_STRATUM_UNSPECIFIED_MESSAGE` rather than converting the warning kind into raw words. The estimator sees **Choose which excavation layer this reinstatement layer comes from.**

`AssignmentMessages` is shared between `CalculatorAssignmentsSheet` and the embedded **Layer destinations** view inside `WorkItemCalculations`, so refusals and notices render identically in both frames.

#### D69 — provenance links

`calculatorProvenance.tsx` provides a shared renderer used by three surfaces: the work-items desktop row, the work-items mobile card (which had no calculator affordance before this), and the detail pane's build-up lines.

`CalculatorProvenanceLink` renders the calculator's name as a visible link. The name is read live from the server — `calculatorName` can be null while `calculatorId` is not (a renamed calculator retains its id). A missing name falls back to the label **Calculator**. Clicking the link opens the `WorkItemCalculations` tab on the **Layer destinations** view for that calculator.

The work-item row and card action menu entry formerly labelled **Calculation assignments** is now labelled **Calculations** and opens the `WorkItemCalculations` tab on its default **Measurements** view. Selecting a provenance link opens the tab on **Layer destinations** by passing `focusCalculatorId`.

`CalculatorProvenanceLink` renders the calculator's name as a visible link. The name is read live from the server — `calculatorName` can be null while `calculatorId` is not (a renamed calculator retains its id). A missing name falls back to the label **Calculator**. Clicking the link opens the `WorkItemCalculations` tab with `focusCalculatorId` set, which starts on the **Layer destinations** view highlighting that calculator (D68).

`WorkItemCalculatorProvenance` shows one link per distinct calculator feeding a work item's build-up. Distinct by id, not by name, because D58 makes names free text and not unique. `activityComponents` are not walked — the server does not enrich them with `calculatorId`.

The server also returns `calculatorReusesExcavatedMaterial: true` on rows whose source layer has engine kind `backfill` (that is, the layer is configured with `isBackfill: true`). This flag is part of the provenance payload and lets the frontend distinguish reuse rows from bought-in fill without inspecting the calculator configuration. An unreadable legacy configuration does not grant the flag — the provenance function catches the parse error and leaves the field `false`.

After a save, the refetch walks the union of where the calculation fed before and after. A layer moved off an item leaves that item's rows changed too; both the old and new destination items are refetched.
The Estimate API (`stralos-api-estimate`) runs a live calculator engine (`@stralos-co-nz/calculators` ≥ 0.3.0). Calculator create, copy, update, and link mutations are functional server-side.

### Default link seeding for project calculators

When a project-level calculator has a `defaultWorkItemId` and a layer has no existing link and has not been marked as decided, `seedLinksForDefault` seeds a link for it using the first `outputKey` the layer exposes.

Measurement rows (`layerKind === "measurement"`) are always skipped. A measurement is a geometry read-out — total length, saw cut length, footprint perimeter, excavation volume, pipe sticks, and each layer's own area, length, and volume — that the estimator may choose to link manually. Auto-seeding them would materialise zero-rate component rows on every existing calculator's next save. The predicate is `isMeasurementRow` from the engine package, not the `m:` id prefix. The activities API (`stralos-api-activities`) takes the identical skip.

The seed order differs by layer kind:

| Layer kind | Seed order |
| - | - |
| `reinstatement` | `looseM3`, `inSituM3`, `tonnes`, `areaM2`, `lengthM`, `count` |
| All other kinds (excavation strata, backfill) | `inSituM3`, `looseM3`, `tonnes`, `areaM2`, `lengthM`, `count` |

`count` is last in both orders and is selected only when the layer states no earlier output. A non-bulk reinstatement layer with a non-geometric unit currently states `count` alone, so it cannot displace a geometric output on another layer.

A bought fill layer seeds `looseM3` first because the estimator purchases loose cubic metres delivered to site, not the compacted void the material fills. Seeding `inSituM3` understated the order quantity by the full compaction factor until manually corrected.

A non-bulk reinstatement layer emits exactly one output — the one determined by its rate item's unit (see [NonBulkMaterialSnapshot](https://help.stralos.app/internal/trench-calculator.md#nonbulkmaterialsnapshot)). The seeder takes that output directly; no seed-order walk is needed.

Excavation strata keep `inSituM3` first. Excavation is paid for by the hole, not by the spoil heap, so in-situ volume is the correct transacted quantity. A global `looseM3`-first order would silently re-denominate every excavation link.

A backfill layer is unaffected: `fillLayerOutputs` withholds `looseM3` and `tonnes` from a backfill layer entirely, so the seed falls through to `inSituM3` regardless of seed order.

The estimator can change any seeded link on the work item detail page.

### Calculator rename

When a calculator has its own name — that is, it is a project-level calculator opened from the **Calculators** page or minted from a work item's row menu — the workbench header shows a pencil next to the title. Selecting the pencil opens an inline input. Saving sends a name-only `PUT` to the existing update endpoint (`{ name }` only, with no config payload). On success the header updates to the server's returned name and the grid row is re-read. On refusal the server's sentence is shown alongside the input and the workbench stays open.

A project-level calculator opened from the **Calculators** page shows the pencil. A calculator whose config is stored as a legacy `jsonb` column (read-only; predates project-level calculators) does not show the pencil and cannot be renamed — its title is the host item's own name.

`CalculatorsPanel` renders as a `DataTable` (grid layout, not cards). Each row exposes four columns: **Name** (with an inline pencil rename control), **Type** (a `KindChip`), **Summary**, and **Destinations** (destination count). Row actions are **Copy** and **Delete**. **Delete** opens `CalculatorDeleteDialog`, which names every work item and activity the calculator feeds before the estimator picks **Delete the rows too** or **Delete, keep the rows**. The workbench's **Remove calculator** opens the same dialog for a stored calculator and closes the workbench after the delete.

The **Summary** cell comes from `describeCalculator` in `calculatorRegistry.tsx`: `Trench: …` for a trench, `Pit: L × W × D · N structures` for a pit, and `Volume: L × W × D · N layers` (or `area × depth` in `areaDepth` mode) for a volume. A pit or volume missing a dimension reads `dimensions not set` in place of the plan. A calculator with no saved geometry shows **No dimensions entered yet**.

#### Calculator rename (D80)

Estimators can rename a calculator without opening it. The **Name (optional)** cell renders a plain-text button that opens the calculator and a pencil icon beside it. Clicking the pencil swaps the name for an inline input; saving sends `{ name }` only through `PUT /calculators/:id`. The workbench header title is also an inline editor: the same name-only PUT runs from inside the open workbench so a rename there does not save the geometry draft. A blank name is refused locally with the same toast that blocks blank work-item names (`BLANK_NAME_REFUSAL`). A server refusal is shown verbatim.

#### `CalculatorModalHost`

`CalculatorModalHost` in `src/calculatorRegistry.tsx` mounts the calculator for whichever calculator arm the `CalculatorEditorSeed` discriminates. It accepts a `page` prop: when `page` is `"workbench"` it renders the workbench frame (`CalculatorWorkbenchPage`); when absent or `"modal"` it renders the legacy modal for embedded editors. It delegates substance loading and fill-material loading to per-arm wrappers (`TrenchCalculatorHost`, `PitCalculatorHost`, `VolumeCalculatorHost`) that each call `useDisposalSubstances` and `useFillMaterials` once per open editor.

##### Dirty-close guard (`hostDirty`)

For the legacy embedded editors (modal frame), the modal's own dirty-close guard compares `JSON.stringify(draft)` against a pristine snapshot of the geometry it owns. Two categories of unsaved work live in the host rather than in the modal, and are therefore invisible to that comparison:

* **Held name** — an unsaved rename is staged in `heldName` (host state in `CalculatorsPanel` / `WorkItemsPanel`). The name-only `PUT` does not run until **Save**; closing before saving discards the rename silently under the modal-only guard.
* **Pending "Used by" assignments** — every work-item choice made in the **Used by** picker is held by `useLayerAssignments` as a `PendingAssignment` and applied only on Save. `hasPending` (a boolean on the `useLayerAssignments` return value) is true whenever at least one such choice is waiting.

`CalculatorModalHost` accepts a `hostDirty` boolean and passes it explicitly to each of the three arms — never through a JSX spread, because a JSX spread of a variable is exempt from TypeScript's excess-property check and a misspelled prop would drop the guard silently. The modal ORs `hostDirty` into its own dirty state, so closing the modal after renaming or making a pending **Used by** assignment prompts **Discard changes?** even when no geometry has changed.

For the workbench frame, `useCalculatorLeaveGuard` owns dirty state directly and reads it via a ref at navigation time. See [Calculator workbench route (D1)](#calculator-workbench-route-d1).

The host computes `hostDirty` as `heldName !== null || editorAssignments.hasPending` and passes it down. Nothing outside `useLayerAssignments` may act on which choices are held — that is `flushPendingAssignments`'s job — so `hasPending` is a boolean rather than the pending-assignment list.

`useDisposalSubstances` issues two concurrent requests: `listAllDisposalSubstances` (paging `GET /api/global/disposal-substances?status=all`) and `listSubstanceUsage` (`GET /calculators/substance-usage`). Usage failures are silently discarded — the picker falls back to alphabetical order. List failures set `{ status: "error" }` and do not retry.

`useFillMaterials` pages every Bulk Materials rate item from `GET /api/rates/rate-summaries?rateGroup=quarry_materials` to exhaustion (200 per page). A failed load leaves the calculator open with the saved fill-material snapshot authoritative; estimators cannot pick a new material until the read succeeds. The `quarry_materials` token is a rate-group constant, not a category name — no organisation names a category "Bulk materials".

The three pricing props that pre-dated the 0.24.0 ui pin (`rateSummaries`, `currency`, `loadRateOptions`) are no longer passed to any calculator editor. Rate items, locations, and unit costs are now chosen on the work-item line through the ordinary pricing engine. `CalculatorsPanel` makes no rate-catalogue request.

`useDisposalSubstances` passes each `DisposalSubstance` row from the Global API through to `calculator-ui` untouched, including the optional `canUseForReinstatement` flag. `calculator-ui` derives the flag when it is absent (for api-global deployments that predate the field). The host never derives or invents this flag.

#### Calculator config save error rewriting (`describeConfigSaveError`)

`describeConfigSaveError` in `src/calculatorsApi.ts` rewrites one specific 400 refusal before the calculator editor displays it. When the server returns `400 calculator_config_backfill_from_ineligible` — a reinstatement layer references an excavated substance marked `canUseForReinstatement: false` — `describeConfigSaveError` replaces the server's message (which begins with a raw config path) with the constant `BACKFILL_FROM_INELIGIBLE_MESSAGE`:

> A reinstatement layer is filled from excavated material that can no longer be used for reinstatement — choose another material for it.

All other 400 refusals and 409 conflict prompts pass through unchanged. The rewriting lives in `src/api.client.ts` via `rewordApiError`, which preserves the error's HTTP status, body, code, URL, and diagnostic while replacing only the user-visible message.

#### Activity-insert copy failure (`activityCalculatorCopy.ts`)

`activityCalculatorCopyFailure` (`src/activityCalculatorCopy.ts`) produces the message the insert review shows when copying an attached activity's calculation fails. Two paths diverge on the error code:

* **`calculator_config_backfill_from_ineligible`** — the copy is a `POST /calculators` with no stored config to exempt, so the same substance will refuse the copy on every retry. The message says the activity was added but the calculation could not be copied, followed by the reinstatement refusal sentence. No retry button is offered because retrying cannot succeed.
* **Any other error** — the message says the activity was added but the calculation could not be copied, and offers **Retry** to finish without adding the activity again.

`copyAttachedActivityCalculators` tracks which copies have already succeeded in a `Set<string>` so a retry skips calculators that went through on the first attempt and only retries the one that failed.

### Non-bulk materials (`src/nonBulkMaterials.ts`)

`useNonBulkMaterials` (in `calculatorRegistry.tsx`) loads every rate item from `GET /api/rates/rate-summaries` in full and maps them through `nonBulkMaterialsFrom` to the calculator's `NonBulkMaterialsState`. The host passes this state as `nonBulkMaterials` to `TrenchCalculatorModal` only; pit and volume wrappers omit the prop deliberately (R8: neither arm has a length axis, and the pit's backfill layer has no area).

`nonBulkMaterialsFrom` excludes items whose `rateGroup` is `quarry_materials` or `disposal` — both already have dedicated controls on the calculator row. Every other item, including unclassified items (`rateGroup: null`), is offered. Inactive items are kept rather than dropped: the ui's `nonBulkMaterialChoices` filter (`status === "active" || option.id === layer.nonBulk.id`) allows a retired item to remain visible for a layer that already names it.

Each option carries `id`, `name`, `unit` (`modeUnit ?? null`), and `status`. The unit is `modeUnit` exclusively — not the legacy `item.unit` column (dropped in api-rates migration 0036) and not `costRate.unit` (the price range's axis, which can name a unit for an item with no `modeUnit`). The rate item picker filters the item list by name as you type.

A failed load leaves the modal open with the "Non Bulk Material" row present but with an error state — the estimator can still save a previously chosen item because the snapshot is already on the layer.

## Multi-estimate module (`src/multiEstimate/`)

PRD 210 introduces multiple tender estimates per project. Since 2026-10-04 this is a **project setting**, not a PostHog flag: `src/multiEstimate/flag.ts` (`useMultiEstimatesEnabled`) reads `multipleEstimatesEnabled` from api-estimate `GET /projects/:projectId/estimate-settings` once per project per page load, and re-reads it when Project settings saves it (`ESTIMATE_SETTINGS_CHANGED_EVENT`, `stralos:estimate-settings-changed`). The setting is off unless set; projects that already held more than one live tender were backfilled on. Loading or a failed read answers "off". Comments in the source still call it "the `multi-estimates` flag".

When the setting is off, the estimate page is the single-estimate page: the simple **Mark won** confirm, no **Duplicate as new estimate** on a version, and bare `/estimate` paths render directly rather than redirecting to the primary's canonical address. Turning it off never deletes an estimate.

### Estimate kinds and identity

Each estimate now carries a `kind` field: `"tender"` (all current estimates), `"delivery"` (the post-award Delivery copy), or `"saved_version"` (a historical snapshot). `EstimateKind` and `EstimateListItem` in `src/multiEstimate/api.multiEstimate.ts` are the canonical types; `toEstimateListItem` normalises server shapes so older API deployments degrade gracefully.

`isHiddenOrVersion` — not-awarded (hidden) tenders and saved versions are read-only regardless of their status field. Only the server checks this definitively on write; the client checks it to gate editing actions up front.

### Switcher and estimate sheets (workspace remote)

The estimate switcher, the Estimates sheet (create, rename, duplicate, make primary, delete) and their name helpers live in the **workspace** remote (`RailEstimateSwitcher`, `ManageEstimatesSheet`), not here; see [Workspace frontend](https://help.stralos.app/internal/workspace-frontend.md). The estimate remote's `EstimatesCard` and `estimateSheets.tsx` have been removed. The estimate remote only opens the sheet and refreshes when it reports a change (see **Manage Estimates sheet and in-place switching** below).

### Mark Won picker (`MarkWonSheet`)

`src/multiEstimate/MarkWonSheet.tsx` implements the **Mark won** action when the project's Multiple estimates setting is on. It replaces the single-button confirm with a side sheet that lists the project's submitted tender estimates, preselecting the open one, so an org admin or a lead of the project can name the one that was awarded. **Mark won** is only offered on an estimate that is itself Submitted.

`markWonCandidates(items)` filters to live (not hidden) tender estimates in `"submitted"` status. `markWonConsequence` builds the confirmation: the chosen estimate is marked won, the project moves to the awarded stage, the chosen estimate becomes primary, and the contract baseline is created from its tender revision; the other estimates are hidden as not awarded, kept read-only, and can be shown from the estimate list.

### Restore version dialog (`RestoreVersionDialog`)

`src/multiEstimate/RestoreVersionDialog.tsx` implements the PRD 210 R18 restore prompt. A graph-format version row in the versions sheet offers **Restore…** (and **Duplicate as new estimate** when the Multiple estimates setting is on); the **Restore version N** dialog appears before anything is written and offers two choices:

| Choice | Behaviour |
| - | - |
| **Keep the version's saved prices** | Restore with `prices: "keep"` — every price, project rate, margin and price choice comes from the version. Nothing is recalculated. |
| **Reprice with current rates** | Restore with `prices: "reprice"` — items and quantities come from the version, priced at the estimate's current project rates and the rate library as they are today. |

The dialog fetches `getRestorePreview(estimateId, versionId)` automatically when it opens. `restorePreviewSentence(preview, currency)` words the result under the reprice choice: how many lines would change price and whether the total goes up or down, plus a day-rate line saying how many day-rate lines would move to the project's current day-rate revision.

What a version row offers depends on its storage (`VersionActions` in `src/EstimateVersionsModal.tsx`): a graph version gets **Restore…**; an old JSON version gets **View** only (R22); a server that predates PRD 210 (`storage === "legacy"`) keeps the old **Create from this version** flow.

### Primary estimate redirect (`primaryEstimateRedirect.tsx`)

`src/multiEstimate/primaryEstimateRedirect.tsx` provides `PrimaryEstimateRedirect`, which reads the project's estimate list and redirects bare `/estimate` URLs to the primary estimate's canonical address (`/projects/:projectId/estimates/:estimateId/…`). Only when the Multiple estimates setting is on; when it is off the bare paths render in place.

### Manage Estimates sheet and in-place switching (PRD 210 §8.1)

`src/multiEstimate/manageEstimates.ts` owns the cross-remote event contract for managing a project's estimates. The Estimates sheet lives in the **workspace** remote (on every project page), not in the estimate remote. The estimate remote asks the workspace to open it via a custom event and refreshes its own page when estimates change.

**Events:**

| Constant | Value | Detail |
| - | - | - |
| `OPEN_MANAGE_ESTIMATES_EVENT` | `stralos:manage-estimates` | Dispatched to `window` to ask the workspace's sheet to open. Detail: `{ projectId }`. |
| `ESTIMATES_CHANGED_EVENT` | `stralos:estimates-changed` | Dispatched by the workspace sheet when the project's estimates change. Detail: `{ projectId, estimateId? }`. |

**Overview button:** the **Manage estimates** button in `EstimatePage`'s overview tab calls `openManageEstimates(projectId)`, which dispatches `OPEN_MANAGE_ESTIMATES_EVENT`. The legacy `?manage=estimates` query parameter is still honoured: on load, `EstimatePage` detects the param, strips it from the URL, and dispatches the same event.

**Change refresh:** `stralos:estimates-changed` triggers `refreshLifecycleState` with a background flag. The page re-reads its lifecycle state without remounting. A change from another project's estimate is ignored.

**In-place estimate switching:**

A switch between two estimates of the same project is not a reload. `EstimatePage` is keyed on the project, not the estimate, so React does not unmount the component. When the URL estimate segment changes to a different estimate of the same project:

1. `switchPending` and `estimateSwitching` become true.
2. The page content is wrapped with `inert` and `aria-busy` — all interactions are disabled and the screen dims while the new estimate loads.
3. The URL updates immediately to name the new estimate.
4. The new estimate's lists load in the background. Once ready, the data swaps under the same panels.
5. If the new estimate list does not arrive within 15 seconds, the inert guard times out and releases the page.

A project change, a direct reload, an unreadable estimate, or the bare project path still trigger a full load.

**Work item carry-across switch (`src/WorkItemsPricingPanel.tsx`):**

When a work item is open and an estimate switch happens, `carryAcrossSwitchRef` records the outgoing item's `estimateId`, `workItemId`, `itemNumber`, and `name`. Once the new estimate's list arrives, the panel:

* Looks up the item by `itemNumber` (preferred) or by exact name when there is exactly one name match.
* If found, opens the matched item and rewrites `?wi=` to the new item's id.
* If not found, returns to the list view and shows a toast: `"Item X.X is not in this estimate"` (or `"<name> is not in this estimate"` for unnumbered items).

While a switch is pending the panel holds the **outgoing** estimate's id so its autosaving pricing never pairs the open item with the incoming estimate.

## Delivery (`src/delivery/`)

The Delivery module is controlled server-side by the `ESTIMATE_DELIVERY_ENABLED` flag. When the flag is off, the server returns `404 delivery_disabled` on all Delivery routes and the client renders `DeliveryUnavailable`. Client-side there is no additional flag; a deep link in test or prod lands on the "not switched on" state rather than a broken page.

### Delivery pages

| Component | Path | Purpose |
| - | - | - |
| `DeliveryComparePage` | `/delivery/compare` | Shows the won tender revision beside the Delivery estimate (tracking sell, internal). Defaults to the latest signed-off position once one exists, otherwise live rows. Supports CSV export. |
| `DeliverySignOffPage` | `/delivery/sign-off` | Sign-off workflow: `draft → in_sign_off → signed_off`, with `reject` and `reopen` transitions. Any project member can request; any one project lead can approve or reject. Each signed-off revision is followed by a "Variations after sign-off" group. |
| `DeliveryVariationsPage` | `/delivery/variations` | Lists approved variation rows applied to the Delivery estimate, grouped by variation reference. Variations applied after the signed revision's snapshot are badged "After sign-off". Review items raised while Delivery was a draft must be acknowledged before requesting sign-off. |

`useProjectDelivery(projectId)` fetches the project's Delivery estimate list item once and exposes it with a `reload` callback. `deliveryShared.tsx` owns the empty states, page frame, signed-money display (`SignedMoney`), and the `DeliveryStatusBadge`.

### Delivery sign-off state machine

```
draft ──request_sign_off──▶ in_sign_off ──approve (any ONE lead)──▶ signed_off
  ▲                          │ reject / withdraw                         │
  └──────────────────────────┘◀──────────── reopen (any lead) ───────────┘
```

`availableDeliveryActions(status, isLead)` computes which transitions the page offers. The lead check is advisory; the server re-checks the role at decision time.

## Variation rows on the estimate schedule

`src/variationRows.ts` reads the variation and tracking-sell wire fields that an approved variation appends to the estimate schedule. These fields are read defensively — an older server that does not send them produces "no variation rows" rather than a type error.

| Field | Meaning |
| - | - |
| `variationId` | The variation that owns this row. |
| `variationLineId` | The specific variation line. |
| `variationRowRole` | Row kind: `"addition"`, `"reversal"`, `"replacement"`, `"quantity"`, `"settlement"`. |
| `variesWorkItemId` | The original schedule line this row nets out, adjusts, or replaces. |
| `variationReference` | Short reference such as `"VO-3"`. |
| `trackingSellAmount` | The Delivery tracking sell for this row (internal, never client-facing). |
| `trackingSellUnitRate` | Per-unit tracking sell rate. |

`variationWireFieldsOf(row)` copies only the keys that are present so a row from an older server adds nothing to the mapped item. `variationFieldsOf(item)` reads these fields for display logic. The estimate schedule groups variation rows into sections headed by the variation reference (e.g. "VO-3 · Pipe upgrade") and shows the tracking sell read-only on a Delivery estimate with no contingency.

## Quarry supplier route resolution (`src/quarrySupplierRoutes.ts`)

Distance is measured per supplier location: each quarry site is measured on its own, never per company. `useQuarrySupplierAnalysis` in `src/quarrySupplierAnalysis.ts` collects every site a quarry price names and calls `measureProjectLocationRoutes` (`src/api.global.ts`). A supplier with an all-locations price is sent as a company id, and api-global adds every active site that company owns.

### Partial-results request

`measureProjectLocationRoutes` sends `partial: true` to `POST /api/global/projects/:projectId/location-routes/resolve` in batches of 200 (api-global's `MAX_LOCATIONS`). The server measures and caches missing or stale legs, returns the measured sites in `routes`, and names every site it could not measure in `unroutable` with a reason. A site the answer does not mention is recorded as `not_measured`.

If a measuring call fails, the client falls back to the cached `GET /api/global/projects/:projectId/location-routes` read for the same batch. A routing outage then costs only the sites not yet measured, not the whole read. A failed cached read still throws.

The company-level `POST /api/global/projects/:projectId/supplier-routes/resolve` and the client's bisection fallback are gone; api-global no longer serves that route.

### Failure reasons

`QuarryRouteFailureReason` is a site's reason from `UnmeasuredLocation`:

| Reason | Source | Notice |
| - | - | - |
| `no_coordinates` | Server: the site has no address on the map. | **No address for this site** — add an address to the site in Companies. |
| `not_found` | Server: the site is no longer in Companies. | **Site not in Companies** — fix it in the rate, not on this estimate. |
| `no_route` | Server: no road route between the project and the site. | **No road route found**. |
| `routing_unavailable` | Server: the distance service did not answer. | **Distance not measured yet**. |
| `not_measured` | Client: the measurement failed and the cached read had no fresh leg. | **Distance not measured yet**. |

### Failure notices (`describeQuarryRouteFailures`)

`describeQuarryRouteFailures` in `quarrySupplierRoutes.ts` groups failures by remedy and produces one `QuarryRouteFailureNotice` per remedy present, in this order: no address (actionable), not found (a data problem), no route, then not measured. Each notice carries a `kind`, a heading (`title`), the affected sites as "Supplier — Site" (up to six names, then "and N more"), and a `detail` sentence. Rates for these sites are still priced; only the distance is missing.

### `routeFailures` state field

`QuarrySupplierAnalysisState` carries a `routeFailures` field alongside `error`. These are distinct: `error` is set only when the whole read fails and the comparison panel cannot be shown. `routeFailures` is populated when some supplier sites could not be measured but the rest of the data is valid — a partial read, not a failed one. The panel shows route-failure notices below the comparison table while `error` stays null.

The field is `optional` on the type so pre-existing fixtures that do not set it read as "no failures", which is both the correct interpretation and the state before this field existed.

## Stored monetary figures

Stored figures are the single source of truth for money on the estimate screens. api-estimate (migration 0106) stores every work item's sell rate to the cent (`unitRate`) with `totalPrice = round2(quantity × unitRate)`, each section's `subtotalPrice`, and the estimate's `itemsTotal`, `contingencyTotal`, `tenderTotal`, and `unsectionedTotal`. The estimate totals ride on the `GET /estimates/:id/sections` envelope.

### Item sell rate

`workItemUnitRate(item, "price")` in `src/workItemMoneyDisplay.ts` returns `item.unitRate` whenever it is non-null. Only a null `unitRate` (a provisional sum, a quantity of zero, or a frozen row no cent rate reproduces) falls back to dividing `totalPrice` by `quantity`. There is no stored cost rate, so the cost rate is always derived. `WorkItemsPanel` reads the rate through this helper. `TenderPricedItemsPanel` uses `item.unitRate` directly as a 2 dp reconciling rate and calls `reconcilingRate` only for a row with none.

### Section subtotals

`sectionSubtotalPrice(subtotalPrice, sumRows)` in `src/workItemOutline.ts` returns the stored `subtotalPrice` and calls `sumRows()` only when it is null. The Unsectioned group has no section row, so callers pass `storedTotals.unsectionedTotal`. `WorkItemsPanel`, `TenderPricedItemsPanel`, and `WorkItemNavigation` use it for section headings and rail subtotals.

### Estimate totals

`StoredEstimateTotals` (`src/api.types.ts`) is `{ itemsTotal, contingencyTotal, tenderTotal, unsectionedTotal }`, all nullable. The page keeps it in `storedTotals` state and passes it to `SummaryPanel`, `WorkItemsPanel`, `TenderPricedItemsPanel`, and `WorkItemsPricingPanel`.

`estimateCommercialBuild` (`src/commercialBuild.ts`) takes `stored` as its last parameter. When `storedTenderTotals(stored)` returns a value (both `itemsTotal` and `tenderTotal` non-null), the subtotal, contingency, and tender price are those figures and `estimateTenderTotals` is skipped. The cost total is always summed from the rows. A null stored contingency is derived as `tenderTotal − itemsTotal`.

### Tests

`src/storedItemFigures.test.tsx` builds every fixture so the stored figure and the re-derived one differ (`quantity 0.3 × unitRate 33.35 = 10.005 → totalPrice 10.01`, but `10.01 ÷ 0.3 = 33.37`), so a surface that still derives fails. Add a case there when touching any of the surfaces above.

## Estimate rounding

The flag and whole-stock helpers live in `src/estimateRounding/`. Both rounding controls sit behind the `estimate-rounding` PostHog flag, read with `useEstimateRoundingEnabled()` (`fallback: false`). The flag is frontend only: api-estimate always honours a stored rounding, so a link or estimate rounded while the flag was on still prices rounded with it off.

* `QuantityLinkSheet` shows **Round up to** only where **Adjust** is shown and the host passes `roundingOffered` (false for a whole-stock build-up line, see `isWholeStockLine` in `src/estimateRounding/wholeStock.ts`, which mirrors api-estimate's check). A new link follows `suggestQuantityRounding` until the estimator picks; an existing link, or any link with the flag off, saves its stored `rounding` verbatim.
* `TenderPricedItemsPanel` shows the estimate's **Round sell rates up to** (saved by `updateEstimateSellRateRounding`, a `PUT /estimates/:id` with `sellRateRounding`) and a per-item **Round sell rate up to** column (`updateWorkItemSellRateRounding`, a `PUT /work-items/:id`; `null` inherits the estimate). Items in a Shared Costs section, provisional sums, and zero-quantity rows get no control. `unitRateUnrounded` is the rate tooltip, and `workItemRateMarginsWithRounding` counts the uplift as margin in the percentages.

See [Estimate rounding](https://help.stralos.app/internal/estimate-rounding.md) for the option lists and the rounding contract.

## Tender download (`src/TenderDownloadPanel.tsx`)

The `tender-download` Tender Submission section (after `tender-check` in `tenderSectionNavigation`) owns the PDF, Excel, and original-workbook downloads; `TenderPricedItemsPanel` keeps only a pointer link. The panel reads the project scope fresh (`getProjectScope`), the company terms (`getOrganisationTerms`), and the project documents (`listAllDocumentsByProject`, filtered by `receivableDocuments`).

* A section that is known to be empty (no scope, no terms, no documents) has its tick disabled and is sent as `false`. A read that is still loading or failed is not known empty: `holdReason` holds the download instead, matching api-estimate, which never drops a ticked section silently.
* `sections` and `documentIds` are sent with `downloadSubmissionPdf` and `downloadScheduleSubmissionXlsx` (layout `submission`). The original-workbook export sends neither.
* `downloadErrorMessage` shows api-estimate's own message for the `tender_terms_unavailable`, `tender_documents_unavailable`, and `tender_project_scope_unavailable` 502s.
* The panel shows the saved project scope read-only, with **Edit in project settings** to change it. Scope editing and **Create scope with AI** live in `ProjectScopeCard` in stralos-feature-project-settings (`src/projectScopeAi.ts` calls api-documents `POST /projects/:projectId/tender-scope-draft`).

## Formatting and review safeguards

### Percent format (`src/percentDisplay.ts`)

All percentage values rendered by this remote now go through `displayPercent` or `displayRatioPercent` from `src/percentDisplay.ts`. The canonical format is number, space, percent sign — `"15 %"`. `check:display-format` (`scripts/check-percent-format.mjs`) fails any hand-rolled `${x}%` template in production source. Precision is the caller's statement: use `{ fixed: N }` where column alignment requires fixed places, and omit it where the value's own precision is enough.

### Rate exception same-unit sentence (`src/RateExceptionsPanel.tsx`)

`rateExceptionSentence` now detects when both sides of a `measured in X, priced in Y` clause format to the same display unit (for example `m2` and `square_metre` both rendering as m²). In that case the formatted sentence would contradict itself. Instead it replaces the clause with **the line and the rate both use m², but under different unit records** and the resolution with **re-pick the unit on the line**.

### Takeoff review: only the open decision is offered (`src/takeoffSubComponents.tsx`)

`TakeoffReviewPanel` now shows only the action that can still be taken. When `decision === "approved"`, only **Remove from takeoff** (which calls `onDecide("rejected")`) is rendered. When `decision === "rejected"`, only **Approve for takeoff** is rendered. When the decision is `null` (pending), both are shown. The current state is always shown as a chip, never as a primary button.

### Add contractor as a sheet step (`src/SubcontractsPanel.tsx`)

The **Add contractor** create is a step inside the package `SideSheet`. Clicking **Add contractor** retitles the open sheet and swaps its body for the contractor form. **Back** returns to the package form without saving. The flow uses one sheet rather than stacking a centred dialog above it.

### Virtual scrolling (`VirtualTableBody` and `InfiniteList`)

Several large tables and card lists in the estimate remote render only the rows in the viewport. This keeps frame times low on packages or rate lists with hundreds of rows.

`VirtualTableBody` (from `@stralos-co-nz/ui` 0.65.0) wraps the `<tbody>` of a Chakra `Table.Root`. It is a drop-in replacement for a plain `<tbody>` and owns all row measurement and recycling internally. Two surfaces use it:

| Surface | Component |
| - | - |
| Contractor return review | `SubcontractBidReviewPage` |
| Contractor portal — rate entry | `SubcontractorPortalPage` |

`InfiniteList` (also from `@stralos-co-nz/ui`) renders a windowed card or row list. These surfaces pass `items` and `renderItem`, with `totalCount` and `itemKey` where needed; both of the latter props are optional. It is used for mobile card views and supplier grid bands:

| Surface | Component |
| - | - |
| Subcontracts panel — mobile card view | `SubcontractsPanel` |
| Project rates — item card view (mobile) | `ProjectRateItemGrid` |
| Project rates — supplier band rows | `ProjectRateSupplierGrid` |
| Work item editor — activity option list | `workItemEditors.tsx` |

`PROJECT_RATES_PAGE_SIZE` (exported from `ProjectRatesPanel`) is a legacy constant retained only so that callers that previously drove a manual page loop do not need to change. Rendering is now handled by `InfiniteList`'s own virtual window; the constant does not control how many rows are mounted.

**Opt-out: Resource summary table.** The Resource summary table in `SummaryPanel` (`src/SummaryPanel.tsx`) passes `virtualized={{ enabled: false }}` and `layout="fixed"` to `DataTable`. Under windowing, a change in mounted rows re-wrapped long resource names, which changed measured row heights and moved the window again, until React hit its maximum update depth and the Overview fell into its error boundary. Fixed layout takes its widths from `SUMMARY_COLUMN_WIDTHS`, so names cut off instead of re-wrapping. The table holds one row per distinct resource, a bounded list, so it renders whole. `src/summaryResourceTableUpdateDepth.test.tsx` covers the regression.

### Resource summary grouping

`groupSummaryResources` (`src/summaryPricing.ts`) groups the summary's resource rows into `SummaryResourceGroup` bands in `RESOURCE_GROUP_ORDER` (Labour, Plant & Vehicles, Materials, Bulk Materials, Disposal, Subcontractors, Day rates, Other), skipping empty groups. Each band carries its rows, `totalCost`, `totalPrice`, and `choiceNeededOnly`. `filterSummaryResources` applies the `SummaryResourceFilter` (`search`, `groups`, `categories`) first; `summaryResourceFilterChoices` (in `SummaryPanel.tsx`) builds the group chips and category choices from the rows so every choice finds something.

`SummaryPanel` passes the bands to `DataTable` as `sections` with `sectionCollapse` (bands start open). The filters sit in the panel's filter slot with a live **Showing X of Y resources** count. While a filter is active the adjustment rows are left out, and the **Estimate total (all resources)** row is always built from the commercial totals, so it covers the whole estimate. Optional columns drop out as the card narrows in `OPTIONAL_SUMMARY_COLUMNS` order: margin, average unit cost, category, items.

### Find a rate sheet (`src/findRate/`)

`FindRateSheet` (`src/findRate/FindRateSheet.tsx`) opens from the **Search rates** icon (`FIND_RATE_BUTTON_LABEL`) on a build-up add row that takes items, bound to that row's target (the work item or an activity). It filters the rate list the build-up already holds (Rates Library rates only; project rates keep their own sheet) in the browser with `filterFindRates` (`src/findRate/findRateModel.ts`); no new request is made. The filters copy the Rates Library's: search over name, code, and supplier names, then category (grouped by rate group), company (your company first, then suppliers), and unit, with **Clear filters** and a live count line. At most 200 matches are listed.

**Add** calls the row's own add function, so routing and charge basis match typing the name: a rate from the work item's own row goes into a new activity. `foundRateQuickAddInput` prefills an hourly rate's quantity with the estimate's hours per day and every other rate with 1. An added rate is marked **Added** and the sheet stays open; all **Add** buttons are disabled while a write is in flight. The footer's **Make a new rate** (when the host passes `onMakeNewRate`) opens the rate editor prefilled with the search text; the host adds the saved rate through `rateSummaryFromNewRate` and announces it with `addedNotice`. The filter menus portal inside the sheet so they open above it.

### Count lines

Estimate lists use the `@stralos-co-nz/ui` count line (`ListCount`, see [Design library](https://help.stralos.app/internal/design-library.md)): **Filtered to N …** while a toolbar filter narrows the list, **Showing all N …** otherwise.

| Surface | Count | `filtered` | Noun |
| - | - | - | - |
| Estimate schedule (`WorkItemsPanel`, `ListCount`) | `visibleItems.length` when filtered, else `totalCount` | A filter chip, search text, status or section filter is active (`listFiltered`) | estimate item |
| Project rates (`ProjectRatesPanel`, `ListCount`) | Matching rows | `activeFilterCount > 0` | item or rate, by view |
| Project rates item cards and supplier bands (`ProjectRateItemGrid`, `ProjectRateSupplierGrid`) | Groups or bands shown | Passed from the panel | item / supplier |
| Subcontracts mobile cards (`SubcontractsPanel`) | `totalCount` | Always `false` | package |

### Soft-delete confirm copy (`src/estimateShared.tsx` — P2-6)

Every red (`destructive`) confirmation dialog in this remote is a delete that the server keeps (`deleted_at`). The default reversibility note shown under the message is therefore **"It's kept for history."** rather than **"This cannot be undone."**

This default is applied by `defaultReversibility` in `src/estimateShared.tsx`, which supplies `{ reversible: false, note: KEPT_FOR_HISTORY }` whenever `confirmTone` resolves to `"destructive"` and the caller does not pass `reversibility` explicitly. The constant `KEPT_FOR_HISTORY` is `"It's kept for history."`.

Two surfaces opt out and keep the plain irreversible line by passing `reversibility: { reversible: false }` directly:

* **Discard measurement in progress** (takeoff drawing) — the in-progress measurement is truly lost; the server never held it.
* **Unlock SOQ** — irreversible in scope; not a delete.

**Variation withdrawal** also keeps the plain line. A withdrawal changes a variation's status on the register rather than deleting a record.

The confirm title follows the copy standard `"Delete {thing}?"` and the body names what the record is removed from, without restating "this cannot be undone".

### Lump-sum change no-change gate (`src/VariationsPanel.tsx`)

`VariationLineEditorModal` now blocks saving a lump-sum change whose varied amount equals the frozen agreed amount. The save button is `aria-disabled` and the editor shows **Enter a different amount** while the amounts match. The preview line value is hidden while the line would change nothing.

### Unit catalogue (`workItemUnits.ts`)

The unit list is not owned by this remote. It lives in api-global's `unit_catalogue` table (what the served catalogue API sends), `@stralos-co-nz/api-common` (backends), and `@stralos-co-nz/ui` (frontends). The picker offers exactly what the served catalogue sends, in the served order, with the served names and symbols. `@stralos-co-nz/ui`'s `UNIT_CATALOGUE` is the offline fallback used only while the unit API cannot be reached.

`useWorkItemUnits` in `src/workItemUnits.ts` loads the catalogue via `listUnitCatalogue` (`GET /api/global/unit-catalogue`) and exposes it as a `useSettingList`-managed list. While the API is in flight, `isFallback` is `true` and the picker falls back to ui's own copy. Once the served rows arrive, the picker uses them exclusively.

`resolveWorkItemUnitDisplay` resolves a stored unit code to its display symbol. A stored value is not always a catalogue code (SOQ-imported rows carry the workbook's spelling). Resolution order: the served catalogue by code, then the catalogue by canonicalised code (`canonicaliseUnit`), then `formatUnit`. This keeps the Unit column consistent even for rows imported before the catalogue was served.

Units added to api-global's catalogue — such as `length (len)`, `roll_length`, `roll_area`, and `sheet` — become available in the picker automatically without any change to this remote.

### Unit picker alias resolution (`WorkItemUnitSelect` — D3)

`WorkItemUnitSelect` (`src/WorkItemUnitSelect.tsx`) uses the shared canonicaliser (`canonicalWorkItemUnitValue`) in both its match predicate (`unitMatches`) and its commit path (`unitNamedBy`). This means an estimator can type a legacy or alias spelling (`m3`, `cu m`, `t`, `tonne`) and Stralos resolves it to the correct catalogue code on blur. The previous behaviour silently committed metres when a typed alias did not find an exact symbol or name match.

**Three exit states on blur:**

* Typed entry names exactly one unit (by alias, symbol, or name) → that unit is written, as if it had been picked from the list.
* Typed entry matches no unit → field stays open with the text on show, sets `customValidity` to `"No unit matches"`, and marks the field invalid. The form cannot be submitted while the field is invalid; the previous unit is not restored.
* Typed entry is a partial search that still matches some rows → the field reverts to showing the committed unit.

`unitMatches` runs `canonicalWorkItemUnitValue(needle) === option.code` as a fourth check after label, value, and code comparisons, so the full alias table is searchable rather than only the catalogue symbols.

### Negative markup / margin warning (`NegativeMarkupWarning` — V1)

`NegativeMarkupWarning` (`src/NegativeMarkupWarning.tsx`) renders an `Alert.Root status="warning"` whenever a percentage argument is a finite number less than zero. It is used in two places:

* **Estimate settings** — beside the default markup or margin field when the saved value is negative.
* **Tender Submission → Margins** — beside any rate-group or item-category rung whose effective percentage is negative. The warning names the rung (via `negativeMarkupSentence`'s optional `subject` parameter) so adjacent rungs are unambiguous.

The warning is informational only. No value is refused. It persists as long as the number remains negative.

`negativeMarkupSentence(percent, basis, subject?)` produces the display text. It uses `basisNoun(basis)` to say "markup" or "margin" and `displayPercent(percent)` for the formatted value.

### Margin rung provenance (`marginRungReport.ts` — F2)

`marginRungReport.ts` provides `marginRungLines` and `pricedByLabel`, which power the **Which markup/margin priced each line** table in `TenderMarginsPanel`.

`pricedByLabel(source, component, ladder, namers)` converts a `PricedMarginRung` enum value (`"component"`, `"category"`, `"rate_group"`, `"tender"`, `"organisation"`) into a human-readable string. For `"category"` and `"rate_group"` rungs it appends `" (this tender)"` or `" (organisation)"` by looking up the rung in the ladder to establish who set it.

`marginRungLines` reads `marginSource` and `marginPct` directly from the server's component rows. This is deliberate: a client-side re-walk of the ladder would return the ladder's current state, which can differ from what the server actually applied (for example, a category override that saved but matched no component because all components carried a `null` category). Reading the recorded rung makes such discrepancies visible rather than hiding them.

Activity containers (`isBuildUpContainer(component)`) are excluded from the list. An activity container's money is the sum of its children's rows, each of which appears in the table on its own row. The server records a rung on the container too, so without this exclusion an activity's lines were listed twice — once under the container and once under each child line.

The table is only rendered when `pricedLines.length > 0`. Lines with no recorded `marginSource` (for example, lump-sum or provisional-sum items that derive their price from an allowance rather than the margin ladder) are omitted from the list.

### Day-rate line display helpers (`dayRateLines.ts` and `DayRateLineNotes.tsx`)

`src/dayRateLines.ts` is the single source of truth for how a contractual day-rate build-up line is described and priced on the client. It is pure — no React, no requests — so the build-up pane, the picker, and the tender review margin table all word the same line the same way.

**`dayRateChargedRate(component)`** — the rate a day-rate line is charged at. This is the value the Rate cell displays and what `WorkItemRateMarginsModal` shows in the tender review.

```ts theme={null}
export function dayRateChargedRate(component: {
  unitCost: number;
  valueOverrides?: readonly ComponentValueOverride[];
}): number
```

⚠ This is not `unitCost` alone. `api-estimate` keeps a day-rate line's `unit_cost` column at the schedule's published rate and records the hand-edited rate only in the `unit_cost` value override. A rate cell that reads `unitCost` directly shows the schedule rate after a reload and does not respond to Revert. Use `dayRateChargedRate` for every surface that shows what the line is charged at. `WorkItemDetailPane` and `WorkItemRateMarginsModal` both call it for their Rate columns.

**`dayRateRateOverride(valueOverrides)`** — returns the `unit_cost` value override when one exists, or `null`. A non-null result means the estimator has hand-edited the rate.

**`dayRateEditedNote(dayRate, override, currency)`**: produces the **Edited (schedule \$X)** badge text. When a schedule update moved the line to a newer revision after the edit, returns **Edited (schedule now \$Y)** instead (`pickedAt` predates `overriddenAt`).

**`dayRateOutOfStepNote(dayRate)`** — produces the out-of-step badge text when the line's revision is no longer current on the project. Returns `null` while the line is in step. `noLongerInSchedule` wins over a revision mismatch; an unreachable rates service returns `null` rather than a false alarm.

`DayRateLineNotes` and `DayRateSourceSlot` (`src/DayRateLineNotes.tsx`) render the two slots beside a day-rate line's description in the build-up. `DayRateSourceSlot` renders the 168 px source column (the slot a rate selector or "Manual rate" takes on non-day-rate lines). `DayRateLineNotes` renders the **Edited** badge and **Revert** button when a rate override is present, and the out-of-step badge when the revision has moved on. Both use the pure helpers from `dayRateLines.ts`.

## Overlay and side-sheet behaviour

The estimate remote uses `SideSheet` for forms that benefit from keeping the underlying estimate visible and for flows that need more horizontal space.

**Work-item forms** (`src/workItemSheetConversions.test.tsx`) — six create/edit screens use `SideSheet`: `SectionEditorModal`, `EstimateSettingsModal`, `PricingComponentEditorModal`, `WorkItemTagsModal`, `ActivityInstanceEditorModal`, and `WorkItemEditorModal`. `SimilarWorkItemsModal` stays on a centred 960 px modal.

**Cross-workspace forms** — four overlays across Documents, Overview, Takeoff, and Tender use `SideSheet`:

| Screen | Component | Notes |
| - | - | - |
| Documents — Add to estimate | `RouteDialog` in `src/ProjectDocumentsPage.tsx` | Was a centred `Dialog`; now a `SideSheet`. |
| Overview — Estimate versions | `EstimateVersionsModal` in `src/EstimateVersionsModal.tsx` | Was a centred `Dialog`; now a `SideSheet`. |
| Takeoff — Create work item | In `src/takeoffSubComponents.tsx` | Was a centred `Dialog`; now a `SideSheet`. |
| Tender — TAG/departure editor | In `src/TenderItemsPanel.tsx` | Was a centred `Dialog`; now a `SideSheet`. |

### Tender clause included/excluded toggle (`TenderItemsPanel`)

The included/excluded checkbox on each tender clause is optimistic. `toggleIncluded` in `src/TenderItemsPanel.tsx` works as follows:

1. A unique `Symbol` is created for the request and stored in `latestToggleByClause` (a `useRef<Map<string, symbol>>`).
2. The clause list is updated immediately with an inverted `included` value (`optimisticClause`).
3. `upsertProjectTenderClause` is called. When it resolves, the server's returned clause replaces the optimistic value — but only when `latestToggleByClause` still records this request's symbol. A stale response from an earlier toggle is silently discarded.
4. On failure, the same staleness check applies: if this is still the latest request, the optimistic update is rolled back to the original clause value and an error toast is shown.

This means rapid successive toggles on the same row never produce a race where an older, slower response overwrites the newer one. The pattern is modelled after the build-up component removal undo in `useRemoveComponentUndo`; keep both in sync when changing the error-toast vocabulary.

Two related flows use a page or popover instead of a sheet:

* **Document suggestion review** — a page rendered at `/documents/review[/:documentId]`. The document list is replaced by the review page while reviewing is active. There is no close button on the header — the exit is the **Close** button rendered by `SuggestionsReview`. Browser back leaves without passing the dirty-close guard; a `useBlocker` guard remains a follow-up.
* **Document folder editor** — a `PopoverRoot` inline on the document card (`RelatedFolderPopover` / `RelatedFolderForm` in `src/ProjectDocumentsPage.tsx`). It mounts only while the popover is open so the input resets on reopening.

The `SideSheet` is built on the same Chakra dialog anatomy as `Dialog`, so `getByRole("dialog")` matches both. The discriminator in tests is `data-placement`, which only `SideSheet` emits. `src/workItemSheetConversions.test.tsx` pins this for Slice A screens; Slice C screens rely on their existing form-level suites plus the dirty-close guard.

## D\&C schedule filter (`dcScheduleFilter.tsx`)

`src/dcScheduleFilter.tsx` provides the URL-based filter that narrows the Work Items schedule to the estimate items linked to a specific Departure & Clarification clause.

### URL parameter

The filter uses the query parameter `?dc=<clauseId>`. The D\&C panel's linked-item count button navigates to the Work Items tab with this parameter set (a push, so Back returns to the D\&C list). The parameter is resolved by the Work Items panel itself rather than the navigation action, so a copied URL, a page reload, and browser back all preserve or drop it correctly.

`dcFilterIdFromSearch(search)` reads the param from a `location.search` string and returns the clause ID, or `null` when the param is absent or blank.

`withDcFilter(href, clauseId)` rewrites an absolute URL (the current address bar value) with the param set or removed, returning a path+query+hash string suitable for a router navigation. It edits the address bar directly rather than the router's stale location, so a simultaneously open detail pane (`?wi=`) is preserved. **Clear filter** calls this with `null` and replaces the history entry (not a push) so Back goes to the page before the filter, not into it.

### Filter resolution (`useDcScheduleFilter`)

`useDcScheduleFilter` resolves a `clauseId` into a `WorkItemsFilter` shape:

* **Loading** — until the tender clause list and per-item tag reads have settled, `ids` is empty so the full schedule never flashes before narrowing.
* **Ready** — `ids` is the set of work-item IDs linked to the clause. `groupLabel` names the clause's group. `incomplete` is true when one or more per-item tag reads failed, surfaced as a note in the banner.
* **Missing** — the clause ID was not found in the estimate (deleted clause or link from a different estimate). The schedule falls back to showing all rows; the banner explains why.
* **Failed** — the clause list could not be read at all. Same fallback as missing.

Per-item tag reads (`useLinkedWorkItemsByClause`) are keyed on each item's `tagCount` and `updatedAt`, so the map refreshes when a link is added or removed but stays stable across unrelated item edits.

### Row selection

`idFilterRowIds(filter)` translates a `WorkItemsFilter` to the `Set<string>` of IDs the schedule should keep, or `null` (show all rows) when the filter is absent, missing, or failed.

`withAncestorWorkItems(matched, items)` expands a matched set to include the parent work item of every matched sub-item, so a linked sub-item never appears without its parent row.

`ancestorSectionIds(rows, parentById)` collects the ancestor section IDs for a set of rows so that nested matches keep their section headings visible.

### Banner (`WorkItemsFilterBanner`)

Renders above the work items list when a `WorkItemsFilter` is active. States:

* **Loading** — "Finding the estimate items linked to this D\&C…"
* **Missing** — "That D\&C couldn't be found — it may have been deleted. Showing all N estimate items."
* **Failed** — "The D\&C filter couldn't be loaded. Showing all N estimate items."
* **Ready** — D\&C filter badge, clause short name, group badge, "Showing M of N estimate items linked to this D\&C. Totals are for the whole estimate." An optional note appears when `incomplete` is true.

A **Clear filter** button appears in all states when `onClear` is provided. It calls `withDcFilter` on the current address bar URL with `null` and performs a replace navigation.

### Accessibility

`useFilterAnnouncement` mounts a `role="status"` polite live region alongside the panel. It announces each settled filter state once. The region is empty on mount so it does not fire on initial render; the same sentence repeated twice in a row is still announced as a second event by clearing then setting the message with a 50 ms delay.

### Sheets beside the open assistant (`@stralos-co-nz/ui` 0.73.1)

From `@stralos-co-nz/ui` 0.73.1, `SideSheet` opens non-modally when the assistant panel is already open. The estimator can interact with both simultaneously: the sheet anchors to the same edge it would normally use, the assistant panel stays visible, and neither overlay blocks the other. The sheet chooses modality when it opens: below `md` it opens full-screen and modal. Later resizing or assistant visibility changes do not alter that choice until the sheet is reopened.

## Build-up component removal (`useRemoveComponentUndo`)

`useRemoveComponentUndo` (`src/useRemoveComponentUndo.tsx`) handles the **Delete component** action on a work item's build-up. It uses `useUndoAction` (ui 0.60.0) instead of a confirm dialog: the row disappears immediately and a toast with an **Undo** button gives the estimator a short window to reverse it. The `DELETE` runs only after the window elapses or a second removal commits the first one early — never on Undo.

**No spurious PATCH before DELETE:** deleting a build-up line no longer sends a `PATCH …/pricing remove` for the line's hidden override entry before the component `DELETE`. The previous sequence caused a `422` on a frozen selection (the server refused to remove an override on a frozen row), which surfaced as a false "Couldn't save pricing changes" error even though the component was about to be deleted. The component `DELETE` is the authority on override cleanup. Since PRD 211 F the pricing PATCH carries `pricingSource` only (see [Work-item pricing PATCH](#work-item-pricing-patch-pricingsource-only)), so a removal sends no pricing PATCH at all.

The hook is lifted out of `WorkItemsPricingPanel` so its three promises can be held in tests without mounting the full pricing surface. `WorkItemsPanel` passes `onRemoveComponentRequested` down to the pricing pane, which calls `request(component, commit)` on the hook.

**Write safety guarantees:**

* Navigating away while a removal is pending (to another estimate section or project) unmounts the hook. `useUndoAction` commits in that same cleanup — immediately, not when a timer that outlived the screen fires.
* Closing or reloading the tab: a `beforeunload` listener asks the browser to confirm. A `pagehide` listener commits what it can.
* A server refusal un-hides the row and shows the error reason in a toast (`"<component name> was not removed"`). The row reappears the moment the commit starts, so a failure restores it with the reason rather than leaving it absent with no explanation.

**Optimistic totals:** while a removal is pending, the hook hides the row from `displayItems` and adjusts the host item's `totalCost` and `totalPrice` proportionally. The build-up header and row totals stay consistent on screen. The server's figures replace both after the `DELETE` and its following refetch.

## API layer

### Runtime URL resolution

The estimate remote resolves its API base URLs through this chain (highest to lowest), applied separately for each module (`global`, `estimate`, `rates`, `activities`):

1. `VITE_STRALOS_*_API_URL` — Vite environment variable (e.g. `VITE_STRALOS_ESTIMATE_API_URL`).
2. `window.__STRALOS_API_URLS__.<module>` — runtime seam the shell can inject.
3. `localStorage.getItem("stralos.<module>ApiUrl")` — per-module localStorage override for local development.
4. `sameOriginFallback(<localhost default>)` — in a production build returns the current `window.location.origin`; under Vite dev returns the localhost default.

Default localhost ports: Global `3300`, Rates `3301`, Activities `3302`, Estimate `3303`.

### Authentication

`authedFetch` in `src/api.client.ts` handles all authenticated requests. On a `401`, it attempts a single token refresh through `window.__STRALOS_AUTH__.refreshAccessToken` (the shell's refresh bridge). If the refresh also returns `401`, the session is unrecoverable.

Before redirecting to `/login?reason=expired&from=<encoded-path>`, `endDeadSession()` clears the three session keys (`stralos.auth.accessToken`, `stralos.auth.refreshToken`, `stralos.auth.user`) from `localStorage`. It prefers the host bridge's `window.__STRALOS_AUTH__.clearSession()` when available, and falls back to removing the keys directly.

Clearing the session before the redirect is required. The shell's `/login` route treats a session as live if any of those keys are present (`isAuthenticated()` checks `accessToken !== null && user !== null`), so a redirect that leaves the keys in place sends the user straight back to the page that failed — with the same dead token. The same pattern applies in `src/documentsApi.ts` for documents requests.

The first-`401` path (where the refresh itself fails) is left to the host's `performRefresh`, which clears the session before resolving `null`. `endDeadSession` does not run on that path to avoid duplicating the responsibility.

When the shell bridge is absent (standalone development without the shell), the client falls back to re-reading the token from `localStorage`.

### Workspace notification after project requests (`projectRequestEvents.ts`)

`authedFetch` passes authenticated API responses to `observeProjectRequest` (`src/projectRequestEvents.ts`). The observer fires a `stralos:project-request` custom browser event for a successful GET or HEAD, a successful non-OPTIONS mutation, or a `403`/`404` response so the workspace shell can revalidate its own project data. Other outcomes do not emit an event.

The event detail carries `{ version: 1, path, kind }` where `kind` is one of:

| `kind` | Condition |
| - | - |
| `refused` | Response status is `403` or `404` — the project may have been removed or access revoked. |
| `edited` | Response is successful (`ok`) and the method is not `GET`, `HEAD`, or `OPTIONS` — project data was written. |
| `read` | Response is successful and the method is `GET` or `HEAD` — a successful direct read. |

Successful reads notify the workspace because an RLS-filtered list can return `200` with no rows after a project is removed or access is revoked. The event carries no response payload, bearer token, or project data.

The event is only dispatched when the browser pathname at the time the response arrives matches the pathname captured when the request was issued (`path && window.location.pathname === path`). A response that arrives after the user has navigated away does not trigger revalidation for the new page.

**Suppressed duplicate project read:** the estimate remote previously issued its own project `GET` to check access whenever `projectRequestEvents` resolved. This was redundant — the workspace shell already owns the authoritative project read. The estimate's `projectRequestEvents` module now mirrors the workspace copy: it no longer performs an additional project `GET`. A refused response (`403`/`404`) is still reported as `kind: "refused"` so the workspace can revalidate; only the successful `read` event that was being emitted for the estimate's own check is suppressed.

### Busy-read retry (`busyRetry.ts`)

`fetchWithBusyRetry` (`src/busyRetry.ts`) wraps the estimate remote's fetch calls. When a GET or HEAD request returns HTTP 503, the module waits for the `Retry-After` header's stated number of seconds (up to `MAX_BUSY_RETRY_WAIT_MS` = 10 s, defaulting to `DEFAULT_BUSY_RETRY_WAIT_MS` = 1 s when the header is absent), then issues one silent second attempt. The result of the second attempt is returned regardless of its status; no further retry is made.

Safety invariants:

* **Reads only.** A 503 does not confirm the request was not applied. POST, PUT, PATCH, and DELETE responses are returned immediately without retry.
* **Single retry.** One quiet retry per request — never a loop.
* **Abort-aware.** If the request's `AbortSignal` fires before the wait or the second attempt, the first response is returned immediately.

Both API clients (`api.client.ts` and `documentsApi.ts`) wrap their fetch with `fetchWithBusyRetry`.

### Pricing panel scroll behaviour (`WorkItemsPricingPanel`)

`WorkItemsPricingPanel` manages two view modes: `list` (the pricing grid) and `split` (the grid replaced by the detail pane). Scroll position is handled differently for each transition:

* **Opening the detail** — when the estimator selects a work item from the list, `listScrollRef` captures the current list scroll position via `readWorkItemListScroll()`, `scrollOpenedDetailToTopRef` is set to `true`, and the view switches to `split`. A `useLayoutEffect` that watches `viewState.mode` fires on the next render, detects the flag, and calls `window.scrollTo({ top: 0, behavior: "auto" })`. This ensures the detail pane header is always visible at the top of the screen rather than inheriting the list row's scroll position.
* **Returning to the list** — the three paths that set `viewState` back to `list` (URL change, back navigation via `popstate`/`history.pushState`, and the close button) each call `window.requestAnimationFrame(() => restoreWorkItemListScroll(listScrollRef.current))`. This restores the row the estimator was looking at before opening the item.

The `scrollOpenedDetailToTopRef` flag is reset immediately after the scroll so a subsequent tab switch or re-render within split mode does not retrigger the scroll-to-top.

### Project readiness (`src/projectReadiness.ts`)

Some pages in the estimate remote require the project's estimate record to exist before they can load. A newly created project may not have that record yet — the provisioning service creates it asynchronously. When a page opens before provisioning is complete, the API answers `GET /projects/:id/estimate` with `409 project_not_provisioned` (with a `Retry-After: 5` header).

`src/projectReadiness.ts` is the estimate remote's half of the readiness system. `useProjectReadiness` and `ProjectPreparing` from `@stralos-co-nz/ui` 0.68.0 are the other half. The project error codes and `classifyProjectReadinessError` come from `@stralos-co-nz/domain` (3.1.0 or later); pass the classifier to `useProjectReadiness` as `classify`, which is required from ui 0.73.0.

**How readiness answers are classified (`isReadinessAnswer`):**

A failure is treated as a readiness answer — meaning the page should wait and retry — when the error is any of:

* A network failure (no status)
* Any 5xx response
* `403 project_access_required`, `404 project_not_found`, `408`, `429`
* `409 project_not_provisioned` specifically — a `409` with any other code (for example `estimate_frozen`) is not a readiness answer

Everything else — including `402 billing_read_only` — is a refusal that retrying will not change. A refused error resolves as `{ kind: "refused" }` so the hook does not retry it. The page displays it the same way it did before the readiness system existed.

**Project and estimate read (`readProjectAndEstimate`):**

Pages read both the project (api-global) and its estimate (api-estimate) together using `Promise.allSettled`. When both fail, the project's failure takes precedence because api-global is the authority on whether the project exists. A fail-fast `Promise.all` could report whichever request lost the race — a deleted project could appear as "preparing" (the estimate's 409) or a refused access as "unavailable".

**Pages and surfaces covered:**

| Surface | Variant | Behaviour |
| - | - | - |
| `EstimatePage` (all estimate tabs, calculators, variations, contractor-return review, workbench) | Page — full-page | Replaces the generic error card for 409/404/403/5xx; refusals keep their existing card. Keyed on `reloadNonce` so **Retry** and repricing reloads use the same path. |
| `WorkItemsExportPage` | Page — full-page | Same as `EstimatePage`. |
| `ReapplyRatePreferencesPage` | Page — full-page | Same as `EstimatePage`. |
| `ProjectDocumentsPage` | Inline | The page stays mounted and routing is disabled until the estimate exists. The `402` billing notice is unchanged. |
| `ProjectRateDetailPage` — "Where it's used" section | Inline | The section waited and previously swallowed every failure, claiming nothing used the rate. It now shows the correct state — preparing, not-found, no-access, or unavailable. |

**Inline variant for `ProjectDocumentsPage` (`quietWhileLoading`):**

The first readiness check on the Documents page is not worth a banner that appears and then immediately vanishes on every visit. `quietWhileLoading` maps the `loading` state to `ready` so `ProjectPreparing` draws nothing during the initial check. The live region stays mounted throughout, so a subsequent state change is announced without a remount.

**Retry key (`readinessKey`):**

A page that must re-read the same project (a **Retry** out of a refusal, a reload after a write) keys the `useProjectReadiness` hook with `readinessKey(projectId, attempt)`, which returns `"<projectId>#<attempt>"`. Incrementing `attempt` causes the hook to restart its read cycle.

### In-place error display

Background reads that fail display their error inline beside the content they were loading, never as a toast. The design principle is: a background read the estimator did not initiate must not displace working content with a global toast notification. Affected surfaces:

* **Pricing panel rate data** (`WorkItemsPricingPanel`) — a warning alert renders inline at the top of the panel when the background supplier-options load fails. Previously saved pricing remains visible. A **Retry** button lets the estimator request another load attempt.
* **Rate exceptions panel** (`RateExceptionsPanel`) — when the exception rows fail to load, the panel shows the error message inline with a **Retry exceptions** button. Any previously loaded rows are kept visible; the error state only replaces them when `rows` is still `null` at the time of failure.

### Error messages (`ApiError` and `DocumentsApiError`)

`ApiError.message` is user-facing text. It is the server's own sentence when the response body carries a `message` field, or a plain sentence per HTTP status class when it does not. It never contains a status code, a URL, or a raw diagnostic composite.

`userFacingApiErrorMessage(status, body)` in `src/api.client.ts` produces the value assigned to `message`. It returns `serverErrorMessage(body)` when a server message is present, and falls back to `apiStatusSentence(status)` otherwise.

`serverErrorMessage` combines the body's `message` and first `issues[0].message` into a single sentence. It trims both values and appends the issue text only when it differs from the top-level message. Field paths from `issues[0].path` are not included in `message`; they appear only in `diagnostic`.

`ApiError.diagnostic` holds the old composite format (`"API request failed with status N for <url> - ..."`). Use it for logs and developer tooling. `ApiError.serverMessage` holds the raw server sentence before any issue appending, or `undefined` when the body carries no message.

`DocumentsApiError` follows the same contract. `StorageUploadError.message` is always `"The file could not be uploaded to storage. Try again."` — S3 XML is kept only in `StorageUploadError.responseText` (truncated to 300 characters) and is never shown in the UI.

The variation-submit fallback and S3 upload paths no longer include a status code in their visible error text.

`isConversionFailedError` reads `serverMessage` (the typed field) rather than `message` text, so its detection is not affected by the message format change.

`apiStatusSentence` maps HTTP status codes to plain sentences:

| Status | Sentence |
| - | - |
| `400`, `422` | `"Some of the details could not be accepted. Check them and try again."` |
| `401` | `"Your session has ended. Sign in again to continue."` |
| `403` | `"You don't have permission to do that."` |
| `404` | `"That item could not be found. It may have been removed."` |
| `409` | `"This was changed at the same time by someone else. Refresh and try again."` |
| `410` | `"This is no longer available."` |
| `413` | `"That is too large to send."` |
| `429` | `"Too many requests at once. Wait a moment and try again."` |
| `500` | `"Something went wrong on our side. Try again in a moment."` |
| `502`, `503`, `504` | `"The service is not responding right now. Try again in a moment."` |
| Other | `"The request could not be completed. Try again in a moment."` |

Any surface that needs to show an error in the UI should read `error.message`. Any surface that logs or reports to a developer should use `error.diagnostic`.

#### `friendlyErrorMessage`

`friendlyErrorMessage(error, fallback, verb?)` in `src/api.client.ts` is the single translation point for surfacing any caught error to an estimator. It accepts a `verb` of `"load"` (default), `"save"`, or `"do"` and returns:

* **Server fault** (status ≥ 500, or a `service_unavailable` / `internal_error` code on any status): fixed wording — `"We couldn't load this right now. Try again in a moment."` — with a support reference appended when the response body carries a `reference` field. The verb determines `"load this"`, `"save this"`, or `"do that"`. The server's own message is suppressed entirely for these faults.
* **Client refusal** (4xx without a fault code): the server's own sentence, which is written for the person. `error.message` is returned directly.
* **Non-HTTP error** (a dropped connection, a `TypeError`, or anything without a numeric `status`): the caller's `fallback`.

Both `ApiError` and `DocumentsApiError` satisfy the structural duck-type check (`instanceof Error` with a numeric `status`), so `friendlyErrorMessage` works for both clients without an explicit import.

Use `friendlyErrorMessage` in every catch block that renders to the UI. Pass `"save"` where the user asked to write data and `"do"` for actions that are neither a load nor a save (for example, submitting tender).

## Documents shared utilities (`documentsShared.tsx`)

`src/documentsShared.tsx` contains shared helpers used across `ProjectDocumentsPage` and related document surfaces.

### File type labels (`fileTypeLabel`)

`fileTypeLabel(fileType, fileName)` converts a stored MIME type to a human-readable description. Document cards call this to display the file type. It never shows a raw MIME string.

Resolution order:

1. Known MIME type mapped to a label (for example, `"application/pdf"` → `"PDF"`, `"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"` → `"Excel spreadsheet"`).
2. File extension, uppercased and suffixed with `"file"` (for example, `"dwg"` → `"DWG file"`).
3. `"File"` — when no extension is present or the extension is longer than five characters.

A null or undefined `fileType` is treated the same as an unrecognised type and falls through to the extension path.

## Panel scrolling-region names (`SectionPanelCard` — D10)

`SectionPanelCard` in `src/estimateShared.tsx` wraps `SectionCard` from `@stralos-co-nz/ui` and forwards a `scrollLabel` prop to name the panel's scrolling region for assistive technology. Without a label, every panel's region was announced as just "List".

The wrapper defaults `scrollLabel` to the panel's own `title` when `title` is a plain string and no explicit `scrollLabel` is passed. Panels with a non-string `title` (for example, a title that includes a `Badge`) should pass `scrollLabel` explicitly as a plain string.

Pass `scrollLabel` explicitly only when the default `title`-based name would be ambiguous or incorrect. The `scroll` prop (`"own"` | `"page"`) controls whether the panel owns its scroll region; only panels with `scroll="own"` (or without `scroll`) produce a named region — `scroll="page"` panels share their page's scrolling element.

## Live-region announcement pattern (`AlertRegion`)

`AlertRegion` (`src/estimateShared.tsx`) is an always-mounted assertive `role="alert"` region. It renders its children when there is content to show and collapses to `position: absolute` when empty, keeping the node in the accessibility tree at all times.

The rule is: render the region unconditionally and put the condition on its children. A live region inserted into the DOM together with its content is not reliably announced by screen readers; a region that was already present and then receives content is.

`ModalError` is built on `AlertRegion`. Any surface that needs a refusal or error announced — not only `ModalError` — should use `AlertRegion` directly when the message has more than one element to display.

Do not nest one `AlertRegion` inside another; live regions are not composable in ARIA.

### Surfaces that use `AlertRegion` directly

| Surface | `testId` | Content |
| - | - | - |
| Suggestion card (`src/suggestionCard.tsx`) | `suggestion-conflict` | Quote conflict answer returned after accepting. Includes an action button when a conflict resolution is available. |
| Suggestion card (`src/suggestionCard.tsx`) | `suggestion-attach-problem` | Attachment failures returned after accepting. Includes **Retry** and **Accept without attaching** buttons. |
| Calculations tab (`src/WorkItemCalculations.tsx`) | `remove-reading-refusal` | Server refusal when removing a reading from the tab. Uses `status.error.fg` (4.64:1 on `status.error.bg`) rather than `fg.error` (3.36:1), which fails WCAG AA on that background. |
| Calculations tab (`src/WorkItemCalculations.tsx`) | `add-measurement-refusal` | Server refusal when adding a measurement. Same colour rule as the removal refusal above. |

`src/routes.ts` carries four specifier-less imports (`import "react"`, `import "react-dom"`, `import "react-router-dom"`, `import "react/jsx-runtime"`), one per `import: false` entry in `.ci/shell-federation-contract.json`.

These imports prevent a race condition in `@module-federation/vite` 1.21.1's export analysis. On a cold file cache, the plugin's 10 ms settle timer can fire before all shared-package imports have been discovered, producing a virtual module with an incomplete export surface. Rolldown then fails with `MISSING_EXPORT`. A specifier-less import marks the share unsafe to narrow, so the plugin retains its full export surface regardless of what the scan managed to see.

The cost is approximately 1 KB gzip per affected remote — within the D7 bundle growth budget. `src/shareGuard.test.ts` fails if these imports ever drift from the federation contract file.

## Fill materials (`src/fillMaterials.ts`)

The estimate host loads the rates library's Bulk Materials list and maps it onto the fill-material picker that appears in the reinstatement-layer modals (D73/D73b).

### `BulkFillMaterial` model

`BulkFillMaterial` is the host's own type. It carries both ends of the density range returned by api-rates:

| Field | Type | Notes |
| - | - | - |
| `id` | `EntityId` | |
| `name` | `string` | |
| `densityTPerM3Min` | `number` | Loose t/m³ — the low end of the range quoted across suppliers. Equal to `densityTPerM3Max` when only one supplier prices the item. |
| `densityTPerM3Max` | `number` | Loose t/m³ — the high end of the range. Collapses to `densityTPerM3Min` when the server returns no max or a malformed value. |
| `compactionPct` | `number \| null` | Whole percent of the loose volume, or `null` when the material does not compact. `null` and `0` are distinct: `null` means no compaction, `0` is a deliberate zero. |
| `sortOrder` | `number` | Position in the library's own sweep order (category → name → id). |
| `status` | `"active" \| "inactive"` | Items with a `deleted` or unknown status are mapped to `"inactive"`. |

This type exists to keep both ends of the range alive beyond `toUiFillMaterials`. The ui contract (`FillMaterialOption.density`) is a single `number`, so `toUiFillMaterials` narrows the range to `densityTPerM3Min` before passing materials to the modal. The narrowing is lossless for items priced by one supplier; where the range is real, the work-item rate option on the line is what D73b designates as the density decision.

### Omitted materials

`fillMaterialsFrom` omits a material only when api-rates can find no readable density for it. A material priced by multiple suppliers at different densities arrives with a minimum and maximum density and remains selectable.

A page of rows in which not one carries `densityTPerM3Min` is treated as a load error rather than an honest empty list: that shape means the api-rates version predates the field, not that the organisation has no bulk materials.

### Calculator assignment output seed (`src/calculatorAssignments.ts`)

`seedOutputFor` chooses the default `outputKey` for a freshly linked calculator layer, matching the API's `seedLinksForDefault` exactly (D73b):

* A **bought fill layer** (layer kind `"reinstatement"`) that states `looseM3` starts on `looseM3` — the cubic metres actually delivered. Starting on `inSituM3` would under-order by the full compaction factor.
* A **non-bulk reinstatement layer** states only `count`, so `seedOutputFor` always resolves to `count` for that layer.
* Every other layer (including excavation strata) starts on the first output the engine states, which is `inSituM3` for an excavation stratum.

This rule is enforced on both manually added rows (`newRow`) and server-seeded defaults so the estimator sees the same starting output regardless of how the layer got linked.

## Layer assignment control

`LayerAssignmentControl` (`src/LayerAssignmentControl.tsx`) implements the **Used by** column that each calculator editor renders per layer. The calculator UI (in `@stralos-co-nz/calculator-ui`) owns no fetch and knows no destination type — it takes a `renderLayerAssignment(layer)` slot and renders whatever node comes back inside that layer's row. `LayerAssignmentControl` is the host half of that contract.

### Assignability states

`layerAssignability` (from `src/layerAssignment.ts`) decides what the control renders for a given layer. The order is load-bearing:

| State | Condition | Control renders |
| - | - | - |
| `unsaved` | Layer is not in the saved config (`layerPresence` returns `absent`) | Work item picker — choice is held in host state and applied on save |
| `no-outputs` | Layer is saved but the engine emits no row (zero-depth, or a bedding layer its pipe fully displaces) | Reason: "produces no quantity" |
| `assignable` | Layer is saved and has a catalogue entry | Work item picker — choice is held in host state and applied on save |

A picker is never shown for the `no-outputs` state. Rendering a picker an estimator can open and choose from, then be refused, is the defect this control exists to prevent.

### Pending assignments (unsaved layers)

When an estimator chooses a work item for an unsaved layer, the choice is held in `useLayerAssignments` host state as a `PendingAssignment`. It is not written until the calculator is saved, because `PUT /calculators/:id/links` returns `422 calculator_link_unknown_layer` for a layer the saved config does not yet hold.

`flushPendingAssignments` runs after the config write and before the modal closes. It calls `partitionPending` to sort each pending choice into one of three buckets, then writes only the `applicable` set.

`partitionPending` classifies each held choice against the post-save catalogue:

* **`applicable`** — the layer exists and the choice can be written. This includes pending clears (destination `null`): a clear is applicable whenever the layer is still present, regardless of whether it currently emits any quantity. Applying a clear sends the layer with an empty row set, which marks it explicitly unassigned rather than re-seeding it from the calculator default.
* **`noQuantity`** — the layer is saved but the engine emits no row (zero depth, or a bedding layer its pipe fully displaces). The choice is not applied and the estimator is told "that layer produces no quantity, so it was not assigned." This message is correct for a pending assignment; for a pending clear the applicable path above applies instead, because the link the estimator asked to remove is still there.
* **`missing`** — the layer was deleted before the save. The clear is dropped silently; no message is shown on a neighbour's row. The layer's links are gone with it, so "this feeds nothing" is already true.

`applyPendingLinks` assembles the full link register with every applicable held choice merged in. Before it builds the body it checks each pending layer for a split: if the layer was partitioned between the choice being made and the save, the flush refuses with `SPLIT_LAYER_REFUSAL`. Without this check, a flush on a partitioned layer would silently collapse every row to a single destination (or drop them all for a held clear), destroying a partition the estimator built with no undo.

The assignment save is a register write, not a calculator save. It does not close the workbench and does not touch the unsaved draft.

### `layerPresence`

`layerPresence` is the single shared decision for whether a layer is in the saved config. Both `layerAssignability` (at the cell) and `partitionPending` (at save, via `flushPendingAssignments`) call it. The two callers read the result differently: `absent` before a save means "just added"; after a save it means "deleted". The function reports presence only — each caller names what that means in context.

## Takeoff panel

`TakeoffPanel` (`src/TakeoffPanel.tsx`) is the estimate workspace's Takeoff builder section. It hosts uploaded drawings and the measurement list.

**Takeoff usage read (`useTakeoffUsage`):** `TakeoffPanel` owns one `useTakeoffUsage` call and passes the result to `MapTakeoffSurface`. The `enabled` parameter holds the first read until both the measurement list and the work-item list have loaded (detected by a stable `refreshKey` derived from both arrays). Without the gate, the hook fired once on mount and again as each list landed — three identical `GET takeoff-links/usage` calls per panel load. Map sheet measurements are memoised on the sheet id so navigating between pages does not re-derive them from scratch.

### Map layers (`src/councilUtilities.tsx`, `src/councilUtilitiesApi.ts`, `src/linzPropertyBoundaries.tsx`)

`CouncilUtilities` is mounted on the map sheet surface (`src/takeoffMapSurface.tsx`) and owns the map's **Layers** menu. The menu always offers **LINZ property boundaries** (`LinzPropertyBoundaries`), which is independent of any feature flag. The **Utilities** section, and every utility request, is gated by the PostHog boolean flag `estimate-council-utilities` (`useFeatureFlag`, falling back to enabled only on a localhost hostname, where the local stack runs without PostHog). The flag is a UI rollout control, not authorisation: the API routes apply normal authentication and organisation checks only (see [Map takeoff](https://help.stralos.app/internal/estimate-service.md#map-takeoff-migrations-0073-and-0074)).

The menu is injected into the map toolbar via a React `Portal` targeting `utilitiesToolbarRef` (a stable `<span>` host). `takeoffMapSurface.tsx` places the host span (`data-testid="utilities-toolbar-slot"`) in both the normal toolbar and the fullscreen header, and calls `attachUtilitiesToolbar` to move the same host DOM node between slots without remounting the menu controller.

* **Availability:** with the flag on, each map `idle` (debounced 400 ms) calls `GET /api/estimate/takeoff-map/layers` with the viewport, clipped to New Zealand by `utilityCoverageBounds`. The response names the councils in view and the providers and layers registered for them. **Refresh layer availability** in the menu asks again.
* **Network and layer choice:** each network (Stormwater, Wastewater, Drinking water) is a checkbox item. Expanding a network offers pipes and structures, and each provider layer, as exclusions. Choices persist while the viewport moves; a view with no matching layers says so and keeps them.
* **Loading:** features load from `GET /api/estimate/takeoff-utilities/features` per selected provider layer, only when `utilityViewAllowed` passes (NZ envelope, at most 0.025° longitude by 0.02° latitude). A layer that fails is reported as incomplete coverage while successful layers stay visible. Loads abort when superseded or unmounted.
* **Detail panel:** in the normal layout, an absolutely positioned overlay in the top-right of the map, visible while at least one network is on. Its collapsible **Utility details** body shows the selected feature's provider, district, material, plan quantity, fetch and provider-loaded dates, source attributes, **Save as measurement**, **Refresh utilities**, and **Coverage and sources** (each provider's coverage note and source link). In fullscreen the same body opens from a **Utility details** popover button in the header, and active providers' attribution moves to a compact panel at the bottom left. Each active provider's attribution and licence link stay visible while networks are on.
* **Selection:** a map click hit-tests the loaded features only while the Select tool is active.
* **Saving:** **Save as measurement** calls `createGeoTakeoffMeasurement` with a `line` (unit `metre`) or `count` (unit `each`), category `Other`, the label from `utilityLabel`, a quantity from `geodesicQuantity` over the whole returned part (no clipping to the view), and a `sourceSnapshot` from `utilitySnapshot`. The server stores its own geodesic quantity. The duplicate check (same provider, layer, object, and part already on the sheet) is UI-only, not a database uniqueness guarantee. Saving is disabled when the estimate is frozen.
* **Snapping:** loaded utility vertices and lines, and property boundary lines, are passed to the map surface as snap candidates.
* **Provenance:** `SavedUtilitySource` renders a saved measurement's `sourceSnapshot` (provider, object, fetch and provider-loaded dates, attributes, attribution) in the measurement card. It is not flag-gated, so saved provenance stays readable if the flag is turned off. Snapshots saved before the provider catalogue still read as **Canterbury source**.

| Network | Colour |
| - | - |
| Stormwater | `MEASUREMENT_COLOUR_PALETTE[3]` |
| Wastewater | `MEASUREMENT_COLOUR_PALETTE[2]` |
| Drinking water | `MEASUREMENT_COLOUR_PALETTE[0]` |

`LinzPropertyBoundaries` requests `GET /api/estimate/takeoff-map/property-boundaries` on map `idle` for a New Zealand view within the same size limit, and draws non-clickable dotted outlines below takeoff shapes. Route limits and errors are in the [Estimate API reference](https://help.stralos.app/api/estimate.md#property-boundaries). User-facing behaviour and coverage caveats are in the [takeoff guide](https://help.stralos.app/guides/estimators/takeoff.md#map-layers).

### Drawing loupe flag (`src/takeoffLoupeFlag.ts`)

The drawing loupe is gated by the PostHog flag `takeoff-loupe`, read with `useTakeoffLoupeEnabled()` (`fallback: false`), so an unknown or unreachable PostHog shows no loupe. Only the loupe is gated: the snap ring, measuring and zoom are unchanged. With the flag off, `TakeoffPanel.tsx` skips the per-move loupe work entirely.

### Snap attachments

A snapped point carries its attachment inside the measurement's `points` jsonb, with no new column: `{ x, y, attach: { measurementId, vertex } }` for a vertex, or `{ x, y, attach: { measurementId, segment, t } }` for a line, where `segment` is the index of the segment's first point (`segmentPairs`) and `t` is stored to six decimal places. Map points use `lat`/`lng` in place of `x`/`y`. The module is generic over a `PointSpace`, so drawings (`pagePointSpace`) and map sheets share the rules.

* `attachOf` reads an attachment defensively and returns `null` for any malformed value. `withAttach` sets or clears one.
* `attachPosition` resolves where an attachment sits on its host's current shape. `remapAttach` keeps it valid after a host edit inserts or removes points; a midpoint insert splits the segment and the attachment moves to the half it was on.
* `followAttachments` returns every measurement that must move because a host moved, transitively, with a step budget and a re-entry check against cycles. It runs over the whole page or sheet, not only the category on screen. `withDraftFollower` adds the unsaved draft under `DRAFT_FOLLOWER_ID`, so a paused draft follows in place and keeps its attachments for its own save.
* `TakeoffPanel.tsx` and `takeoffMapSurface.tsx` save each follower through the same shape write a manual point edit uses, after the host. A follower that fails sets the follow error (the host's save stands); a cancelled edit is skipped.
* While a point is dragged, the dragged measurement, its followers and a following draft show the quantity their save will send (`takeoffLiveQuantity.ts`, measured with the same `measureShape` / `measureDraft` the save uses). Nothing is written until the drop.
* Snap candidates from saved measurements carry their `attach` payload. Measurements being dragged are skipped as candidates, so a host cannot snap onto its own follower. On a map, utility and property boundary overlays are snap targets that never attach (`takeoffMapGeometry.ts`).

Lead defaults: dragging the attached point itself detaches it; a deleted host leaves its followers in place (an attachment to a measurement not on the page or sheet is never followed); attachments only link measurements on the same page or sheet. Server storage and copy behaviour are in [Estimate service: snap attachments](https://help.stralos.app/internal/estimate-service.md#snap-attachments). User-facing behaviour is in the [takeoff guide](https://help.stralos.app/guides/estimators/takeoff.md#snapped-points-stay-attached).

### Measurement colours (`takeoffMeasurementColour.ts`)

Each saved takeoff measurement gets a stable colour derived from its `id` via FNV-1a hashing. The colour is used for both the list card and the mark on the drawing, so an estimator can visually match one to the other.

The palette (`MEASUREMENT_COLOUR_PALETTE`) contains eight hues chosen to stand out on construction drawing backgrounds (mostly white, grey, and black line art). Red and teal are excluded: red reads as an error, and teal is reserved for draft measurements that have not yet been saved.

**Selection behaviour:** selecting a measurement does not change its colour. A selected shape gains a halo (an `rgba` stroke underneath at lower opacity) and a heavier stroke width. The shape's own stroke remains its palette colour throughout. This preserves the list-to-drawing colour match; a recolour to red on selection had broken it.

`measurementColour(measurementId)` returns the hex colour. `measurementColourRgba(measurementId, alpha)` returns the same colour as an `rgba()` string, used for the filled halo on selected area shapes. Both functions are deterministic — same input, same output across sessions.

## Programme Gantt layout

`ProgrammePanel` (`src/ProgrammePanel.tsx`) renders the estimate Programme tab using SVAR Gantt. Layout constants are defined in `src/programmeGantt.ts` and passed explicitly to the widget so the container height arithmetic never depends on SVAR defaults.

### Layout constants

| Constant | Value | Reason |
| - | - | - |
| `PROGRAMME_ROW_HEIGHT` | `38` px | SVAR 2.7.1 default, restated so `programmeChartHeight` uses a known value. |
| `PROGRAMME_SCALE_ROW_HEIGHT` | `36` px | SVAR 2.7.1 default; the two-row month/day header is `36 × 2 = 72` px. |
| `PROGRAMME_DAY_CELL_WIDTH` | `36` px | Replaced SVAR's 100 px default. 36 px holds a two-digit day number and fits three weeks (\~750 px) beside the grid on a 1280 px viewport. |

`programmeChartHeight(rowCount, scaleRows, maxHeight)` computes the container height as:

```
header  = PROGRAMME_SCALE_ROW_HEIGHT × max(1, scaleRows)
rows    = max(1, rowCount) × PROGRAMME_ROW_HEIGHT
height  = min(header + rows + 16 /* scrollbar */, maxHeight)
```

The 16 px scrollbar allowance prevents the container from being shorter than the chart content. The `maxHeight` ceiling preserves fixed-height behaviour for large programmes.

### Date formatting

`programmeStartCellText` in `src/ProgrammePanel.tsx` formats the resting **Start** and **End** cells through `formatDate` from `@stralos-co-nz/ui`, producing `7 Sep 2026`. The datepicker editor is unchanged. Date-only strings are treated as UTC to prevent a one-day shift west of UTC.

The **Start** and **Finish** summary badges above the chart are also formatted through `formatDate`, so all date surfaces on the panel use a consistent format. The `|| "-"` fallback preserves the empty-programme dash when `formatDate` returns an empty string for a missing or unparseable value.

### Stale durations (EC-02)

A work item's programme duration is stored independently and is not automatically updated when the activity durations on the estimate item change. `ProgrammePanel` computes `staleDurations` in a `useMemo` by comparing each header's `durationDays` against `workItemActivityDurationDays(workItem, hoursPerDay)`. A header is stale when the two values differ.

When `staleDurations` is non-empty, the panel renders an `Alert.Root` warning above the Gantt listing each row with its current programme duration, the derived estimate-item duration, and the value that will be written. The **Update durations** button calls `upsertProgrammeItem` for each stale entry with `durationDays` set to the computed `next` value (rounded up to whole days). Start dates are unchanged. If the update fails, the error is surfaced inline below the list.

The `next` value is the derived activity duration rounded up to the nearest whole day. `workItemActivityDurationDays` drives both the stale check and the update payload so the two cannot diverge.

### Gantt saves and header name (`deriveSavedName`)

A managed programme header mirrors its work item's name server-side. The panel caches the programme list, so after a work item is renamed elsewhere the cached header still carries the old name. Before this fix, every Gantt save — a date drag, a duration change, **Update durations** — sent the cached name back and overwrote the mirrored one.

`deriveSavedName(text, row, storedName)` in `src/ProgrammePanel.tsx` resolves the name to include in a save payload. It returns `undefined` (omit from the request) unless the text in the row was genuinely edited by the user. The function treats three inputs as "not edited":

* No text at all.
* Text equal to the display label the row is shown with (echoing back the rendered value).
* Text equal to the stored name (no change from what the server already holds).

`saveTask` omits `name` from the upsert body when `deriveSavedName` returns `undefined`. `updateStaleDurations` (the **Update durations** action) never sends `name`. `upsertProgrammeItem` leaves `name` out of the body when absent. The API `PUT` takes every field as optional, so no server change was required.

An explicit Gantt rename — the user types a new name in the **Task** cell — still sends the new name. The number prefix that SVAR prepends to the displayed label is stripped before comparison so that prefix echo is not mistaken for an edit.

### Task action intercepts (`add-task` / `delete-task`)

`initGantt` registers two `api.intercept` handlers that gate the SVAR Gantt's built-in row actions before they reach the server:

* **`add-task`** — allowed only when the target row (the row the user triggered the add from) exists in `itemsById` **and** has `type === "work_item"`. Any attempt to add a row at the top level (no target) or under a phase (`sub_item`) returns `false` and is silently blocked by SVAR. This means phases can only be created as direct children of a work-item header.
* **`delete-task`** — allowed only when the row being deleted has `type === "sub_item"`. Work-item headers are managed through the estimate item. Milestone rows carry runtime `type === "milestone"`, which sits outside the shared `ProgrammeItem` union, and are blocked because the intercept permits only `sub_item`.

Both intercepts read from `itemsByIdRef` (a ref) rather than the closure, so they see the current programme state after every `onProgrammeChanged` refetch.

When `delete-task` is allowed, the `api.on("delete-task")` handler opens a `ConfirmActionModal` before issuing the server delete via `deleteProgrammeItem`. SVAR removes the row from its own view optimistically before the event fires; confirming commits the delete to the server, and cancelling triggers `onProgrammeChanged` to restore the row SVAR already hid.

### Select-time reveal guard

The bar-vanish defect that affects `PlanGanttPanel` also applies here. `stepRevealGuard` and `shouldSuppressReveal` in `src/programmeGantt.ts` provide the same single-shot suppression.

## Pricing engine autosave (`src/pricingEngine.tsx`)

`pricingEngine.tsx` contains the estimate remote's pricing hook. It autosaves work-item pricing to `PATCH /work-items/:id/pricing` after every user edit. The autosave is designed so that opening an estimate never writes anything.

### Opening writes nothing

When an estimate loads, the server returns stored pricing rows for each work item. The client re-materialises those rows field by field using the keys it knows. The materialised result may spell values differently from the server's stored row — a `null` the client omits, a key the server owns but the client has never heard of (such as `resolvedAt` on an auto-resolved entry), or a derived scalar — but the underlying data is unchanged.

`hydratedEntriesRef` holds each stored entry exactly as hydration delivered it, by object identity. `hydrationEchoRef` tracks which component IDs are still echoing their stored entry. Before every flush computes a diff, it advances `lastSavedPricingRef` (the baseline) to the client's own spelling of each echoed entry. That way, a re-materialised stored entry always matches the baseline and is never sent to the server. A user action that changes a stored entry calls `dropSnapshot`, which removes the entry from `hydrationEchoRef` permanently. From that point the component is live and its changes are diffed normally.

Two asymmetries that previously caused spurious writes on open were fixed by this approach:

* A `null` value the client omits: the client's `stablePricingPayload` serialiser treats `null` as absent, so a row with a stored `null` and a re-materialised absent key looked different unless the echo baseline was advanced first.
* A server-owned key such as `resolvedAt`: only keys listed in `COMPARED_OVERRIDE_KEYS` are compared. That `Record` is keyed by `keyof WorkItemPricingComponentOverride`, so a field added to the type cannot be omitted from comparison without a compile error.

### Activity price choice (`priceChoiceFor` — D10)

`priceChoiceFor` in `src/pricingEngine.tsx` marks only components whose server-owned `priceChoiceRequired` flag is true. New activity lines use the project's default option at insert time, including the project override, ordered rules and pricing location, project office, and cheapest eligible option. Several available options do not themselves require a choice.

A persisted choice flag remains authoritative for legacy unresolved rows and lines the server could not price when inserted. Client re-resolution cannot clear it, even if a rule now matches: the server refuses an `auto` selection with `422 price_choice_required`. Explicit selection or a server-confirmed resolution clears it. `priceChoiceOptionCount` and `priceChoiceSuggestedOptionId` supply the prompt's count and suggestion.

When the selector is closed on an unresolved line, it shows no price rather than an indicative or stale amount.

`collectPriceChoices` (D10 companion) iterates every component the engine would resolve and builds a `Map<componentId, PriceChoiceMark>` for the work item render. The UI attaches a `PriceChoicePrompt` badge to the line, and shows a one-click **Use \<option>** button when the server's suggestion or a single project-location option is unambiguous.

### Price choice attention (`priceChoiceAttention.tsx` — D10)

`src/priceChoiceAttention.tsx` provides the shared helpers for collecting and counting components with `priceChoiceRequired === true` for the notice, totals warning, and Submit tender block. The pricing resolver and zero-cost review logic also read the flag for their own guards.

**Exports:**

* `priceChoiceLines(workItems)` — returns every component with `priceChoiceRequired === true`, in schedule order, as `PriceChoiceLine` objects carrying `workItemId`, `componentId`, and a human-readable `label` (`"1.2 Trench: Civil labour"`).
* `priceChoiceNotice(count)` — the sentence every totals surface carries: `"1 line needs a price choice; totals exclude it"` (singular) or `"N lines need a price choice; totals exclude them"` (plural).
* `priceChoiceSubmitBlock(lines)` — the reason Submit tender is refused while any lines are waiting. Returns `null` when nothing blocks. The block sentence names each waiting line by label so the estimator knows what to resolve.
* `PriceChoiceTotalsNotice` — a React component that renders the notice beside a row of totals. Renders nothing when every line has a price.

A line waiting for a price choice has `unitCost 0` as written by `api-estimate`; it contributes nothing to any total. `needsZeroCostReview` in `src/workItemMoneyDisplay.ts` returns `false` for a component with `priceChoiceRequired === true` so these `$0` lines are not also flagged as zero-cost lines for review — the two notices are mutually exclusive.

`EstimatePage` derives `submitBlock` from `priceChoiceSubmitBlock(priceChoiceLines(workItems))` and passes it to `EstimatePageHeader`, which forwards it to `LifecycleActionBar`. The server enforces the same rule: `POST /api/estimate/estimates/:id/transition` (or the project-resolved twin) with `action: "submit"` returns `422 price_choice_required` while any component on the estimate carries `priceChoiceStatus === "choice_required"`.

### Read model for price selections (`priceSelection`, `valueOverrides`)

Since PRD 211 F (stralos-feature-estimate#547), the pricing panel reads each build-up line's stored selection directly from the component read model fields `priceSelection` and `valueOverrides`. It no longer rebuilds a pricing map from `GET …/work-item-pricing`; that endpoint is now read for the work item's pricing source only.

`priceSelection` carries the supplier name stored at selection time, the selected option's location, and the selection context (supplier-scoped or organisation-scoped). The line's source label is derived entirely from this stored value — it is not refreshed from the current rate library. `valueOverrides` is the server's array of active hand-edited field values for the line (see [Value overrides](#value-overrides-put-value-overrides) below).

A line arriving without a `priceSelection` key — from a server build predating PR B of this stream — fails first-render resolution and renders as closed (the same state as a failed load).

**Removed:** `pricingMapsFromPersisted`, `toMaterialisedOverride`, the sparse-patch baseline machinery for `componentOverrides`, and the dead synthetic-id activity flatten are all gone.

### Work-item pricing PATCH (`pricingSource` only)

The autosave's `PATCH /work-items/:id/pricing` now carries `pricingSource` alone. It no longer sends `componentOverrides`. Pricing selections are written through `PUT …/pricing-rule` (see below); the PATCH is only the signal that switches the work item between internal and supplier pricing.

### Pricing rule write (`PUT …/pricing-rule`)

Rate option selections for a build-up line are written through `PUT /work-items/:workItemId/components/:componentId/pricing-rule`. The pricing panel calls this endpoint when the estimator chooses an option from the rate option picker. This replaces the previous `componentOverrides` path in the autosave PATCH.

### Value overrides (`PUT …/value-overrides`)

Hand-edited field values (name, category, quantity, resource count, unit, unit cost) are written through `PUT /work-items/:workItemId/components/:componentId/value-overrides`. The request body sends only the fields the estimator changed. A reset sends `{ clear: true }` which restores all fields on the line.

`src/valueOverrideMarks.ts` owns the badge labels, tooltip text, and per-field format helpers used by both the build-up row (`describeValueOverrides`) and the component editor (`describeValueOverride`). The overridden fields are mapped through `EDITOR_FIELD_OVERRIDE` (camelCase editor keys → `ValueOverrideField` server keys). `valueOverrideFor` returns the active override for one field, or `undefined`.

### Whole-return review hold

`estimatePricingWrites.ts` coordinates a write hold that the whole-return pricing review page takes while it is mounted. The review reads every returned rate against the stored snapshot, then applies a choice the server validates against that snapshot's revision. No autosave may land between the read and the apply.

The hold is a token set keyed by estimate ID. Releasing a token frees only that holder's own slot. A remount — the page re-keyed to a different package, or React StrictMode's double-invoke — runs the old cleanup before the new effect in the same commit. The release announcement is therefore deferred one microtask and suppressed when another holder acquires the estimate in that same turn.

`prepareEstimateVersionWrite` checks the hold before a version save or restore. If the review is open it throws `PRICING_REVIEW_OPEN_REFUSAL`, which is shown inline. The same check runs in each mounted pricing editor so the refusal does not depend on any editor being mounted.

## CI workflows

`build-remote.yml` runs on every push to `main` and every pull request. It rebuilds the pnpm workspace, runs type-check, lint, and tests, and builds the federation remote. It does not publish anything.

`deploy-dev.yml` runs on every push to `main` and on manual dispatch. It produces the by-SHA artifact and synchronises the remote bundle to the dev remotes bucket. It does not activate the remote; `stralos-core`'s `gate-dev` workflow handles composition and activation.

`release.yml` runs on a published GitHub release with a stable `vX.Y.Z` tag. It copies the already-produced by-SHA bundle to the semver prefix and writes the machine-readable `release.json` record (release-model R13). It never rebuilds. The step that copies bytes calls:

```
node scripts/archive-remote.mjs release \
  --bucket ARTIFACTS_BUCKET \
  --remote estimate \
  --sha GITHUB_SHA \
  --tag vX.Y.Z \
  --vite-config vite.config.ts \
  --federation-contract .ci/shell-federation-contract.json
```

All six options are required. If any is absent, the script exits with a usage error that names the missing option. A release of a SHA requires that SHA to have been dev-deployed first; `release.yml` copies the by-SHA bundle and never rebuilds, so a tag on a commit that has no bundle fails before any write.

See [Micro-frontends — Dev publication](https://help.stralos.app/internal/micro-frontends.md#dev-publication) for the full produce → gate → activate sequence.

### Routes contract

`build-remote.yml` emits a `routes-contract.json` beside `built-against.json` inside the artifact (federation routes contract v2). The contract is code-only: `remoteKey`, `contract`, `exposes`, and `routes[].path` in code order (31 paths). Presentation metadata (nav labels, ordering, auth) stays in the shell/seed.

The committed `.ci/routes-contract.json` is the source of truth. `src/routes.contract.test.ts` derives the contract from the real `./routes` import (under vitest), asserts byte-identity with the committed file, and runs the vendored schema verifier. A route added, removed, or reordered fails the estimate remote's own CI before the build step runs. The build then validates the committed contract against the vendored schema and copies it verbatim to `dist-remote/routes-contract.json`.

`build-remote.yml` re-asserts the emitted artifact with the schema validator before upload, so a malformed or missing contract file never reaches the remotes bucket.

The shell uses this published contract to discover the estimate remote's routes without cloning the source repository at a git SHA (Stage 3 of the shell-reduction federation-routes-contract programme).


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