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

# Design library

> Page-composition primitives in @stralos-co-nz/ui — the consistency layer for every Stralos screen

The design library is the page-composition layer in `@stralos-co-nz/ui`. Compose screens from these primitives instead of hand-writing Chakra props, and the suite becomes consistent by construction rather than by review.

Its scope comes from the UI consistency review of 2026-09-09, which audited 253 dev screenshots and counted five page-header patterns, 23 disagreeing status colour maps, six dialog widths, five date formats, and 445 literal palette props across the estate. The verdict was a missing-layer problem: `stralos-ui` had no `PageHeader`, `DataTable`, `StatusBadge`, `SectionCard`, `Toolbar`, or default dialog footer, so twelve remotes and the shell each filled those gaps independently.

Each remote pins `@stralos-co-nz/ui` to an exact version and bundles its own copy. `ui` is not a federation singleton: federation contract v2 shares only `react`, `react-dom`, `react-router-dom`, and `react/jsx-runtime`. From 0.72.0, `react-router-dom` is a peer dependency of `ui` (`^7.18.0`) rather than a dependency, so the consuming remote supplies it.

Rates-specific and API-contract pieces have moved out of `ui`. `RateLocationPicker` is in `@stralos-co-nz/ui-reference-data`, the location-editor types are in `@stralos-co-nz/maps-ui`, and the project-readiness error codes and classifier are in `@stralos-co-nz/domain`. The sections below say where each one lives.

***

## The seven rules

**1 — One page header, inside the card.** One title size, an optional status chip and count beside it, one line of subtitle, an optional back link above, and an action cluster right-aligned with at most one solid button. It sits inside the content card. Use `PageHeader`.

**2 — Colour carries one meaning.** Teal is interactive: the primary action, the active nav item, links. A project that is Live and the count chip on a `PageHeader` may also use it. Every state goes through one status → palette map via `statusPalette`. Kinds and roles (`Client`, `Supplier`, `Yard`) are neutral outline chips via `KindChip`. Red is for the irreversible; amber is for "needs attention".

**3 — One primary per view; toggles are not buttons.** One solid button (the task), outline for secondary, ghost for tertiary, a kebab for row actions, `ConfirmDialog` for destructive. A toggle is a `Switch` or a segmented control, never a filled button.

**4 — One table.** Sentence-case headers, numerics right-aligned and tabular, hover, sticky header by default, a count line that reads "Filtered to n" or "Showing all n", optional section row with subtotal, horizontal scrolling. The unit goes in the header (`Cost ($/t)`), never in the cell. Use `DataTable`.

**5 — One dialog contract.** Three widths (`sm` 480 px, `md` 640 px, `lg` 960 px). A footer with a hairline, Cancel, and exactly one primary. Fields on a twelve-column grid with spans. Use `Dialog` with the `actions` prop and `FormGrid`.

**6 — One formatter for dates, money, units, and words.** `7 Sep 2026` · `7 Sep 2026, 12:04 pm` · `$1,234.56` · `m² m³ ea hr t` · humanised enum names. Use the `format` helpers and `DateField`.

**7 — Add columns, not padding.** A sparse list gains the columns its reader needs, plus a count line. KPI tiles are label-over-value with no trailing space and are never repeated on a page. Empty states get a heading, one line, and one action. Lists load as skeleton rows, not a spinner.

**Acceptance for any screen:** header via `PageHeader` · one solid button · every list via `DataTable` with right-aligned numerics · every status via `StatusBadge` · every dialog via `Dialog` with `actions` · every date via `DateField`/`formatDate` · no literal palette prop in the diff · no raw identifier in visible text.

***

## Capitalisation in product copy

Use sentence case for navigation labels, page and panel titles, dialog titles, field labels, column labels, buttons, and help text. Capitalise the first word and proper nouns; preserve established acronyms and unit symbols. Examples: `Work items`, `Project rates`, `Global settings`, `Preview export`, `Set scale`, `Lock SOQ`, `API access`, and `Volume (m³)`.

Write labels in sentence case even where a shared typography role displays them in capitals visually, such as a table header. Keep the visual treatment in the shared component or theme. Never lowercase user-entered project, company, or item names, and never rewrite stored identifiers or route keys as a presentation fix. Change a remote-owned label in its owning package; retain any published contract until its normal contract update is reviewed.

Source: UI/UX comparison finding F-SHELL-18, 14 September 2026. Screen changes should include a copy check alongside layout review.

***

## Theme changes that apply without consumer code changes

These theme changes are cumulative and land on every screen without a consumer changing a line. Rows 1–7 shipped with `stralos-ui` 0.38.0; row 8 shipped with 0.60.3.

| # | What | Before → after |
| - | - | - |
| 1 | Every Chakra `Table` header | Sentence-case `fg` text on Chakra's default border → uppercase, letter-spaced, `text.muted`, over a `border.strong` hairline; cells middle-aligned; `[data-numeric]` cells right-aligned with tabular figures |
| 2 | `colorPalette="gray"` outline and ghost controls, light mode | `fg` gray.800 → **gray.700**; `border` gray.200 → **gray.300**; `subtle` (hover) gray.100 → **gray.200** |
| 3 | `colorPalette="green" / "orange" / "red" / "blue"` solid, subtle, and outline | Stock Chakra ramps → Stralos brand values in both modes. **Orange `contrast` goes white → navy.950** (white on `#E8A23D` is 2.4:1 and fails AA; navy is \~8:1) |
| 4 | `status.info.*` | navy.600 on navy.50 → **blue.600 on blue.50** |
| 5 | `red/orange/green` `.100 .200 .400 .800 .900 .950` and every `colorPalette="blue"` | Previously resolved to stock Chakra colours → brand ramps |
| 6 | Every unsized `Button` | `md` (40 px, 20 px icons) → **`sm`** (36 px, 16 px) |
| 7 | Every `Dialog` that does not set its own width | `sm` 448 → **480**, `md` 512 → **640**, `lg` 672 → **960**, `xl` 896 → **960** |
| 8 | Status text tokens (`status.*.fg`, `fg.error`, `fg.warning`, `fg.success`, `fg.info`) and the `green`, `orange`, `warning`, and `red` palette `fg` slots | Ink one step darker in light mode so every combination clears **WCAG AA 4.5:1** on white, `bg.subtle`, `bg.muted`, and the chip's own tint. `teal.subtle` is a paler tint so petrol text over the default `Badge` also clears 4.5:1. Dark mode is unchanged. No solid fills move. |

Rows 6 and 7 are the two size flips. They restyle every screen in the suite on the release that carries them. A button that names its own size is untouched; a dialog that sets its own `maxW` is untouched.

### 0.58.0 — Petrol amendment

These changes land app-wide from the `stralos-ui` 0.58.0 pin bump. No consumer code needs to change.

**Accent colour: teal.500 → teal.700 ("Petrol").** The interactive accent moved from `#00BFB3` (2.31:1 — a fill colour that was never legible as text or a border) to `#007A73` (5.21:1 both as a fill with white text and as text on white). Every place the accent was used has moved with it:

* `teal.solid` and `teal.contrast` — the primary button fill and its white-text contrast token.
* `text.interactive` — links and active nav items.
* `*:focus-visible` — the global keyboard focus ring.
* Every palette's `focusRing` (teal, navy, green, orange, blue, gray) — all now point at `teal.700`. Red keeps its own `red.500` ring.
* `accentColor` on `html` — native checkboxes, radios, and range inputs now tint with `teal.700`.

**`border.strong` contrast fix.** `border.strong` (the token used on inputs and the `DataTable` column-header and footer hairline) moved from `gray.300` (1.22:1 — effectively invisible) to `gray.600` (4.78:1). `border.default` (cards, dividers) is unchanged.

**Sunrise ramp and illustration tokens.** A `sunrise` colour ramp (50–950) has been added alongside two new semantic tokens:

* `illustration.accent` — `sunrise.500` (`#FF8A5B`) in light mode, `sunrise.400` in dark mode. Use for illustration fills and strokes in empty states, onboarding art, and celebration moments (a submitted tender, a completed import).
* `illustration.accentText` — `sunrise.700` (`#C4501E`, 4.65:1 on white) in light mode, `sunrise.300` in dark mode. The one step safe for a small text label or icon glyph inside a piece of illustration.

These tokens are strictly illustration and celebration only. Never use them for status, chart series, or interactive controls. Orange/amber already owns warning, and the sunrise ramp has not been validated against CVD checks.

**Shape — rounded corners.** Border radii changed from near-square (2/2/2/4/4 px) to rounded (4/6/8/12/16 px):

| Token | Before | After | Typical use |
| - | - | - | - |
| `radii.xs` | 2 px | **4 px** | Checkboxes, small chips |
| `radii.sm` | 2 px | **6 px** | Inputs, small buttons |
| `radii.md` | 2 px | **8 px** | Buttons, badges, popovers (suite default) |
| `radii.lg` | 4 px | **12 px** | Cards, menus |
| `radii.xl` | 4 px | **16 px** | Dialogs, side sheets |

The `StralosMark` brand symbol is untouched. The `full` token (9999 px, used by pill chips and `SegmentedControl`) is unchanged.

**Motion — `easings.overshoot` added.** A new easing token, `cubic-bezier(0.2, 0.9, 0.3, 1.35)`, is available for moments that should feel alive — a primary button's hover lift, a success banner's arrival. It is not yet wired into any component recipe in 0.58.0; it is available for the next release.

**Typography split: Inter leads body text.** `fonts.body` changed from Sora-led to Inter-led (`"Inter", "Sora", ...`). `fonts.heading` remains Sora-led. The split means:

* Page titles, card titles, navigation labels, and any component that sets `fontFamily: "heading"` explicitly continue to use Sora.
* Table cells, form fields, helper text, and all other body content now render in Inter.

No consumer needs to change: the split is enforced inside the theme. The two fonts are within \~3.5% of each other at 13 px, so the change is a legibility and personality adjustment, not a layout-breaking size change.

### Visual-token refresh (0.60.0)

Three token changes in 0.60.0 apply automatically to every screen at the next pin upgrade.

**Table headers are now sentence case.** `DataTable` column headers switched from `label.caps` (uppercase, letter-spaced) to `label.field` (sentence case) as the theme default. This aligns with the capitalisation rule above. Pass `headerCase="caps"` on a `DataTable` to keep the uppercase treatment for one table; use `textStyle="label.caps"` on a `columnHeader` for a bare Chakra table. Remote-owned column labels do not need to change — the label text was already sentence case; it was the visual treatment that was uppercase.

**`focus.ring` gains a dark mode arm.** The focus-ring colour token (`focus.ring`) previously resolved to the same `teal.700` petrol literal in both modes. On the dark canvas (`bg.canvas` in dark mode, `#0A1420`) that colour measures 1.9:1 contrast — below the WCAG 2.4.11/1.4.11 floor for focus indicators. From 0.60.0, `focus.ring` resolves to `teal.700` in light mode and `teal.400` in dark mode (7.6:1 on the dark canvas). No consumer change is needed; the ring on every palette that previously used the petrol literal now inherits this token.

**`colorPalette="warning"` replaces `colorPalette="orange"` for caution controls.** `orange` is a general-purpose amber palette — any button or badge may use it for decoration. This made it impossible to recolour warning indicators without also repainting decorative amber. From 0.60.0, use `colorPalette="warning"` for a caution control or chip, and `status.warning.*` for a tinted row or banner. The `warning` palette currently resolves to the same amber steps as `orange` — no pixel changes today — but the two ramps can now move independently. Never use `colorPalette="warning"` because something should be amber; use it because something needs attention.

### 0.60.1 dark-mode table fix

Table rows with `bg: "bg"` now sit on the navy card ladder in dark mode. Previously Chakra's `bg` token in dark mode resolved to a neutral zinc near-black, so a line table inside a navy card was a near-black slab — visually detached from its card. The fix replaces the `bg.*` / `bg.panel` / `bg.subtle` / `bg.muted` / `bg.emphasized` dark tokens with navy values so tables and cards share the same surface hierarchy. No consumer code changes are needed.

### 0.60.3 — status text contrast fix (light mode)

`stralos-ui` 0.60.3 moves light-mode status foreground inks one step darker so every `status.*.fg`, `fg.error`, `fg.warning`, and `fg.success` token clears the WCAG AA 4.5:1 threshold on all documented backgrounds. It also lightens `teal.subtle` so petrol (`teal.fg`) clears it.

| Token group | Before → after (light mode) |
| - | - |
| `status.error.fg`, `fg.error`, red palette `fg` | red.600 → **red.700** |
| `status.warning.fg`, `fg.warning`, orange palette `fg` | orange.700 → **orange.800** |
| `status.success.fg`, `fg.success`, green palette `fg` | green.600 → **green.700** |
| `teal.subtle` | teal.100 → **teal.50** (background; no text change) |

Dark-mode tokens are unchanged. No consumer code change is needed — the updated values land on every screen that uses the semantic tokens, `StatusBadge`, or `AttentionBadge`. `statusContrast.test.ts` in `stralos-ui` measures every foreground/background pair from the emitted token CSS in both modes.

### 0.61.0 — sticky table headers and windowed long lists

In ui 0.61.0, `DataTable` headers became sticky by default. When a table fits its container, the page is the scrollport and the header parks below the app bar; a table with `maxHeight` sticks inside its own scroller. At that version, flat tables above 200 rows were windowed automatically; sectioned tables were excluded, and expandable tables needed an explicit `virtualized` setting. The current `virtualized` contract is described below.

### 0.62.0 — `entryRow` and `columns[].span`

**`entryRow`** adds a persistent add row that `DataTable` owns. It renders one node per data column as real `td` cells so the row cannot drift out of the header's column contract. It is not `footer` and is not counted in `rows` — it reaches neither `count`, selection, sorting, nor windowing. It outranks `emptyState` so the add row remains visible on an empty table. Use `placement: "top"` for a short list where the row stays visible; long lists use bottom placement with `pinned: true` and a `maxHeight` scrollport.

**`columns[].span`** is a per-row function that returns how many columns a cell occupies for that row. Use it for edit-in-place rows that need to merge adjacent cells. It is clamped to data columns and cannot absorb the leading or trailing control columns.

### 0.62.1 — entry row pinning fixed

`DataTableEntryRow.pinned` now works correctly. The first implementation applied `position: sticky; bottom: 0` to the entry row's cells; measured in Chrome, that declaration had no effect and the row sat at the end of the body regardless of scroll position. The declaration now targets the `<tr>` element itself, which holds the row against the scrollport floor as documented. Consumers using `pinned: true` can remove any scroll-into-view workaround that was compensating for the previous no-op.

### 0.65.2 — sticky header fixes

**Table overflow corrected.** `DataTable`'s scroll wrapper now applies `overflow: auto` only when the table is genuinely wider than its container. Previously `overflow: auto` was applied unconditionally, which made the wrapper a vertical scrollport and silently disabled `stickyHeader` on every table that fits its container. No code change is needed — tables that passed `stickyHeader` will now actually stick.

**`SectionCard stickyHeader` propagates to nested tables.** When `stickyHeader={true}` is set on a `SectionCard`, the card header sticks below the app bar and publishes its measured height as `--stralos-section-sticky-top`. `DataTable` reads this variable so its column headers park below the sticky section header rather than behind it. Both measurements update when the header wraps or the viewport changes. Pass `stickyHeader` on any `SectionCard` whose header should stay visible while a long list scrolls beneath it.

**Sticky headers in horizontally scrolling tables.** `DataTable` sticky headers now work when the table is wider than its container and scrolls horizontally. Previously, the horizontal scroll area doubled as the vertical scrollport, and a header inside a scrollport exactly as tall as its content never moved. The header row now sticks at the top while the table body scrolls in both directions. A `maxHeight` is no longer required to get a sticky header on a wide table.

### 0.66.0 — measured sticky headers across viewports

**Measured section and ancestor offsets.** `SectionCard` with `stickyHeader` now measures actual ancestor column offsets, section boundaries, and compact Filters/Actions disclosures when computing where its header band should park. Headers remain visible at any combination of wide and short viewports, including nested cards inside horizontal overflow. The band falls back to normal flow when the viewport is too short to show both the header and at least one row.

**Bounded tables below pinned section bands.** A `DataTable` inside a `maxHeight` scroller that sits beneath a pinned `SectionCard` header now measures those actual ancestor bands and parks its column headers below them, rather than behind them. Previously, a bounded scroll area's columns could slide under the section's sticky band when the user scrolled. No code change is needed — the scroll owner is preserved, and the column offset updates when the section band resizes.

**Resource picker search and summary always visible.** `PersonPicker`, `CrewPicker`, and `PlantPicker` now use `SectionCard` with `stickyHeader` internally, so the selection summary and search input remain visible as the option list scrolls. Previously the search field could scroll out of view in a full-height dialog.

**Resource picker result cap removed.** All supplied results are now accessible through virtual scrolling. Previously the option list was capped at 50 rows; items beyond that position were unreachable without narrowing the search. The existing `maxListHeight` and surrounding-dialog scroll ownership are unchanged.

**`useElementHeight` export.** `useElementHeight` is now exported from `@stralos-co-nz/ui`. Use it to measure a navigation band or preview panel whose height must be fed into a sticky offset. Consumer repos that previously copied this hook can remove their local copies.

### 0.78.2: virtual list safety net

`DataTable` (and `ReadOnlyDataTable`), `VirtualTableBody` and `InfiniteList` each sit inside a `VirtualFallbackBoundary`. If a list hits React's update-depth limit (error #185) or the virtual window's own measuring throws, that list re-renders in a fallback mode instead of taking the page down:

* **Full render**: a list of 200 rows or fewer (`FULL_RENDER_FALLBACK_MAX_ROWS`) that is not range-loaded renders every row, so there is nothing left to measure.
* **Frozen measurement**: a larger list, or any list using `virtualData`, stays windowed with measurement frozen. Measuring no longer re-renders, a row's stored height only grows, and the estimate stays the one the list mounted with. Rows keep their real height; only the spacers can be off. `InfiniteList` paging is unchanged: the sentinel still loads one page at a time.

Each fallback is reported once through the shell's observability bridge (`__STRALOS_OBSERVABILITY_BRIDGE__`, version 1) as a `VirtualListFallback` error; with no bridge, nothing is reported. Any other error, or a second failure while already in fallback, goes to the page's own error boundary as before. No consumer change is needed.

***

## Primitives

### `PageHeader` — rule 1

The top of every screen. Title, optional status and count, one-line subtitle, optional back link or breadcrumb trail, right-aligned actions, optional children (tabs, a filter row) below.

`title` · `headingLevel?` (`"h1"` default) · `subtitle?` · `status?` · `count?` · `backTo?: { label, href?, onClick?, render? }` · `breadcrumbs?: PageHeaderBreadcrumb[]` · `actions?` · `children?`

One `h1` per page. A `PageHeader` that heads a panel inside a page (a drawer, a detail pane, a card with its own actions) takes `headingLevel="h2"`. The size never changes: the audit found four title sizes and the fix was one size, not one tag.

In a single-page app, `backTo` should use the `render` slot. `href` emits a raw anchor, which causes the browser to navigate and the shell to re-bootstrap. `onClick` alone emits a link-styled button with no URL, so middle-click and "open in new tab" do nothing.

`onClick` receives the event, so `href` + `onClick` is a usable router link: the anchor keeps a real URL and the handler calls `event.preventDefault()` then pushes the route. Without the event, the pair did both — pushed the route and reloaded the page on top of it. Existing `() => void` handlers are unaffected.

**Breadcrumb trail.** Pass `breadcrumbs` to show an ordered trail above the title. A non-empty `breadcrumbs` array replaces the back link — pass one or the other, not both. Each `PageHeaderBreadcrumb` has the same three link shapes as `backTo` (`render > href > button`), with the same SPA-router guidance: use `render` in a single-page app. When no shape is supplied, the crumb renders as plain text. The last crumb receives `aria-current="page"` automatically. The trail is wrapped in a `<nav aria-label="Breadcrumb">` so assistive technology announces the landmark.

### `SectionCard`

Content card wrapper. Pass `padded={false}` when the card content starts with a `PageHeader` (the header supplies its own padding).

Pass `stickyHeader={true}` to keep the card's header band visible while the list scrolls. The card's header height is published as `--stralos-section-sticky-top`, which a nested `DataTable` reads to park its column headers below the sticky band. Both measurements update when the header wraps or the viewport changes.

**Scroll ownership (0.69.0).** A page-level `SectionCard` with `stickyHeader` — one at the top of the page, not inside another sticky card, dialog, or drawer — owns the list's scroll by default. The card sizes itself to fill the viewport below it; the page stops scrolling and the header band stays still. The scrolling region is focusable (`role="region"`, `aria-label` from `title` or the `scrollLabel` prop) so keyboard users scroll it with arrow keys and Page Down. When a richer `header` slot is used instead of `title` (as `RegisterPage` does), the accessible name is read from `scrollLabel`. From 0.70.0, `RegisterPage` derives the name from its own `title` prop automatically when it is a string, so assistive technology announces the correct register name — "People", "Crews", "Compliance", and so on — rather than the generic fallback "List". Pass `scrollLabel` on `RegisterPage` when `title` is not a plain string.

Pass `scroll="page"` to opt out and keep the page as the scroller. Use this for cards where the content below the list matters or the list is not the page's primary content. `RegisterPage` accepts the same prop.

Use `title` and `actions` for a simple heading row, or `header` for a richer band such as a `PageHeader` with tabs and a `Toolbar`. `header` replaces `title`/`actions`.

```tsx theme={null}
<SectionCard
  padded={false}
  stickyHeader
  header={
    <>
      <PageHeader title="Rates" actions={<Button>Add rate item</Button>} />
      <Toolbar px="18px" pb="3">{/* search and filters */}</Toolbar>
    </>
  }
>
  <DataTable rows={rows} columns={columns} getRowId={(row) => row.id} />
</SectionCard>
```

Do not put `overflowX="auto"` on the card or any ancestor wrapping the table. The table owns horizontal scrolling when its columns exceed the available width; an extra ancestor scroller interferes with scroll ownership and sticky headers.

### `Toolbar`

A sticky horizontal strip for search, filter controls, and a count line, sitting between the `PageHeader` and `DataTable`.

### `MultiSelectMenu` (0.80.0)

A toolbar filter dropdown where every option is a checkbox row, so several values can be ticked at once. Nothing ticked means "All …", like the single-choice filters beside it. Import `MultiSelectMenu`, `multiSelectSummary`, and the `MultiSelectMenuOption` and `MultiSelectMenuProps` types from `@stralos-co-nz/ui`.

```tsx theme={null}
<MultiSelectMenu
  label="Severity"
  allLabel="All severities"
  pluralLabel="severities"
  options={[{ value: "high", label: "High", count: 5 }]}
  value={severities}
  onChange={setSeverities}
/>
```

| Prop | Notes |
| - | - |
| `label` | Required. Names the trigger and the option group, for example "Severity". |
| `allLabel` | Required. Trigger text with nothing ticked, for example "All severities". |
| `pluralLabel` | Required. Noun for two or more ticked: "severities" reads "2 severities". |
| `options` | Required. Each option has `value`, `label`, an optional `count` shown at the end of its row, and optional `disabled`. |
| `value` | Required. The ticked values. Empty means no filter. |
| `onChange` | Required. Receives the new ticked values, always in the options' order. |
| `countLabel` | Formats the count in each row's accessible name, for example `(n) => formatCount(n, "finding")` reads "High, 5 findings". The visible count stays the bare number. Defaults to `String`. |
| `size` | `"xs"` (24 px) or `"sm"` (32 px, default). |

The trigger reads the `allLabel` with nothing ticked, the option's label with one ticked, or "2 severities" with two or more. `multiSelectSummary(options, value, allLabel, pluralLabel)` returns the same text for callers that need it elsewhere.

The options are real checkboxes in a popover, not `Menu.CheckboxItem` rows, because a menu item drops activations on items it has not highlighted. The popover stays open while ticking: Space ticks the focused row, Tab moves between rows, and Escape closes the popover and returns focus to the trigger.

### `StatusBadge` — rule 2

Renders a status value from the `statusPalette` map with the correct colour for the screen's colour mode. Use instead of a hand-built `<Badge bg="green.100" color="green.800">`.

The `attention` domain covers `unpriced`, `stale`, `superseded`, and `expired`. An amber glyph appears by default; pass `glyph={false}` to suppress it, or a React node to override it.

The `estimate` domain covers both the tender lifecycle and the delivery lifecycle. From domain 3.9.0 (PRD 210 phase 1), `Estimate["status"]` includes `in_sign_off` (orange, "In sign-off") and `signed_off` (green, "Signed off") in addition to the existing tender statuses. These delivery statuses are only set on a delivery estimate. The `ESTIMATE_STATUSES` map is keyed by the full domain union, so a missing status is a compile error rather than an uncoloured chip.

The `variation` domain supports seven distinct lifecycle colours and glyphs (F-TV-18). Each lifecycle state maps to its own palette and glyph — use `domain="variation"` and pass the variation's status value rather than wiring colours by hand. The `glyph` override and `glyph={false}` suppression work identically here.

— `registerStatusDomain`

#### Custom status domains — `registerStatusDomain`

Never alias a built-in domain to borrow its colour. Register a custom domain for any state that only one remote shows. Call `registerStatusDomain` once at module scope in the remote's entry point:

```ts theme={null}
// src/statusDomains.ts in the remote, imported by its entry point.
import { registerStatusDomain } from "@stralos-co-nz/ui";

registerStatusDomain("party", {
  approved: { colorPalette: "green", label: "Approved" },
  pending: { colorPalette: "orange", label: "Approval pending" },
  suspended: { colorPalette: "red", label: "Suspended" },
  not_applicable: { colorPalette: "gray", label: "Not applicable" },
});
```

Then use `<StatusBadge domain="party" value={party.approvalStatus} />` anywhere in that remote.

Register when exactly one remote shows the state. Promote to a built-in domain only when more than one remote shows the same state. `registerStatusDomain` refuses a built-in name, so promoting a registered domain turns the registration into a compile error that tells you to delete it.

`statusDomainEntries(domain)` is the equivalent of `STATUS_PALETTE[domain]` for a filter or legend that needs to work with registered domains. `STATUS_PALETTE` is statically typed over the built-in union and cannot see registered domains. `statusDomainEntries` returns a copy, and `{}` for a domain nobody has registered.

The registration is module-level state in the copy of `@stralos-co-nz/ui` that registered it. `ui` is not a federation singleton: under contract v2 each remote bundles its own copy at an exact pin, so a domain one remote registers is visible only inside that remote. Registering the same name twice in one remote merges key by key, with the last registration winning a contested key. Give a domain a name that says whose it is.

### `KindChip` — rule 2

Neutral outline chip for a kind or role label (`Client`, `Supplier`, `Yard`). Kinds are not state, so they are not coloured.

### `AttentionBadge`

Amber chip for a count that needs attention. `count={0}` renders nothing.

```tsx theme={null}
<AttentionBadge count={4}>to review</AttentionBadge>
```

Use instead of a hand-built `<Badge bg="orange.100" color="orange.800">`.

### `DataTable` — rules 4, 7

The one table. Section rows, a kebab column, a count line, skeleton rows, an empty state, and a scroll area.

The sticky-header default, automatic windowing, `stickyHeaderOffset`, and `pagination` contracts described below require `@stralos-co-nz/ui` 0.61.0 or later.

`columns` · `rows` · `getRowId` · `getRowLabel?` · `sections?` · `sectionActions?` · `sectionCollapse?` · `rowActions?` · `expandable?` · `selectable?` · `columnPicker?` · `sort?` · `count?` · `emptyState?` · `entryRow?` · `stickyHeader?` · `stickyHeaderOffset?` · `maxHeight?` · `pagination?` · `virtualData?` · `loading?` · `onRowClick?` · `footer?` · `density?`

`entryRow` and `columns[].span` require `@stralos-co-nz/ui` 0.62.0 or later.

**`count`** takes `{ total, filtered?, shown?, noun?, announce? }` and renders a `ListCount`. `total` is how many rows match the current filters (the server's total), not how many are loaded. Pass `filtered` from the page's filter state: the line reads "Filtered to 12 projects" while a filter or search is on and "Showing all 12 projects" otherwise. Without `filtered`, the line falls back to `shown < total`, which cannot tell a filtered list that fits on screen from an unfiltered one. Give it the noun, as `{ one, other? }` so a count of one agrees ("Filtered to 1 project"). Use `announce: false` for a count that moves on every keystroke of a filter.

**`getRowLabel`** names the row for the kebab, checkbox, and expander. Without it the name comes from the first column's rendered text when that column renders a plain string. Pass `getRowLabel` whenever the first column is a link, badge, or anything else that is not text.

**`selectable`** is keyed by `getRowId`, not by row identity. The header checkbox appears only when you pass `onToggleAll`. It reports all / some / none over the rows on screen.

* `isSelectable(id)` — decides whether a row's checkbox can be used. Use for cap enforcement (for example, refuse new selections once 500 items are chosen).
* `disabledReason(id)` — says why a refused row cannot be selected. The reason goes on the checkbox's accessible name.

**`rowStatus`** takes `{ status: "warning" | "error", message: string }` and tints the row with the matching semantic colour. The message surfaces on hover and to assistive technology.

**`rowDepth`** takes a per-row number and indents the leading cell by 24 px per level. Use for nested work items or bill-of-materials hierarchies that share one flat `rows` array.

**`layout="fixed"`** switches the table to `table-layout: fixed` and requires a `width` on every column. Use with `density="compact"` for even rows that stay aligned when filtering.

Control columns — the checkbox (`selectable`), expander (`expandable` or `sectionCollapse`), and row-actions kebab — have their widths reserved automatically: `40px` for `density="compact"` and `48px` otherwise. Only provide `width` on your data columns.

**`expandable`** is controlled. `renderDetail` is optional. Pass `isExpandable(row)` for a mixed list where only some rows open. From 0.76.1, toggling a row's detail keeps the row and its chevron mounted, so focus stays on the chevron and `aria-expanded` updates in place. No consumer change is needed.

**`columnPicker`** is a slot for a control that owns which columns appear. It shares the kebab column. Pass `<DataTableColumnMenu choices={choices} api={api} />` to get show/hide and reorder out of the box; see [`useDataTableColumns`](#usedatatablecolumns-and-datatablecolumnmenu) below.

**`virtualized`** controls windowed rendering for long tables. Only rows near the viewport are mounted, keeping the DOM shallow regardless of list length. Windowing is enabled by default for loaded tables, including sectioned and expandable tables. Pass `virtualized={{ enabled: false }}` to opt out. The count line still reflects the full row count. From 0.78.2 a render loop or measuring error falls back instead of crashing the page; see [0.78.2: virtual list safety net](#0782-virtual-list-safety-net).

`DataTable` uses its containing scroll region: a page-level sticky `SectionCard` can own a bounded scrollport, an explicit `maxHeight` can bound the table, and `scroll="page"` leaves the page as scroll owner. In a server render or test where no layout is measurable, the table renders all rows; pass `viewportHeight` for a deterministic window.

Pass a `DataTableVirtualization` object:

* `enabled` — `false` refuses windowing, including the automatic kind. Default `true`.
* `rowHeight` — row height in CSS pixels. Defaults to the height measured off the first body row at runtime. Every row must actually be this tall; variable-height rows cause the scrollbar to drift.
* `overscan` — extra rows rendered above and below the viewport. Default 6.
* `viewportHeight` — pass this in tests or server renders where the scroller has no measurable height.

**`columnResize`** adds a drag-or-arrow-key grip to each column's trailing edge. It is fully controlled: widths arrive back through `columns[].width` and nothing is stored here. Pair it with `useDataTableColumns`, which holds the widths and calls your `onColumnStateChange` callback.

Pass a `DataTableColumnResize` object:

* `onResize(key, width)` — called with the column key and its new width in CSS pixels. Wire this to `api.setWidth` from `useDataTableColumns`.
* `minWidth` — narrowest a column may be dragged. Default 64 px.
* `step` — how far one arrow-key press moves the edge. Default 16 px.
* `isResizable(key)` — which columns carry a grip. Defaults to every column.

**`sort`** takes `{ key, direction, onSort }` and turns every `sortable: true` column's header into a control with `aria-sort`. It does not re-order rows — sorting is server-side. `onSort` is called with the column and the direction it should take next.

**`stickyHeader`** is on by default. Where the header sticks depends on what owns the scroll:

* **Inside a `SectionCard` scrolling region (the default from 0.69.0):** the area stops being a scroll container. The header sticks to the top of the region with CSS `position: sticky`, including for wide tables that scroll sideways inside it. Pass `stickyHeaderOffset` to override the sticky top position.
* **Inside a `maxHeight` scroller:** the header sticks to the top of that scroller.
* **On a scrolling page (`scroll="page"` or no `SectionCard`):** the header parks under the app bar via `--stralos-sticky-top`. When the table fits its container width, the header is CSS sticky. When the table is wider than its container and scrolls horizontally, the header scrolls with the rows — horizontal scroll and page-level vertical sticky cannot coexist.

When the table sits inside a `SectionCard` with `stickyHeader={true}`, the card publishes `--stralos-section-sticky-top` so column headers park below the section band rather than behind it.

When the scroller is too short to show both a pinned header and at least one full row, the header flows with the rows instead of pinning. This prevents a pinned header with no visible rows beneath it in very short viewports (such as the scheduler at 320×120 px). The sticky position is restored automatically when the scroller grows to accommodate both. Inspect `data-sticky-table-state` on the scroll area element (`"sticky"` or `"flow"`) to confirm the mode in tests.

**`entryRow`** adds a dedicated row for creating a new record without leaving the table. Pass a `DataTableEntryRow` object:

* `cells` — one `ReactNode` per data column, in `columns` order. Missing trailing entries render empty cells.
* `action` — the trailing cell beside the kebab column. Typically the submit button and any inline validation message.
* `placement` — `"bottom"` (default, after every data row) or `"top"` (before them). Use `"top"` for a short list where the row is always visible. Use `"bottom"` with `pinned: true` for a longer list.
* `pinned` — holds the entry row against the foot of the scroll area so a long list can still be added to without scrolling to the end. Requires `maxHeight` to establish the scrollport; without one the row sits at the end of the body as normal. Available from `@stralos-co-nz/ui` 0.62.1.
* `label` — accessible name for the row group. Defaults to `"Add row"`.

`DataTable` renders the entry row even when `rows` is empty — pass `entryRow` and omit `emptyState` for a panel that always shows the add row rather than an empty state.

```tsx theme={null}
<DataTable
  rows={substances}
  maxHeight="400px"
  entryRow={{
    cells: [<SubstanceNameInput />, <RateInput />],
    action: <Button type="submit">Add</Button>,
    pinned: true,
  }}
  …
/>
```

**`pagination`** is the one load-more pattern. Pass it instead of building a pager yourself.

```tsx theme={null}
<DataTable
  rows={loaded}
  count={{ total, filtered: search !== "", noun: { one: "rate" } }}
  pagination={{ hasMore: loaded.length < total, onLoadMore: fetchNext, loading }}
  …
/>
```

Loading triggers automatically as the user scrolls toward the end of the list. A loading status is shown while a fetch is in flight, and `End of list` replaces it once every row is loaded. A batch is requested once per scroll position, so an error or an empty response cannot trigger repeated requests. The count line acts as the anchor: append rows to `rows` in the same update that changes `count`, and the live region announces the new total. `DataTablePagination` props: `hasMore` · `onLoadMore` · `loading?` · `pageSize?`.

There is no Previous/Next option. A caller that needs offset pagination must build it by hand.

**`virtualData`** is for result sets too large to load at once, where the caller fetches only the rows the user is looking at. The table reserves space for the entire result set so the scrollbar reflects the full list, renders unloaded positions as placeholder rows with `aria-busy="true"`, and reports the visible range to the caller whenever the user scrolls.

```tsx theme={null}
<DataTable
  rows={loadedRows}
  columns={columns}
  getRowId={(row) => row.id}
  maxHeight="600px"
  count={{ total, filtered: search !== "", noun: { one: "item" } }}
  virtualData={{
    totalCount: total,
    getItem: (index) => loadedRows[index],
    onRangeChange: (start, end) => fetchRange(start, end),
    resetKey: `${sortKey}:${sortDir}`,
  }}
/>
```

`VirtualData<T>` props:

* `totalCount` — the full count of rows in the result set. The table uses this to size the scroll area and set `aria-rowcount` correctly for assistive technology.
* `getItem(index)` — returns the loaded row at the given position, or `undefined` when that position has not been fetched yet. The index is the row's position in the full result set, not in the loaded slice.
* `onRangeChange(start, end)` — called with an inclusive start and exclusive end (including overscan) whenever the visible range changes. Fetch the positions in that range and update `rows` and `getItem` in the same render.
* `resetKey?` — change this value when sorting or filtering changes. The table scrolls back to position 0 so the user sees the first results rather than a position that may no longer be meaningful.

`virtualData` is mutually exclusive with `pagination` — use one or the other. It works only with ungrouped data; pass no `sections` prop alongside it. Use `virtualData` when the server can return a slice at any offset; use `pagination` when the server only supports appending the next page.

**`sections`** render as real `<tbody>` row groups. A `ReactNode` label degrades the group's accessible name; pass a string and put decoration elsewhere.

**`sectionActions`** puts a `RowMenu` in each section band's trailing cell.

**`sectionCollapse`** puts a chevron in the band's leading cell. It is controlled. A collapsed section keeps its band and renders none of its rows. The select column stays empty on a band.

**`sections[].subtotal`** renders in the last data column, right-aligned. The kebab column stays empty on a section row.

**`columns[].span` — merging columns for one row (0.62.0)**

```tsx theme={null}
{
  key: "rule",
  header: "Rule",
  render: (rule) => (isEditing(rule) ? <RuleEditorControls /> : <Text>{ruleSentence(rule)}</Text>),
  span: (rule) => (isEditing(rule) ? 2 : 1),
}
```

`span` is a per-row function that returns how many columns this cell occupies for that row. The columns it absorbs render no cell. Use it for edit-in-place rows that need to merge adjacent cells — for example, keeping the Scope cell as itself while merging Rule and Distance into one editor while a rule is being edited.

`span` is deliberately not a `renderRowOverride` prop. A prop that handed the caller a whole `tr` would let a screen write its own cells, which is the column drift this component exists to remove. A span cannot express that: the checkbox, chevron, and kebab columns stay exactly where the header places them. Span is also clamped to the data columns that follow, so a row cannot swallow the kebab column.

Cell-level editing needs neither `span` nor an override: `column.render` already receives the row, so `render: (item) => isEditing(item) ? <Input /> : item.name` is the complete pattern for a single editable cell.

**Density:**

| Value | Row height | Use for |
| - | - | - |
| `"comfortable"` (default) | 44 px | A grid people act on: editable cells, a kebab per row, rows that open something. |
| `"compact"` | 32 px | A long read-only reference or library list that is scanned rather than operated. |

Density is not a global theme default. It is a per-table judgement about what the reader does with the rows.

Use instead of a bare `<Table.Root>` with hand-written headers, and instead of card-per-row CSS grids.

`onRowClick` is a convenience, not a navigation contract. A `tr` is not a button. Keep a real link or a `RowMenu` item in the row.

### `ReadOnlyDataTable`

`ReadOnlyDataTable` (0.78.0) is `DataTable` without row actions, section actions, or row selection, and without the code for them. It takes every `DataTable` prop except `rowActions`, `sectionActions`, and `selectable`, and strips those three at runtime for an untyped caller. Columns, sections, sort, expandable rows, the column picker slot, `onRowClick`, windowing, pagination, the count line, and the empty state all work the same way.

`DataTable` imports `RowMenu` and Chakra's `Checkbox` at module scope, so every table pays for them whether it uses them or not. `ReadOnlyDataTable` imports neither. It measured 8,134 gzip bytes against `DataTable`'s 43,379 on the same page, about 35 KB less.

The saving is only real for a remote with no full `DataTable` anywhere. Once one table on any screen of the remote takes a kebab or a checkbox, the remote pays for them once and the read-only tables beside it save nothing. Swap a whole remote's read-only lists, such as a dashboard, not one table among several. The moment a row needs a kebab or a checkbox, use `DataTable`; the props are the same.

See `docs/datatable-bundle-cost.md` in `stralos-ui` for the measurements and the bundle-cost gate.

### `useDataTableColumns` and `DataTableColumnMenu`

`useDataTableColumns` manages which columns are visible, in what order, and how wide. Nothing is written to storage here — the hook reports every change through `onColumnStateChange`, and the caller decides where to persist it.

```tsx theme={null}
const { columns, choices, api } = useDataTableColumns(ALL_COLUMNS, {
  onColumnStateChange: (state) => save(state),
});
<DataTable columns={columns} columnPicker={<DataTableColumnMenu choices={choices} api={api} />} … />
```

**`useDataTableColumns` options:**

* `state` — controlled state loaded from wherever you store it. Omit for the uncontrolled shape.
* `defaultState` — starting state when uncontrolled. Read once.
* `onColumnStateChange(state, change)` — called with the whole next state and a `change` descriptor (`reason: "visibility" | "order" | "width" | "reset"`, `key?`). Not called when a change is refused.
* `minVisibleColumns` — how many columns must remain visible. Default 1.

**`DataTableColumnState` shape** (persisted by the caller):

* `hidden` — keys currently hidden. Everything else is shown.
* `order` — keys in display order. Unknown or dropped keys are ignored; new columns appear after stored ones.
* `widths` — per-column width in CSS pixels as set by a resize.

**`DataTableColumnMenu`** is the built-in column picker. Show/hide uses checkboxes; reorder uses up/down buttons rather than drag, so the feature is fully keyboard-operable. Pass it to the `columnPicker` slot. Props: `choices` · `api` · `label?` · `title?` · `showReset?` · `size?` (`"2xs"` for a compact table).

### `AiCellLabel` — AI cell provenance

`AiCellLabel` marks a cell whose value an AI read, rather than a person typed. Place it in the cell's leading gutter.

```tsx theme={null}
<AiCellLabel
  state={cell.editedAt ? "overridden" : "ai"}
  source="Sheet1, row 14"
  aiValue={formatMoney(cell.aiValue)}
  onRevert={cell.editedAt ? () => revert(cell) : undefined}
/>
```

**States:**

* `"ai"` — the AI read the value and nobody has changed it. Renders an accent dot.
* `"overridden"` — the AI read the value and a person has since typed over it. Renders a muted dot. The only state where a revert offer makes sense.

A cell a person typed from the start carries no marker. That is the point: the marker means "a machine wrote this", so applying it to everything says nothing.

**Props:** `state` · `source?` (where the value was read from, shown in the popover) · `aiValue?` (the original AI value, shown beside the revert action) · `onRevert?` (only offered for `"overridden"`) · `revertLabel?` (defaults to "Revert to AI value") · `confidence?` (0–1, appended to the accessible name as a percentage)

The dot is a real button, not a hover target. Its accessible name states the whole situation ("AI-filled, not yet reviewed — from Sheet1, row 14"). The popover opens on focus as well as hover, and the revert action inside it is a second real button.

### `RowMenu` — rule 3

The kebab that carries a row's actions. Selection is wired to the item's own click, so keyboard activation and assistive-technology activate run the same handler as a pointer.

`items: { label, onSelect, destructive?, disabled? }[]` · `label?` · `size?` (`"xs"` default, `"2xs"` for a compact row)

Use instead of a `View` link, an `Edit` button, or any other per-row action vocabulary.

### `ListCount` — rules 4, 7

`<ListCount total={12} filtered={search !== ""} noun={{ one: "project" }} />` → "Filtered to 12 projects" or "Showing all 12 projects". The line answers one question: is this list filtered, or is that everything? `noun` accepts `{ one, other? }` (`other` defaults to `one` plus "s"); a bare string is the plural, used as-is for every count including one. `shown` is optional and only read when `filtered` is not passed. Prefer `DataTable`'s `count` for a table, because it carries the polite live region. Use `ListCount` directly for a card grid or a list that is not a table.

### `KpiStrip` and `MetricTile` — rule 7

A row of tiles under the header. `size="compact"` is label-over-value with no trailing space.

```tsx theme={null}
<KpiStrip px="18px" pb="3.5">
  <MetricTile size="compact" label="Tender price" value={formatMoney(price)} helper="excl. GST" />
</KpiStrip>
```

`helper` takes a `ReactNode`, so a delta chip, a `StatusBadge`, or a link can sit under the value. Use instead of a hand-rolled `<SimpleGrid>` of `<Stat>` cards.

The `size="compact"` label uses `label.field` (sentence case). From 0.60.2, the compact tile no longer uses `label.caps`, so tile labels match the sentence-case table headers introduced in 0.60.0. Write labels in sentence case — "Pipeline value", not "PIPELINE VALUE". Use `textStyle="label.caps"` directly on a `Text` element only when all-caps is an explicit design choice for a specific tile.

### `SkeletonRows` — rule 7

`<SkeletonRows rows={5} columns={4} />`. Use instead of a bare `<Spinner />` while a list loads.

### `InfiniteList`

A virtually-scrolled card list with automatic scroll-triggered loading. Use `InfiniteList` for non-table layouts — activity feeds, document cards, search results — where `DataTable` is not appropriate.

```tsx theme={null}
<InfiniteList
  items={activities}
  totalCount={total}
  hasMore={activities.length < total}
  isLoadingMore={fetching}
  onLoadMore={fetchNext}
  renderItem={(activity) => <ActivityCard activity={activity} />}
  itemKey={(activity) => activity.id}
  emptyState={<EmptyState title="No activities yet" />}
/>
```

Only items near the viewport are mounted. Loading triggers automatically as the user scrolls toward the end. A spinner is shown while `isLoadingMore` is true and the list is empty (initial fetch); `End of list` replaces the sentinel once `hasMore` is false.

Props: `items` · `renderItem` · `hasMore?` · `totalCount?` · `filtered?` · `noun?` · `isLoadingMore?` · `onLoadMore?` · `itemKey?` · `loadKey?` · `emptyState?`

* `hasMore`: controls automatic loading and the end-of-list status. Defaults to `totalCount !== undefined && items.length < totalCount`.
* `totalCount`, `filtered`, and `noun`: when `totalCount` is set, a `ListCount` renders above the list: "Filtered to n items" while `filtered` is true, "Showing all n items" otherwise. Fewer loaded items than `totalCount` is paging, never filtering, so pass `filtered` explicitly when a filter is active. `noun` defaults to `{ one: "item" }`.
* `loadKey`: a cursor or token that changes when a new batch is available. Pass it for cursor-based APIs so the sentinel can tell the difference between two fetches for the same batch position. Defaults to a key derived from the first and last item keys plus the list length.
* `emptyState`: rendered once the list settles at zero items. Not shown during the initial fetch.

Import `InfiniteList` from `@stralos-co-nz/ui`.

### `ResponsiveRowList` and `RowCard`

A breakpoint switch for data-dense surfaces. The desktop `DataTable` (or other grid) renders unchanged from the `md` breakpoint up. Below `md`, a stacked card list takes over. Both slots are always in the DOM and CSS visibility controls which is shown — the same mechanism as `AppFrame`'s hamburger/inline nav.

```tsx theme={null}
<ResponsiveRowList
  grid={<DataTable rows={rows} columns={columns} getRowId={(r) => r.id} />}
  cards={rows.map((row) => (
    <RowCard
      key={row.id}
      primary={row.name}
      meta={row.code}
      pairs={[
        { label: "Unit rate", value: formatMoney(row.unitRate) },
        { label: "Quantity", value: formatQuantity(row.quantity, row.unit) },
      ]}
      actions={<RowMenu items={[{ label: "Edit", onSelect: () => edit(row) }]} />}
      onClick={() => open(row)}
    />
  ))}
/>
```

`RowCard` renders one row as a card: a primary line (semibold, clamped to two lines), an optional muted meta line beneath it, a two-column label/value grid for numeric columns, and a trailing actions slot with a 44 px minimum touch target. Children render below the pairs as expansion content.

Desktop-only affordances — drag handles, spark cells — should be omitted from the `RowCard` rather than squeezed in.

**`ResponsiveRowList` props:** `grid` · `cards`

**`RowCard` props:** `primary` · `meta?` · `pairs?: { label, value }[]` · `actions?` · `onClick?` · `children?`

Import both from `@stralos-co-nz/ui`. The fleet register uses `ResponsiveRowList` as its reference implementation.

### `VirtualTableBody`

A `Table.Body` replacement that windows rows for custom editable tables whose cell structure is owned by the caller. Use this when `DataTable` cannot be adopted — for example, a bespoke table that renders its own rows because cells hold uncontrolled inputs or drag handles.

```tsx theme={null}
<Table.Root>
  <Table.Header>…</Table.Header>
  <VirtualTableBody columnCount={4} revealRow={shouldReveal ? { index: newRowIndex } : undefined}>
    {rows.map((row) => (
      <Table.Row key={row.id}>{/* … */}</Table.Row>
    ))}
  </VirtualTableBody>
</Table.Root>
```

`VirtualTableBody` forwards `data-virtual-key` and `aria-rowindex` to each rendered row automatically. Pass `columnCount` when children are custom row components so the top and bottom spacer rows span the correct number of columns. `revealRow` scrolls a given row index into view on mount — use it to reveal a newly added row.

Props: all `Table.Body` props, plus `columnCount?` · `revealRow?: { index: number; onReady?: () => void }`

Import `VirtualTableBody` from `@stralos-co-nz/ui`.

### `UnitCombobox` (0.72.0)

The shared unit picker, used by the rates and activities editors. It is a strict select: typing filters the catalogue, but only a pick from the open list writes, and unmatched text is discarded on blur so the committed unit stays on display.

```tsx theme={null}
<UnitCombobox
  value={item.unitCode}
  options={unitOptions}
  ariaLabel="Unit"
  onValueChange={(code) => setUnit(code)}
/>
```

The closed field shows the unit symbol (`m³`). The open list shows `Name (symbol)` rows (`Cubic metre (m³)`). Search matches the name, the symbol, and the stored code, so typing `cubic_metre` finds the row. Opening the list without typing shows the whole catalogue, as a native select does. A committed code that is not in the catalogue (for example, while the catalogue loads, or from an offline fallback list) displays through `formatUnit`, never as the raw code.

| Prop | Notes |
| - | - |
| `value` | Required. The committed unit **code** (`"cubic_metre"`), not its symbol. |
| `options` | Required. The unit catalogue as `SettingListOption[]`. `ui` has no API client: load it with `useSettingList` or any other source. |
| `ariaLabel` | Required. |
| `onValueChange` | Required. Called with the chosen unit's code. |
| `featuredValues` / `featuredLabel` | Codes listed a second time at the top, under `featuredLabel` (default `"Most Used"`). |
| `disabled` / `busy` | `busy` stops input but keeps focus, for an entry row that commits one field and moves to the next. |
| `size` / `width` | Control size and fixed width. |
| `onKeyDown` | Receives only keys the list did not consume, so a grid's Enter-commits and Escape-clears still work inside the field. |

Import `UnitCombobox` and `UnitComboboxProps` from `@stralos-co-nz/ui`.

### `useProjectReadiness` and `ProjectPreparing` (0.68.0)

For a page that needs a service-side project record that may not exist yet. The estimate is the first user. Use these two primitives together: `useProjectReadiness` polls the remote and tracks state; `ProjectPreparing` renders every non-ready state and owns the single live region.

```tsx theme={null}
import { classifyProjectReadinessError } from "@stralos-co-nz/domain";
import { ProjectPreparing, useProjectReadiness } from "@stralos-co-nz/ui";

function EstimatePage({ projectId }: { projectId: string }) {
  const { state, data, retry } = useProjectReadiness({
    projectId,
    // Must be a read (GET): called repeatedly and retried after failures.
    load: (signal) => estimateApi.getProjectEstimate(projectId, { signal }),
    classify: classifyProjectReadinessError,
  });

  return (
    <>
      {/* Keep mounted in EVERY state, ready included — it owns the live region. */}
      <ProjectPreparing state={state} feature="estimate" reference={projectId} onRetry={retry} />
      {state === "ready" && data ? <Estimate estimate={data} /> : null}
    </>
  );
}
```

For a section inside a page — a routing notice or a usage panel — pass `variant="inline"`: a compact bordered row with no heading.

**States and service responses:**

| State | Trigger | What happens |
| - | - | - |
| `loading` | First check, or manual retry out of an error state | In flight; `ProjectPreparing` renders silently. |
| `preparing` | `409 project_not_provisioned` | Polled every 5 s. |
| `slow` | Still preparing after 30 s | Same polling; description added. |
| `stalled` | Still preparing after 2 min | Polling continues; reference and Retry shown. |
| `ready` | 2xx — `data` holds the record | Polling stops; `ProjectPreparing` renders nothing. |
| `not-found` | `404 project_not_found` | Page-level `EmptyState` with h1. Terminal until `retry()`. |
| `no-access` | `403 project_access_required` | Page-level `EmptyState`. Terminal until `retry()`. |
| `unavailable` | Network error, 5xx, or unrecognised response | Manual Retry plus automatic retries at 5 → 10 → 20 → 30 s backoff. Not a `preparing` state — the wait clock restarts on recovery. |

**`useProjectReadiness` options:**

* `projectId` — a change aborts the in-flight request and restarts from `loading`.
* `load(signal)` — the remote's own fetch. Must be a GET. Honour `signal` — it is aborted on unmount and when `projectId` changes.
* `intervalMs` — poll interval while preparing. Defaults to 5000 ms, matching the service's `Retry-After: 5`.
* `slowAfterMs` — elapsed wait before `preparing` becomes `slow`. Default 30000 ms.
* `stallAfterMs` — elapsed wait before the state becomes `stalled`. Default 120000 ms.
* `classify(error)` — maps an error `load` threw onto an outcome. Required from 0.73.0 (optional in 0.72.0). Pass `classifyProjectReadinessError` from `@stralos-co-nz/domain` (3.1.0 or later), or wrap it for your own error class. The project error codes (`PROJECT_NOT_PROVISIONED_CODE`, `PROJECT_NOT_FOUND_CODE`, `PROJECT_ACCESS_REQUIRED_CODE`) are exported from `@stralos-co-nz/domain` too; `@stralos-co-nz/ui` deprecated its own classifier in 0.72.0 and removed it in 0.73.0.

`ProjectPreparing` takes an optional `copy(state, feature)` prop returning a `ProjectReadinessCopy` (`{ title, description?, announce }`) to replace the default wording for a state. It replaces the `projectReadinessCopy` export, which 0.73.0 removes.

**Guarantees:**

* Never two requests in flight. `retry()` during a check is a no-op.
* Aborts the in-flight request on unmount and on `projectId` change. A response from a stale project is ignored.
* Pauses while `document.hidden`. On becoming visible it checks once immediately.
* `slow`/`stalled` advance on time alone, so the message changes on schedule even while a slow request is still open.

**Accessibility:**

`ProjectPreparing` renders exactly one `<div role="status" aria-live="polite" aria-atomic="true">`, mounted empty and kept mounted in every state including `ready`. Text is written only on a state change — never on the 5-second tick — and focus is never moved. The spinner is `aria-hidden` and stops under `prefers-reduced-motion`. Do not render a second live region for the same wait.

**Custom error classification:**

```ts theme={null}
import { PROJECT_NOT_PROVISIONED_CODE, classifyProjectReadinessError } from "@stralos-co-nz/domain";

useProjectReadiness({
  projectId,
  load,
  classify: (error) =>
    error instanceof EstimateApiError && error.code === PROJECT_NOT_PROVISIONED_CODE
      ? "preparing"
      : classifyProjectReadinessError(error),
});
```

### `EmptyState`

An empty-state block in two layouts.

* **`layout="inline"` (default)** — a bordered surface panel for empty tables, card grids, and drawers. Renders at the element's natural width.
* **`layout="page"`** — centred both ways inside a `60vh` minimum-height block, with a large glyph above the heading (defaults to `FileQuestion`). Use for a whole-page state such as a 404, a not-found route, or a zero-content page that is the screen's only content.

Props: `title` (required) · `description?` · `icon?` · `action?` · `secondaryAction?` · `layout?` · `headingLevel?`

Pass `headingLevel="h1"` on a `layout="page"` state that is the page's only heading. A 404 page is the canonical example.

`secondaryAction` renders alongside `action` in a wrapping row, centred on `layout="page"` and left-aligned on `layout="inline"`.

### `Dialog` with `actions` prop — rule 5

`Dialog` has an `actions` prop that renders the standard footer: a hairline, Cancel, and exactly one primary, right-aligned.

```tsx theme={null}
<Dialog
  open={open}
  onClose={close}
  size="md"
  title="Add rate item"
  actions={{ primary: { label: "Save item", onClick: save, loading: saving } }}
>
  <FormGrid>…</FormGrid>
</Dialog>
```

Give `Dialog` one of `sm` / `md` / `lg` — never `xl` or a custom `maxW`. A dialog that does not fit `lg` (960 px) is a page, or a form that needs splitting.

`onClick` skips native form validation. For anything with a `<form>`, use `form` instead of `onClick`:

```tsx theme={null}
actions={{ primary: { label: "Send invites", form: "invite-form", loading: sending } }}
…
<form id="invite-form" onSubmit={send}>…</form>
```

`loadingText` is what the button says while `loading`, replacing the label.

`ConfirmDialog` accepts `confirmLoading` and `confirmLoadingText`. Cancel is not rendered while `loading` is true — a confirm already in flight cannot be called back.

**`showCloseButton` (0.61.1).** Pass `showCloseButton={false}` to remove the × button from the dialog header. The header reclaims the 40 px corner the button reserved. Escape and the backdrop still close the dialog — use `suppressDismiss` when the caller needs to own every close path.

An explicit `footer` prop still wins, so no existing dialog changes.

### `ConfirmDialog` — name the consequence

`tone` chooses `neutral` (teal, default), `caution` (amber), or `destructive` (red). Reserve `destructive` for loss that cannot be undone; an archive that can be restored is neutral. The existing `destructive` boolean remains shorthand; an explicit `tone` wins.

Keep the required `message` concise, then use the optional slots to make the decision concrete:

* `subject` — the named object, rendered first in semibold.
* `consequence` — one sentence explaining what confirming does.
* `change: { label?, before, after }` — a before → after line with tabular figures and an accessible "changes to" arrow.
* `cascade` — an array listed under "Also affected"; absent or empty renders nothing.
* `reversibility: { reversible, note? }` — "This cannot be undone." or "You can undo this."; `note` replaces that sentence when a more specific recovery path helps.

```tsx theme={null}
<ConfirmDialog
  open={deleting}
  title="Delete work item?"
  message="Delete this work item from the estimate?"
  subject="Work item · Drainage trench"
  consequence="The work item and its build-up will be permanently removed."
  cascade={["3 activities and their resource lines", "The attached calculator build-up"]}
  reversibility={{ reversible: false }}
  tone="destructive"
  confirmLabel="Delete work item"
  onConfirm={deleteWorkItem}
  onCancel={cancelDelete}
/>
```

### `Dialog` titleEditor — a title renamed in place

`Dialog` takes an optional `titleEditor` prop (`DialogTitleEditorSpec`). With it, the title carries a pencil icon. Clicking the pencil replaces the title text with a field that holds the title, selected whole. Enter or blur saves through `onRename`, Escape reverts the edit and leaves the dialog open, an unchanged name sends nothing, and a blank or refused name leaves the dialog open with a sentence beside the field (`role="alert"`).

```tsx theme={null}
<Dialog
  open
  onClose={close}
  title={calculator.name}
  titleEditor={{
    onRename: (name) => renameCalculator(calculator.id, name),
    fieldLabel: "Calculator name",
    buttonLabel: "Rename calculator",
    failureMessage: "Calculator could not be renamed.",
  }}
>
```

`title` stays the source of the name shown and of the dialog's accessible name in both states. Resolve `onRename` once the new name is held and pass it as the next `title`. A rename never closes the dialog.

### Dialog stacking, close behaviour, and scroll cues

When a second dialog opens above a `Dialog`, the parent dims its header, body, and footer with `bg.scrim` and hides its close button from sight, focus, and assistive technology. Closing the child restores the parent. Escape and a backdrop click act on the top layer only.

**Close-latch behaviour (0.54.0).** The header ×, Escape, the backdrop, and Cancel share an idempotent close-latch that prevents one gesture firing two dismissals. In earlier versions the latch was only released when `open` changed, so a caller that declined `onClose` while busy (for example, a save still in flight) left all four dismissal routes dead until the dialog was reloaded. The latch is now released a macrotask after a clean close and on Discard, and it is never armed while `suppressDismiss` is set. The same fix applies to `ConfirmDialog`'s cancel latch.

**`ConfirmDialog` dismiss survivability (0.61.1).** A `ConfirmDialog` is now safe to raise from an `onSelect` callback in a combobox or similar control. Two previously problematic routes are closed:

* **Focus landing outside.** A combobox raised from its own `onSelect` closes its list and restores focus to its input, which is outside the confirm. Previously that focus movement was treated as an outside interaction and cancelled the confirm. From 0.61.1, only a pointer-down outside cancels the confirm. Focus leaving the overlay does not.
* **A layer below closing.** When the confirm is opened by a gesture that itself owns an overlay layer (such as a combobox's dropdown), the combobox tears down its layer after raising the confirm. Previously that teardown cascaded into the confirm and dismissed it with no user gesture at all. From 0.61.1 the confirm ignores teardown events from layers below it; Escape, a backdrop pointer-down, and the host's controlled actions remain its close routes.

With `scrollBehavior="inside"` and `bodyLayout="scroll"` (the defaults), the header casts a soft shadow when content remains above and the footer when content remains below. The cues update on scroll and resize. A body that fits has neither cue. Pass `bodyLayout="panes"` to leave scrolling to the body's own columns; pass `scrollBehavior="outside"` to let the page scroll instead. Neither emits shadows.

### `SideSheet` — one record, beside the list it came from

`SideSheet` is `Dialog`'s contract anchored to an edge, for editing or inspecting one record picked from a list, board or gantt that stays visible beside it. It shares `Dialog`'s close machinery (`DialogActions`, `DialogCancelButton`, and `useDialogClose` all work inside it unchanged) and adds `placement` (`end` by default, or `start`), `size` (`sm` 400px, `md` 480px, `lg` 640px, `xl` 720px, `2xl` 90vw, `3xl` 1280px capped at the screen width less 4rem), `headerActions` for record-stepping controls beside the title, `insetTop` to open below the app bar, and `modal`.

Which overlay to use — take the first row that fits:

| If the person is… | Use |
| - | - |
| Saying yes or no to one consequence (≤ 1 input) | `ConfirmDialog` |
| Doing a short blocking create or edit, and what is behind does not matter | `Dialog` `sm` / `md` |
| Editing or inspecting one record picked from a list, board or gantt that still matters | `SideSheet` |
| Working for minutes: sections, tables, internal tabs, anything worth a URL | A full page or route |
| Changing one field, making one choice, filtering, or reading help | A popover, inline edit or menu |

**A form sheet is `lg` (640px).** Every create/edit form moves to a sheet. Pass `size="lg"` so no form ends up narrower than the 640px centred modal it replaces. `sm` (400px) and `md` (480px) are for inspect-and-light-edit panels beside a list — where-used, evidence, hire costs — not for forms. The component default stays `md`, so a form states its size:

```tsx theme={null}
<SideSheet
  open={adding}
  onClose={() => setAdding(false)}
  title="Add project rate"
  size="lg"
  recordName={name || "New project rate"}
  confirmOnDirtyClose
  isDirty={dirty}
  actions={{ primary: { label: "Add rate", form: "project-rate-form" } }}
>
  <form id="project-rate-form" onSubmit={save}>
    <FormGrid>…</FormGrid>
  </form>
</SideSheet>
```

> [!NOTE]
>
> Creating a related record part-way through a form (a supplier, a location) wants a step inside the sheet with a Back, not a second overlay on top. There is no stepped primitive yet — treat it as a gap rather than a licence to stack overlays.

**`modal="responsive"` is non-modal from `md` up.** No backdrop, no scroll lock, the page behind stays live and clickable, and the sheet captures and restores focus itself. An outside pointer never closes it and never raises the discard confirm — "outside" a non-modal sheet is the board the person is working on. Escape closes it whatever the focus position. Below `md` it is full-screen and modal regardless of the `modal` prop, and the modality is fixed when the sheet opens: zag starts its focus trap, scroll lock, and `aria-hidden` sweep on that frame and does not re-run them, so a sheet that changed modality on a resize would keep half of the old behaviour. Re-opening picks up the new width.

**Beside the open assistant, every sheet is non-modal from `md` up (0.73.1).** A sheet that opens while the assistant's chat panel is open behaves as `modal="responsive"` for that opening, whatever `modal` says. The panel moves to sit beside the sheet, and a modal sheet would leave it visible but unusable — behind the backdrop, outside the focus trap. The dirty-close guard still holds on ×, Escape, and any host-guarded close. The assistant is recognised by its published contract alone: an open, `data-modal="false"` dialog content element inside `[data-stralos-remote="assistant"]` (ui-assistant 0.4.0 or later). This is read once when the sheet opens and held for that opening.

**A host that closes the sheet or swaps its record must go through the guard.** Setting `open={false}` bypasses `confirmOnDirtyClose` entirely — the guard only wraps the sheet's own dismissal routes. Take a ref and ask:

```tsx theme={null}
const sheet = useRef<SideSheetHandle>(null);

<SideSheet
  ref={sheet}
  open={taskId !== null}
  onClose={() => setTaskId(null)}
  title={task.name}
  recordName={task.name}
  confirmOnDirtyClose
  isDirty={dirty}
  headerActions={
    <Button onClick={() => sheet.current?.requestReplace(() => setTaskId(nextId))}>
      Next
    </Button>
  }
>
```

`requestReplace(next)` runs `next` once the guard is satisfied — switching records, or handing over to another overlay — instead of closing. `requestClose()` is the sheet's own × from the host's side. Both do nothing while `suppressDismiss` is set.

A non-modal sheet publishes `data-modal="false"`. The layer gate only counts modal layers as blocking, so a non-modal sheet never holds another remote's dialog back.

### `DialogActions` — outside a dialog

`DialogActions` renders the standard footer as a standalone component. Cancel calls `onCancel`, and is not rendered when there is no `onCancel`.

The component is `DialogActions`, not `DialogFooter`. Chakra already exports `DialogFooter` through this barrel; shadowing it would be a silent breaking change.

**`wrap` — narrow-screen button wrapping.** Pass `wrap` on any dialog or standalone footer whose buttons may not all fit on one line. Without it, buttons overflow on narrow drawers or small viewports. The assignment drawer uses it to keep its actions visible at any width.

```tsx theme={null}
actions={{ primary: { label: \"Save\", onClick: save }, wrap: true }}
```

**`destructive` — split footer for destructive verbs.** Pass any `ReactNode` to `destructive` and it renders hard left, splitting the footer with the rest of the actions on the right. Use this for actions that destroy the thing the dialog is editing (for example, "Delete assignment" or "Cancel booking"). The split keeps a destructive verb away from the dialog's own Cancel button, which sits beside the primary.

```tsx theme={null}
actions={{
  primary: { label: \"Save changes\", onClick: save },
  destructive: (
    <Button variant=\"ghost\" colorPalette=\"red\" size=\"sm\" onClick={onDelete}>
      Delete assignment
    </Button>
  ),
}}
```

`destructive` is for secondary destructive verbs only. A primary destructive action (a dialog whose whole purpose is deletion) uses `primary.destructive: true` to render the sole primary button in red.

### `SideSheet`

A side sheet is anchored to an edge and keeps the list, board, or gantt it came from visible beside it. Use one when the thing behind still matters. Use a centred `Dialog` for a short blocking create or edit; use a full page when the work takes minutes.

`SideSheet` shares the same contract as `Dialog`: `DialogActions`, `DialogCancelButton`, `useDialogClose`, `DialogTitleEditor`, `ConfirmDialog`, and `Combobox` all work inside it without changes.

**Props:**

`open` · `onClose` · `title` · `titleEditor?` · `description?` · `headerActions?` · `children` · `footer?` · `actions?` · `size?` · `placement?` (`"end"` default) · `modal?` · `insetTop?` · `initialFocusEl?` · `confirmOnDirtyClose?` · `isDirty?` · `recordName?` · `suppressDismiss?` · `steps?` · `ref?` (`SideSheetHandle`)

**Size.** `sm` 400 px · `md` 480 px (default) · `lg` 640 px · `xl` 720 px · `2xl` 90vw · `3xl` `min(1280px, calc(100vw - 4rem))`. A form sheet is `lg`. `xl` is opt-in for screens measured too cramped at 640. Two were: the rates option editor (around 14 fields plus the advanced pricing editor) and Similar work items (where every match title wrapped to three lines at 640 and the overlay grew to 900 px). `2xl` is the ruled exception for wide compact tables, not a wider `xl`: the estimate schedule import is a compact one-row-per-item table that measured too narrow at 720 px and needs around 1,100 px or more. It is relative (90vw) so it always leaves a strip of the screen visible behind it and never overflows the viewport. `3xl` (0.80.0) is twice `lg` and is ruled for one screen: the Schedule review findings, long prose cards an estimator reads while the schedule stays in view. It is a fixed width rather than relative, so a large screen still shows the schedule behind it, and it never comes closer than 2rem to either edge of the screen. A form is never `2xl` or `3xl`.

**`SideSheetHandle`.** The `ref` gives the host two guarded imperative calls:

* `requestClose()` — closes through the dirty-close guard, as the × does. Does nothing while `suppressDismiss` is set.
* `requestReplace(next)` — runs `next` once the guard is satisfied, instead of closing. Use this for any hand-off that would otherwise bypass `confirmOnDirtyClose` by setting `open={false}` directly.

#### Mid-form create: steps inside a sheet

When a form needs a record that does not exist yet — a supplier while adding a rate — open it as a step inside the current sheet, not as a second sheet on the same edge. A second sheet stacks two footers, two × buttons, and leaves the outer form behind a scrim; a step swaps the frame in place and keeps a way back.

Pass `steps` as an array of `SideSheetStepSpec`, outermost first, empty at the root. The host owns the array because it already owns the state that decides it, and that is what keeps a step's `actions`, `isDirty`, and primary `loading` live rather than frozen at push time.

```tsx theme={null}
<SideSheet
  open={adding}
  onClose={close}
  title="Add project rate"
  size="lg"
  confirmOnDirtyClose
  isDirty={dirty}
  recordName={name}
  actions={{ primary: { label: "Add rate", form: "project-rate-form" } }}
  steps={
    creator === "supplier"
      ? [
          {
            title: "New supplier",
            recordName: draft.name || "New supplier",
            isDirty: draft.name !== "",
            onBack: () => setCreator(null),
            actions: {
              primary: { label: "Create supplier", form: "supplier-form", loading: saving },
            },
            children: (
              <SupplierForm
                id="supplier-form"
                value={draft}
                onChange={setDraft}
                onSave={create}
              />
            ),
          },
        ]
      : []
  }
>
  <ProjectRateForm id="project-rate-form" … />
</SideSheet>
```

**`SideSheetStepSpec` props:** `title` · `description?` · `children` · `footer?` · `actions?` · `isDirty?` · `recordName?` · `onBack` · `backLabel?` · `initialFocusEl?`

**What the sheet manages automatically:**

* One overlay at any depth. The root form and each step stay mounted and hidden when not active, so uncontrolled fields and scroll position survive the detour.
* The title, accessible name, breadcrumb trail, and footer always reflect the active frame.
* `headerActions` and `titleEditor` from the root are hidden while a step is open.
* Cancel and Escape leave the step; the × and backdrop leave the sheet. `DialogCancelButton` inside a step backs out of the step, not the sheet.
* A dirty step always prompts before it is left, whether or not the sheet has `confirmOnDirtyClose`. Closing the sheet over a dirty step asks once.
* Focus moves into the step on open and returns to the control that triggered it on back. A polite live region announces the transition to assistive technology.

**Returning the created record.** When the step's save handler resolves, write the record, select it into the field that needed it, and remove the step from the array. The host's own save handler already has this path.

The live region (`role="status"`) is mounted for the sheet's whole life, but only when `steps` is passed at all. A plain sheet — one with no `steps` prop — carries no `role="status"` element. This is a structural gate: inserting a live region in the same commit as its first announcement would silence that announcement, which is the whole reason it mounts early. A screen that carries its own status region is unaffected by a sheet that does not use steps.

### `RichTextEditor` and `RichTextView` (0.79.0)

`RichTextEditor` is a small controlled editor on tiptap 3: paragraphs, a heading (H2), a subheading (H3), bulleted and numbered lists, line breaks, and bold, italic and underline. Nothing else is kept, typed or pasted: pasted links, images, tables, colours and code lose their formatting and keep their text, because the schema is built only from those extensions. The vocabulary matches what `stralos-api-global` validates, which answers `422` for anything outside it. The value is a `RichTextDoc` (tiptap JSON), and an empty document is reported as `null`.

```tsx theme={null}
<FormField label="Project scope" span="full">
  <RichTextEditor value={scope} onChange={setScope} placeholder="Describe the project scope…" />
</FormField>
```

| Prop | Notes |
| - | - |
| `value`, `onChange` | Required. The stored document, or `null` when it holds no text. |
| `readOnly` | Shows the document without the toolbar and prevents editing. Defaults to `false`. |
| `placeholder` | Shown while the document is empty. |
| `invalid` | Marks the editor as in error. Inside a `FormField`, the field's error sets it. |
| `minHeight` | Minimum height of the writing area. Defaults to `"8rem"`. |
| `aria-label`, `aria-labelledby`, `aria-describedby` | Name and describe the editor outside a `FormField`. Inside one, the field's label, helper, error and required state are wired automatically. |

The toolbar is a WAI-ARIA toolbar: one tab stop, with the arrow keys moving between buttons. Each button names its shortcut in a tooltip and `aria-keyshortcuts`, using ⌘ on Apple platforms and Ctrl elsewhere: **Bold** Ctrl+B, **Italic** Ctrl+I, **Underline** Ctrl+U, **Heading** Ctrl+Alt+2, **Subheading** Ctrl+Alt+3, **Bulleted list** Ctrl+Shift+8, **Numbered list** Ctrl+Shift+7.

`RichTextView` renders a stored document read-only as plain HTML elements, without loading the editor. It tolerates documents from older or newer releases: unknown nodes are skipped and their text still shows. Pass `emptyText` to show something when the document is empty; without it, an empty document renders nothing.

```tsx theme={null}
<RichTextView value={project.scope} emptyText="No scope recorded." />
```

Helpers, all exported from `@stralos-co-nz/ui` with the `RichText*` node types:

* `RICH_TEXT_MAX_CHARACTERS` (50,000): the text limit shared with api-global's validator.
* `richTextCharacterCount(doc)`: characters of text; line breaks and block boundaries are not counted.
* `isRichTextEmpty(doc)`: true for `null` or a document whose text is only whitespace.
* `richTextEquals(a, b)`: compares documents ignoring key order (a `jsonb` round trip reorders keys); every empty document equals `null`.

### `FormGrid` and `FormField span` — rule 5

A twelve-column grid. Each field takes `span="short"` (4), `"medium"` (6), `"full"` (12), or a number. A field with no span takes the whole row.

```tsx theme={null}
<FormGrid>
  <FormField label="Category" span="medium" required>…</FormField>
  <FormField label="Unit" span="medium" required>…</FormField>
  <FormField label="Item name" span="full" required>…</FormField>
</FormGrid>
```

Use instead of a `<Stack>` of fields with per-field `maxW` values.

### `LocationPicker`

Use `mapDisplay="collapsible"` in dialogs or forms where location is secondary. Address, City, and Region stay visible. The map and coordinates mount on demand when the disclosure is expanded, with a pin summary while collapsed. Invalid coordinates open the section automatically.

Pages that focus on a location can use the compatible default, `mapDisplay="inline"`.

### `RateLocationPicker` — whose price this is

`RateLocationPicker` is a single field for saying whose price a rate is: the organisation's or a supplier's, everywhere or at one location. It is shared by the Rates Library and the Add Project Rate sheet. Import it from `@stralos-co-nz/ui-reference-data` (0.3.0 or later). It moved out of `@stralos-co-nz/ui`, which deprecated its copy in 0.72.0 and removed it in 0.73.0; the component and its types keep the same names, so moving is an import-path change.

It emits `{ scope, supplierId, locationId }` — the same triple `rates_rate_options` stores — so nothing between this control and the database row has to re-derive ownership.

```tsx theme={null}
<RateLocationPicker
  ariaLabel="Price location"
  organisationName={org.name}
  organisationLocations={orgLocations}
  suppliers={suppliers}
  value={selection}
  onSelect={setSelection}
  actions={isAdmin ? [
    { label: "Add location", onSelect: openLocationCreator },
    { label: "Add supplier", onSelect: openSupplierCreator },
  ] : []}
/>
```

**Props:**

| Prop | Default | Purpose |
| - | - | - |
| `organisationName` | Required | The organisation's display name. Supplied on every render — never stored. The shell owns it and refreshes it on rename. |
| `organisationLocations` | `[]` | The organisation's own places. The "All locations" row still appears even with none. |
| `suppliers` | `[]` | Companies we buy from. Rendered after the organisation, alphabetically. |
| `value` | Required | The committed selection, or `null` for nothing chosen. |
| `onSelect` | Required | Called with `RateLocationPickerSelection` on every pick. |
| `actions` | `[]` | Create affordances rendered as links below the field. Pass none and a non-admin sees no offer. |
| `allLocationsLabel` | `"All locations"` | The row each group leads with. |

**`RateLocationPickerSelection`** is what a pick emits:

```ts theme={null}
type RateLocationPickerSelection = {
  scope: "organisation" | "supplier";
  supplierId: string | null; // always null under scope "organisation"
  locationId: string | null; // null means every location the owner has
};
```

**Create affordances are links below the field, not rows in the list.** They are visible without opening the list, a keyboard walk through the places cannot land on one, and the caller decides which appear. Passing no `actions` is how a non-admin sees no offer.

**The organisation's name is a prop and is never stored.** `scope: "organisation"` means ours. The old model recorded ownership twice and a place could appear under two companies at once because the dropdown read the stale copy. A stored name is also what made the old field render "Internal (Internal)" and survive an organisation rename unchanged.

**Unknown values go blank.** A token the list no longer offers — a retired location, or one that changed owner — is held but shows blank rather than a raw uuid. The caller's value is untouched.

The component returns a fragment: the field plus its action links. Wrap it in a `FormField` to attach a label; the label names the `input`, not the wrapper.

```tsx theme={null}
<FormField label="Whose price is this?" span="full">
  <RateLocationPicker … />
</FormField>
```

### Shared rate reference forms

Rate workflows such as the Rates Library import and the project-rate picker share a location form and a supplier form. The rates feature builds both in `RateCreationSheets.tsx` as `LocationCreationSheet` and `SupplierCreationSheet`, and re-exports them through `RateReferenceModals.tsx`. The location sheet renders the map search with `LocationPicker` from `@stralos-co-nz/maps-ui`. API writes remain with the caller; the sheets handle UI state, validation, and the unsaved-changes guard.

> [!NOTE]
>
> `SupplierEditorModal` moved to `@stralos-co-nz/ui-reference-data` 0.1.0 (base UI 0.57.0, breaking). Import it from there. `@stralos-co-nz/maps-ui` 0.10.0 removed `LocationEditorModal`, `rateSupplierChoices`, and `RateReferenceSource`; no production screen rendered them. Build location forms from `LocationPicker` instead.

**`LocationEditorValues`** is the location form state shape. It lives in `@stralos-co-nz/maps-ui` (0.5.0 or later); `@stralos-co-nz/ui` removed its copy in 0.73.0:

```ts theme={null}
type LocationEditorValues = {
  id?: string;
  /** Who owns the place: a parties.id, or null for ours. */
  partyId?: string | null;
  name: string;
  kind: "office" | "yard" | "depot" | "branch" | "quarry" | "other";
  addressLine1: string;
  latitude: string;
  longitude: string;
  city: string;
  region: string;
  status?: "active" | "inactive";
  countryCode?: string;
};
```

`partyId` is a `parties.id` for a supplier, or `null` for the organisation's own location. `locations.party_id` is the only ownership fact; recording ownership twice was the defect where a place could appear under two companies at once. maps-ui 0.10.0 removed the deprecated `rateSourceId` field.

Both sheets guard unsaved changes: closing one when the form is dirty triggers the standard **Discard changes?** prompt rather than closing immediately.

### `ColourPickerField`

A colour picker combining a swatch row and a hex input.

`value: string` · `onChange(hex: string)` · `swatches?: readonly string[]` · `noneLabel?: string` · `disabled?` · `inputAriaLabel?`

The `None` choice is always first and clears the value to `""`. The selected swatch shows a ring outline and a tick. The hex input always shows the stored value; it has no placeholder. Wrap in a `FormField` to attach a label; pass `inputAriaLabel` when the input sits outside a `FormField`. Helpers `colourFieldError` and `isHexColour` live in `@stralos-co-nz/ui`.

```tsx theme={null}
<FormField label="Schedule colour">
  <ColourPickerField
    value={form.colour}
    onChange={(hex) => setField("colour", hex)}
    noneLabel="None (automatic)"
  />
</FormField>
```

Use instead of hand-copied swatch rows with no selected state. Do not render a custom `ColourPickerField` in fleet, workforce, or project-settings — the shared component handles all three.

### `ColourSwatch`

A small coloured square for displaying a stored hex colour inline.

`colour: string | null | undefined` · `label: string` · `size?: "sm" | "md"`

Renders only when `colour` is a valid `#rrggbb` value. Carries `role="img"` and an `aria-label` naming the colour's purpose and value, for example `Schedule board colour: #3b82f6`. Use `label` to describe the purpose, not the hex — the component appends the hex itself.

Use inside `RegisterNameLink` (via `colour` / `colourLabel` props) and anywhere a scheduler-rail-style coloured marker is needed.

### `RegisterPage`, `RegisterNameLink`, `registerOpenColumn`

Shared structure for the eight register pages (fleet, workforce, rates references, and so on).

**`RegisterPage`** renders a `SectionCard` with a `PageHeader` and count inside the card, a `Toolbar` where search always comes before filters, an optional notice band, and the list edge to edge. On narrow screens the toolbar wraps: search and filters move to a second line rather than overflowing.

Props: `title` · `count?` · `subtitle?` · `status?` · `actions?` · `search?: { value, onChange, label, placeholder? }` · `filters?` · `toolbarEnd?` · `activeFilterCount?` · `secondaryActions?` · `tabs?` · `notice?` · `children` · `scroll?: "own" | "page"` · `scrollLabel?`

The scrolling region is named after `title` automatically when it is a string (0.70.0). Pass `scrollLabel` when `title` is a React node rather than a plain string — for example, when it wraps an icon or a status chip.

**`RegisterNameLink`** renders a record's name as a navigation link or a link-styled button. Pass `to` for a direct URL; pass `onOpen` when the record opens in a dialog. An optional `colour` prop renders a `ColourSwatch` beside the name; `secondary` adds a muted second line.

**`registerOpenColumn`** is the single standard "Open" row action. It returns a `DataTableColumn` whose header is empty and whose action is labelled `"Open {rowLabel}"` for assistive technology.

A company/location-style register — one whose rows open a drawer editor — keeps **both** `registerOpenColumn` and a `rowActions` kebab. The **Open** button is the discoverable affordance on the row; the kebab is where the estate's other registers put row verbs, so a person who learnt the gesture on another register finds it here too. Pass `getRowLabel` whenever the first column renders a link rather than plain text — without it, the kebab's accessible name falls back to the row id.

Registers that rely on `RegisterNameLink` with an `onOpen` prop as the sole access point do not need `registerOpenColumn` or `rowActions`.

```tsx theme={null}
<RegisterPage
  title="Fleet"
  count={fleet.length}
  search={{ value: q, onChange: setQ, label: "Search fleet" }}
  actions={<Button size="sm">Add vehicle</Button>}
>
  <DataTable
    columns={[nameCol, typeCol, registerOpenColumn({ onOpen: openEdit, getRowLabel: (r) => r.name })]}
    rows={fleet}
    getRowId={(r) => r.id}
    getRowLabel={(r) => r.name}
    rowActions={(r) => [{ label: "Edit", onSelect: () => openEdit(r) }]}
  />
</RegisterPage>
```

### `editedFieldProps` — unsaved changes

A style object spread onto any Chakra control that takes style props. Teal border and `bg.selected` ground, plus `data-edited="true"`.

```tsx theme={null}
<Input value={rate} onChange={edit} {...(dirty ? editedFieldProps : {})} />
```

### `SelectCombobox onCreate` — making the record you are picking

Pass `onCreate` for a one-field mid-form create: a supplier while adding a rate, a client while adding a project. The record is made inside the picker — never in a second overlay over the form. Two to eight fields belong in a `SideSheet` step instead.

```tsx theme={null}
<SelectCombobox
  ariaLabel="Supplier"
  value={sourceId}
  choices={supplierChoices}
  onCreate={(name) => void createSupplier(name).then((source) => setSourceId(source.id))}
  createLabel={(name) => `Create supplier "${name}"`}
  onSelect={(choice) => setSourceId(choice.value)}
/>
```

* Opt-in and inert without it. No `onCreate`, no row, and the control is otherwise unchanged.
* `onCreate` receives the trimmed query — the seed for the new record's name. The hand-rolled alternative opens an empty field and asks the person to type it again.
* The row appears only while something is typed that is not already an exact match, so it cannot offer to duplicate the row above it.
* `onSelect` is never called for the create row. Creating is not choosing. Perform the write, then select the new record by setting `value`.

**The standing offer — `createEntries`**

Use `createEntries` for rows that must be offered before anyone types — the "Create new supplier…" entry, or a record with no name to seed. Each entry carries its own handler; nothing is recognised by token value.

```tsx theme={null}
<SelectCombobox
  ariaLabel="Price source"
  value={sourceId}
  choices={sourceChoices}
  createEntries={[
    { label: "Create new supplier…", onCreate: startSupplier },
    { label: "Add our own price source…", onCreate: startInternal },
  ]}
  onSelect={(choice) => setSourceId(choice.value)}
/>
```

Standing entries are never filtered out — an offer that disappears when you type is not standing. `onSelect` is not called for them either.

Use both on the same control when they mean different actions. Typing "Fulton" can reasonably show `Create "Fulton"` alongside "Add our own price source…".

See [SelectCombobox inline record creation](https://help.stralos.app/internal/combobox.md#inline-record-creation) for the full prop reference.

### `DateField` and `parseDateInput` — rule 6

A controlled date input. Displays `7 Sep 2026`, reads and writes `yyyy-mm-dd`, accepts `7/9/2026`, `7-9-2026`, `7 Sep 2026`, and `2026-09-07` typed, commits on blur and Enter, reverts and reports `aria-invalid` on text that is not a date.

`value: string | null` · `onChange(iso | null)` · `placeholder?` · `min?` · `max?` · `pickerLabel?` · every Input prop

Use instead of `<Input type="date">`. A native date input renders the browser's locale.

`MoneyInput` is the input for new money fields: numeric `value` / `onChange`, right-aligned currency prefix, grouped two-decimal display on blur, and an optional `unit` suffix (for example `/t`). `CurrencyInput` keeps its existing raw-string contract.

### `format` helpers — rule 6

Pure, `en-NZ`, no React. Import from `@stralos-co-nz/ui` — there is no `/format` subpath.

| Function | Renders |
| - | - |
| `formatDate(input)` | `7 Sep 2026`, or `""` |
| `formatDateTime(input)` | `7 Sep 2026, 12:04 pm` |
| `formatMoney(value, opts?)` | `$1,234.56`, falling back to `—` |
| `formatNumber(value, opts?)` | Grouped en-NZ number, up to two decimals by default |
| `formatPercent(0.15)` | `15.0 %` — the input is a fraction |
| `formatUnit("square_metre")` | `m²`; covers all unit-catalogue codes; an unknown code is returned unchanged |
| `formatQuantity(60, "metre")` | `60 m` |
| `formatPerUnit("m3", "m")` | `m³/m`; either half may be absent |
| `formatDimensions([0.6, 1.2], "metre", opts?)` | `0.6 m × 1.2 m` |
| `formatCount(count, singular, plural?, opts?)` | `1,200 items`; zero is plural |
| `pluralize(count, singular, plural?)` | Noun only; 1 and -1 are singular |
| `formatStatusLabel(value, opts?)` | `Estimates received` (wraps domain's) |
| `DATE_INPUT_EXAMPLE` | `e.g. 7 Sep 2026` |

`formatUnit` covers all 27 unit-catalogue codes: `millimetre`, `metre`, `kilometre`, `length`, `roll_length`, `square_metre`, `sheet`, `roll_area`, `hectare`, `cubic_metre`, `litre`, `kilolitre`, `megalitre`, `kilogram`, `tonne`, `each`, `number`, `set`, `pair`, `lot`, `item`, `lump_sum`, `provisional_sum`, `hour`, `day`, `week`, and `month`. It also resolves common aliases through `canonicaliseUnit` — for example `m3` → `m³`, `lm` → `m`, `ls` → `LS`, `cube` → `m³`. Four legacy spellings not in the catalogue (`centimetre`, `percent`, `cubic_meter`, `square_meter`) are handled separately and still resolve to their symbols. A code not in this set is returned unchanged.

Every formatter takes `null | undefined`. A screen never has to guard a nullable field before formatting it.

The empty value is `—` (`EMPTY_VALUE`) in a list and `"Not set"` in a form (used by `CurrencyAmount`). Both take a `fallback` to override: `formatMoney(v, { fallback: "Not set" })` for money inside a form.

Use instead of `toLocaleDateString()`, `Intl` calls in consumer code, and hand-written `$${value.toFixed(2)}` templates.

### `notify` — outcome feedback

Three levels, one shape. Both `title` and `message` are required on every level.

| Level | For |
| - | - |
| `notify.success` | It worked and it is finished. |
| `notify.error` | It did not work. |
| `notify.info` | Work accepted and still running, or a state to know about before the result lands. |

```tsx theme={null}
notify.info({
  title: "Recalculating the estimate",
  message: "The totals will update when the calculation finishes.",
});
```

`notify.info` exists because in-flight states were being emitted as `notify.success` (claiming something finished that had not) or as `notify.error` (claiming something broke that had not).

**`action`** puts one follow-up on the toast: `{ label: "Undo", onClick }`. One action only — a choice between several things belongs in a `ConfirmDialog`.

A blank `message` renders the title alone without throwing. A blank `title` throws — the title is written in code and the throw lands on the developer who wrote it.

A toast raised while a modal `Dialog`, `SideSheet`, or `ConfirmDialog` is open is spoken by screen readers from inside the overlay. The overlay mounts two empty live regions (`aria-live="polite"` and `aria-live="assertive"`) from first render, and the toaster copies each toast's text into the topmost open overlay's region. Success, info, and loading toasts go to the polite region; error toasts go to the assertive region. With no modal overlay open, nothing is copied and the toast is audible where it is.

The visible toast renders outside the overlay and is `aria-hidden` to the screen reader — the live regions inside the overlay are the only announcement path. Anything the person must answer before continuing goes in a `ConfirmDialog`, not a toast.

**The overlay live regions carry no `role`.** `role="alert"` or `role="status"` would make `getByRole("alert")` and `getByRole("status")` queries inside consumer tests ambiguous whenever a toast fills the region. The live values are written out as explicit `aria-live` and `aria-atomic` attributes. Do not add a role to these regions.

`notifyValidationErrors({ invalidCount })` is a shorthand for `notify.error` when a save is blocked by invalid fields. Pass the result of `validateAll()`:

```ts theme={null}
const result = validation.validateAll();
if (!result.valid) return notifyValidationErrors(result);
```

It emits "Can't save yet" with a message counting the invalid fields and noting the first one is highlighted.

See [Form feedback pattern](https://help.stralos.app/internal/form-feedback-pattern.md) for the full `notify`, `Dialog`, `SubmitButton`, and form validation API.

### `useUndoAction` and `UndoActionBanner` — undo instead of confirm

Use these when a change is cheap to reverse and the person does it often — archiving a rate sheet, removing an activity. The caller applies the change immediately and shows a brief inline receipt; the actual write to the server runs only when the window elapses.

```tsx theme={null}
const { pendingAction, run, clear } = useUndoAction();

// In the row handler:
run({
  label: "Archived 3 rate sheets",
  impact: "−$1,240.00",
  onCommit: () => archiveOnServer(ids),
  onUndo: () => restoreLocally(ids),
});

// In the layout, once per view:
<UndoActionBanner action={pendingAction} />
```

The window stays open for 6 seconds by default. An unmount commits immediately. A second `run` call commits the first before starting the next.

**When to use undo instead of confirm:**

* The change is on-screen already (the person can see what happened).
* The person does this action repeatedly.
* Reversing the write is straightforward.

**When to use `ConfirmDialog` instead:**

* The change cascades to records another person may see during the window.
* The action cannot be reversed.
* The consequence is large enough that pausing is appropriate.

`UndoActionSpec` props: `label` · `impact?` (the size of what changed, shown beside the label so the person knows whether they just removed \$12 or \$12,000) · `onCommit` · `onUndo` · `windowMs?`

### `SubmitButton` — `aria-disabled`, not `disabled`

The submit control: a spinner while `isSaving`, and no double submit.

`isSaving` · `disabled?` · `type?` (`"submit"` by default) · anything a `Button` takes except `loading` and `disabled`

Blocked is `aria-disabled`, not a real `disabled`. A real `disabled` takes the button out of the tab order at the moment it matters most. `aria-disabled` keeps focus where it was.

In tests, `button.disabled` is always `false`. Assert `aria-disabled="true"` instead; neither `toBeDisabled()` nor the `disabled` property sees it. A `waitFor(() => expect(button.disabled).toBe(false))` before a click resolves immediately and then clicks a refused button.

### `CommandPalette` — Cmd+K (0.60.0)

```tsx theme={null}
const [open, setOpen] = useState(false);
<CommandPalette
  open={open}
  onOpenChange={setOpen}
  items={[{ id: "new-project", label: "New project", group: "Create", onSelect: newProject }]}
/>;
```

Built on the same Chakra `Dialog.Root` as every other overlay — its focus trap, `aria-modal`, backdrop, and portal. The field owns the list's keyboard through `aria-activedescendant`, so arrow keys move the highlight while typing keeps filtering. Enter runs the highlighted command. Escape closes.

* **No Stralos vocabulary.** The host passes `items` and owns every word. The default filter (0.60.1) needs every word of the query as a substring of the label, a string description, the group, or a keyword. It ranks results: label start, then a later label word, then the label anywhere, then description, then group or keywords. Groups are ordered by their best-ranking row. Before 0.60.1 the filter was a subsequence match that answered "rates" with "Construction Activities". Pass `filter` to replace it with a scoring function or a server search.
* **A match is always explained** (0.60.1): matched text is wrapped in `<mark>`, and a row found only through an invisible `keywords` entry says `Matches: <keyword>`. A caller-supplied `filter` receives no marks.
* **The list is a bounded scroller** (0.60.1): `min(400px, 60vh)`, scroll-edge shadows, the active option kept in view, and a footer with key hints (↑↓ / Enter / Esc) and the result count. From 0.61.1, the cap snaps down to the last whole-row boundary so no row is cut in half at the fold. A bottom fade appears over the last visible row while more rows remain below.
* The palette **closes before the command runs**, so a command may open a dialog of its own.
* The hotkey does **not** fire while focus is in a text field — Ctrl+K is delete-to-end-of-line there. Pass `hotkey={false}` where the host binds its own opener.

`CommandPaletteItem` props: `id` · `label` · `description?` · `group?` · `keywords?` · `shortcut?` · `disabled?` · `onSelect`

### `ResourceScheduler` — `maxHeight`

The `maxHeight` prop was previously hard-capped at `70vh` with no override. Two full-page boards stopped at 70% of the window with their own scrollbar while the page kept the rest as dead space.

`maxHeight` now takes anything a CSS `max-height` takes and still defaults to `"70vh"`. Two shapes work:

* `maxHeight="calc(100vh - 180px)"` — fills the viewport under whatever chrome sits above it, keeping the grid's own scroll and sticky header rows. Prefer this.
* `maxHeight="none"` — makes the grid as tall as its rows and lets the page scroll. Simpler, but header rows scroll away.

### `SelectableRow` and `SelectableRowList`

`SelectableRow` renders a choose-one button with a title, optional description, optional leading content, optional amount, and an optional disabled reason. `SelectableRowList` wraps a set of `SelectableRow` components in `list` / `listitem` semantics with hairline separators between rows.

Use for a small set of mutually exclusive choices presented as cards rather than a dropdown — for example, choosing a calculator type or a subcontract package basis.

### `SegmentedControl`

Use `SegmentedControl` for one choice among a short set of settings. Use Tabs to switch content panels, and a `Switch` for an independent on/off setting.

```tsx theme={null}
<SegmentedControl
  aria-label="Win rate window"
  value={window}
  onValueChange={setWindow}
  options={[
    { value: "30d", label: "30d" },
    { value: "90d", label: "90d" },
    { value: "12m", label: "12m" },
  ]}
/>
```

Supply `aria-label` or `aria-labelledby` for the group. An option's `ariaLabel` gives a terse label to a value such as `0`: for example, `Normal text size`. `value={null}` leaves every option unchecked. Disabled options never call `onValueChange`. The default `sm` is 32 px tall; `xs` is 24 px.

### `CommandPalette` — Cmd+K / Ctrl+K

`CommandPalette` is a keyboard-first search overlay backed by Chakra's `Dialog`. It combines a combobox (fuzzy-match on label, group, and keywords) with the same focus trap, `aria-modal`, backdrop, and portal as every other overlay in the suite.

The palette ships no commands of its own — the host passes items. Each item has an `id`, `label`, optional `description`, `group`, `keywords`, `shortcut` (decoration only), `disabled`, and `onSelect`. The palette closes before `onSelect` runs, so a command may open a dialog of its own.

```tsx theme={null}
<CommandPalette
  open={open}
  onClose={() => setOpen(false)}
  items={items}
  onSelect={(item) => item.onSelect()}
/>
```

**Hotkey binding.** The palette binds Cmd+K (Mac) / Ctrl+K (other) to the document while it is closed. The hotkey does not fire while the focus is in a text field outside the palette itself — Ctrl+K means "delete to end of line" in readline-style inputs, and stealing it from a form field causes data loss. Pass `hotkey={false}` where the host owns the opener, such as a toolbar button or a shell-level hotkey, so the palette never binds a second listener.

### Switch appearance

The solid `Switch` has a white thumb in both states, for every palette. Its unchecked track is neutral grey (`gray.500` in light mode, `navy.700` in dark mode). The checked track uses `colorPalette.solid`. Leave the palette inherited unless the setting has a specific meaning.

### Disabled solid button in dark mode

In dark mode, a disabled solid button recedes to `navy.900` fill with `gray.600` label (0.60.1). The previous values were `navy.800` fill and `gray.400` label — a 5.9:1 contrast ratio that made the disabled button read as an enabled secondary button. The new pairing is 3.3:1, consistent with light mode's own 3.9:1, and sits one step off the card surface.

### Native checkbox accent

The theme sets an inherited `teal.700` accent on `html`, reaching native checkboxes, radios, and ranges in all remote content. The accent was `teal.600` before 0.58.0. Remote providers inject no document-scope styles.

In dark mode, `html` also receives `color-scheme: dark` (0.60.1), so native checkboxes, date inputs, and scrollbars follow the dark theme. The teal accent applies there too.

### Muted and helper text

`text.muted` and Chakra's `fg.muted` use `#5F6C7C` in light mode, including `Field.HelperText` and `Dialog.Description`. Contrast is 5.35:1 on white and 4.93:1 on the canvas. Dark mode stays `gray.400`.

### Exported layer utilities and chart colours

Two sets of names are exported because consumer repos were re-implementing them:

* `coversDate`, `coveredDayCount`, `isoWeekday`, and the type `CoverageRange` are on `@stralos-co-nz/ui`. They implement the working-days coverage rule behind `SchedulerItem.workingDays`. An empty `workingDays` covers nothing (the server's `empty_coverage`); an omitted `workingDays` covers every day.
* `labelColourFor`, `contrastRatio`, `relativeLuminance`, `LABEL_WHITE`, `LABEL_NAVY`, and `LABEL_BLACK` are on `@stralos-co-nz/ui` (0.67.0). `labelColourFor` returns the text colour (white or `navy.950`, falling to pure black in the mid-luminance band) to use over a given `#rrggbb` bar fill. `contrastRatio` and `relativeLuminance` are the underlying WCAG 2.x calculations. The three `LABEL_*` constants are the candidate ink colours the picker rule chooses between. Guard a user-typed value with `isHexColour` before passing it to any of these helpers. A legend or chip drawn outside the board previously had to copy these from an internal module; they are now importable directly.
* `REMOTE_ROOT_ATTRIBUTE` and `FOREIGN_LAYER_SCOPES` are on the root barrel. The shell previously declared its own copies.

**Layer gate: only modal layers block (0.54.0).** The rule that defers a remote's dialog while a foreign layer is open now ignores non-modal layers. A non-modal drawer — such as the assistant's chat panel, which is non-modal from `sm` up and stays open across navigation — previously held back every other remote's dialog. A layer is treated as non-modal only when it publishes `data-modal="false"`, which is a promise only a component that latches its modality per opening may publish. `SideSheet` does this; a raw Chakra dialog or drawer does not and is always counted.

Import all of them from `@stralos-co-nz/ui`.

The chart-colour helpers `CHART_SLOT_HEX` and `resolveChartSlotHex` also remain in base UI (`@stralos-co-nz/ui`). `CHART_SLOT_HEX` is the theme chart-slot palette; it replaces `SCHEDULER_CHART_HEX`, which was deprecated in 0.72.0 and removed in 0.73.0. They are shared brand colours used by resource and organisation colour pickers and are independent of the scheduling components.

From 0.53.0, scheduling components (`ResourceScheduler`, `AssignmentDrawer`, and related types) are in `@stralos-co-nz/ui-scheduler`. Assistant components are in `@stralos-co-nz/ui-assistant`. The `@stralos-co-nz/ui/scheduler` and `@stralos-co-nz/ui/assistant` subpaths are removed. Migrate imports to the new packages before upgrading the base UI pin to 0.53.0.

### Icons

The barrel re-exports lucide icons used across Stralos screens. Import from `@stralos-co-nz/ui`, not from `lucide-react` — a federated remote that depends on lucide directly gets a second copy in its bundle.

### `StralosLockup` — shared app and sign-in branding

Use `<StralosLockup />` for the **horizontal Stralos logo**. It matches the stralos.app website's header lockup exactly (Graeme, 2026-10-10): the vector mark and the "Stralos" wordmark, with no divider rule and no tagline. `StralosLockupProps` accepts `size?: "sm" | "md" | "lg"`, which sets the logo's height:

* `sm` (default) — 24 px tall (84 px wide). Keeps the `AppFrame` header at its existing height.
* `md` — 32 px tall, for sign-in cards.
* `lg` — 40 px tall, for large sign-in or onboarding surfaces.

The logo is **true vector** (stralos-ui 0.77.2): a few KB, with no embedded image. It uses the website's proportions: the mark, an 11 px gap at a 30 px mark, and "Stralos" in Sora SemiBold (600) at 1.18rem with 0.005em letter-spacing. The wordmark is outlined to paths from the website's own Sora font, so it renders identically before any web font loads. Light surfaces get the full-colour mark and a navy wordmark; dark mode gets all white. The brand kit carries the same artwork as `vector/stralos-logo-horizontal.svg` and `vector/stralos-logo-horizontal-white.svg`. The lockup exposes one image labelled "Stralos" to assistive technology. `AppFrame` uses it in the header and the top mobile drawer; login-web and admin-web use it on their sign-in pages.

Consumers adopting this export must bump their exact `@stralos-co-nz/ui` pin in the same PR.

### `AppFrame` — back link, nav badges, and popover sub-groups

**Back link.** There is no `railBackLink` prop. The rail's "All Projects" link is the first project-scope `NavigationItem` in `navigation`:

```tsx theme={null}
<AppFrame
  navigation={[
    { label: "All Projects", path: "/projects", scope: "project", icon: <ArrowLeft size={16} /> },
    ...projectEntries,
  ]}
  projectSelector={selector}
>
```

Do not nest one `AppFrame` inside another — you get two sticky headers.

**Navigation item badge.** `NavigationItem` accepts a `badge` prop: any `ReactNode` (a count, an `AttentionBadge`, a "New" chip). The badge renders as a trailing marker in the header popover, the mobile drawer, and the expanded project rail. On the collapsed rail it renders as a small decorative dot. Include the badge's meaning in the item's `ariaLabel` when the badge carries information that is not already in the label.

**Popover sub-group sections.** When a `NavigationItem`'s children include a nested group, the popover renders that group as a flat menu section: a muted heading, items at the top level's indent, and a divider between sections. The popover widens to between 200 px and 280 px to accommodate longer labels. The mobile drawer and project rail continue to render sub-groups as indented trees. Popovers without sub-groups keep their existing 200 px width.

**`NavigationGroup` owner slots.** Two optional slots let the owner render controls inside a project-rail group without overriding the group's `label` (which still names the group for assistive technology and the collapsed rail icon tooltip):

* **`headerContent`** — renders *in place of* the label text in the group heading row, for example an estimate-switcher dropdown that replaces the plain "Estimate" heading. The label remains the accessible group name. `headerContent` is hidden when the rail is collapsed; the label text is shown instead.

* **`leadingContent`** — renders as the *first row inside the group*, under the heading, before any navigation items. Use this when the heading should stay visible as a label and the control belongs beneath it (for example, a switcher dropdown sitting under an "Estimate" heading). The row is hidden when the rail is collapsed, and it appears in the mobile project menu. Omitting `leadingContent` leaves the group rendering its items as normal.

```tsx theme={null}
// headerContent: the switcher IS the heading
<AppFrame
  navigation={[{
    label: "Estimate",
    scope: "project",
    headerContent: <EstimateSwitcher />,
    items: estimateItems,
  }]}
  projectSelector={selector}
>

// leadingContent: the heading stays, the switcher is the first row
<AppFrame
  navigation={[{
    label: "Estimate",
    scope: "project",
    leadingContent: <EstimateSwitcher />,
    items: estimateItems,
  }]}
  projectSelector={selector}
>
```

Use `headerContent` (ui 0.75.1) when the control should *be* the section heading. Use `leadingContent` (ui 0.75.2) when the heading text should remain visible and the control belongs on its own row beneath it. The shell places the PRD 210 estimate switcher with `leadingContent`, so the **Estimate** label stays visible; see [Frontend architecture — Project rail slots](https://help.stralos.app/internal/frontend-architecture.md#project-rail-slots-shell).

### Attention and selectable rows

`StatusBadge` supports the `attention` domain (`unpriced`, `stale`, `superseded`, `expired`) and seven distinct variation lifecycle colours and glyphs, with an optional `glyph` override. Pass `false` to suppress the glyph.

`DataTable` supports `rowStatus` for an explained warning or error tint, `rowDepth` for 24 px-per-level nesting, and `layout=\"fixed\"` with widths on every column.

`SelectableRow` renders a choose-one button with a title, description, optional leading content and amount, and a disabled reason. `SelectableRowList` wraps choices in `list`/`listitem` semantics with hairline separators between rows.

### Scheduler and resource picker additions

`ResourceScheduler` accepts optional `railWidth`, `onRailWidthChange`, `resizableRail`, `fillRail`, and `loading` props. Resizing clamps width between 240 and 480 pixels. `fillRail` widens the rail when spare space is available, without shrinking below the chosen width. The `loading` prop shows six skeleton rows without premature group headers or an empty state.

Short booking labels use whichever side of the bar offers more space, with a truncated inside label when neighbours leave no room outside.

`SchedulerLegend` can render explicit booking statuses and resource kinds independently of a board. Pass `capacityEntries` from `capacityLegendEntries` when the caller has already computed visible capacity tones. `ResourceKindIcon` and `resourceKindLabel` expose the resource-kind language used by the scheduler.

`AssignmentDrawer` displays allocation as a percentage with a visible `%` suffix. `AssignmentDraft.allocation` remains a fraction: entering 50 in the field submits `0.5`, and pool bookings submit `1`. An unavailable Confirmed status includes a visible explanation linked to the disabled control.

`PersonPicker`, `CrewPicker`, and `PlantPicker` default to selection mode. Use `mode=\"add\"` for pick-to-add lists: available rows say **Add**, already-added rows say **Added**, and the summary counts already-added resources. Pass `onRemove` to add a separate **Remove** button to an added row. `summary` replaces the summary line (`null` hides it entirely). `maxListHeight=\"none\"` lets a containing dialog own the only scroll region. All supplied results are accessible through virtual scrolling; there is no row cap.

### `StralosLockup`

Use `<StralosLockup />` for the horizontal Stralos logo, which matches the stralos.app header lockup: the vector mark and a Sora SemiBold "Stralos" wordmark, with no rule and no tagline. `StralosLockupProps` accepts `size?: "sm" | "md" | "lg"`, the logo's height. The default `sm` (24 px) preserves the `AppFrame` header height. `md` (32 px) and `lg` (40 px) are for sign-in cards. The logo is true vector, full colour in light mode and white in dark mode, and the whole lockup exposes one image named "Stralos". `AppFrame` uses it in the header and top mobile drawer.

Import from `@stralos-co-nz/ui`. Consumers adopting this export must bump their exact `@stralos-co-nz/ui` pin in their PR.

### Guided page tours

From 0.76.0, `@stralos-co-nz/ui` includes the guided page tour engine: the tour JSON schema, `registerTours`, `data-tour` anchor helpers and the `TourHost` renderer. `RegisterPage`, `PageHeader` and `SideSheet` accept tour anchor props. Page remotes contribute `*.tour.json` files and anchors; only the `stralos-feature-tours` widget remote mounts `TourHost`. See [Guided page tours](https://help.stralos.app/internal/guided-tours-frontend.md).

***

## Rules that CI now enforces

| Gate | What it catches | How to run |
| - | - | - |
| `src/exportSurface.test.ts` | A removed or unpinned export name | `npx vitest run src/exportSurface.test.ts` |
| `scripts/api-additivity.mjs` | A narrowed type — a prop losing an arm, an optional prop becoming required, a new required prop — against the last released tag. A same-kind literal value rename (e.g. `"teal.500"` → `"teal.solid"`) is classified as `valueChanged` and reported as additive; it is not a blocking finding and does not require the `breaking-api` label. | `node scripts/api-additivity.mjs` |
| `src/importScope.test.ts` | Shipped source importing a package outside `ALLOWED_RUNTIME_PACKAGES`, a runtime value from `@stralos-co-nz/domain` outside `ALLOWED_DOMAIN_RUNTIME_IMPORTS`, or a network global (`fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource`, `sendBeacon`); or a project-readiness API error code in any `src` file. It parses with the TypeScript AST, so a comment naming a package is never a failure. Widen an allowed list in that file deliberately. | `npx vitest run src/importScope.test.ts` |
| `scripts/check-colour-literals.mjs` | A literal palette prop (`bg="red.50"`, `color="green.600"`) in any file not already baselined | Per-file ratchet against `.colour-literal-baseline.json` |
| `src/data/dataTableBundleCost.test.ts` | A new module-scope import into `DataTable` that raises its gzip cost above the gate | Runs in CI on every PR |

Adding an export means adding it to `src/index.ts` and to the pinned list in `exportSurface.test.ts`. A failure naming your new export is the gate working.

To land a deliberate breaking API change: get sign-off first, then add the `breaking-api` label to the PR so the pin wave is planned rather than discovered.

The barrel (`src/index.ts`) must stay enumerated — never use bare `export *`. Consumer agents grep it to verify the pinned contract, and federation build tooling depends on it.

***

## Removing an export that a published package imports

Published packages that import `@stralos-co-nz/ui` (`maps-ui`, `calculator-ui`) carry a peer range, and a published peer range can never be narrowed. If `0.57.0` removes `SupplierEditorModal` and `maps-ui 0.1.x` / `0.2.0` import it under `@stralos-co-nz/ui >=0.51.1 <1.0.0`, then pnpm resolves `ui 0.57.0` alongside them without a warning — and the consumer's build is what breaks. `@stralos-co-nz/ui` is not a runtime singleton under federation v2 (each remote bundles its own copy), so nothing catches this at load time either.

Follow these three steps when removing an export that any published package still imports:

**1. Deprecate for one minor before removing.** The release that stops using an export keeps it as a deprecated re-export:

```ts theme={null}
/** @deprecated removed in 0.N+1.0; use X */
export { X as DeprecatedName } from "./X";
```

Downstream packages then have one release in which both shapes work before the next minor removes the re-export.

**2. Downstream packages bound their peer range below the next breaking minor.** `maps-ui`, `calculator-ui`, and any new `ui` consumer must declare `>=<floor> <<next breaking minor>` rather than `<1.0.0`. A release that removes what they import becomes a peer conflict pnpm reports rather than a silent pairing.

**3. Add the incompatible pair to `check-package-compat` when the removal lands.** Each feature remote that carries the downstream package runs `scripts/check-package-compat.mjs` in CI before its install (`estimate`, `workspace`, `project-settings`, `organisation`). Add the entry to `INCOMPATIBLE` in every copy — the file is byte-identical across those repos — in the same wave as the removal, before any consumer takes the new `ui`:

```js theme={null}
{
  when: "@stralos-co-nz/ui >=<removing release>",
  require: "<package> >=<first release without the import>",
}
```

Do not remove the export without completing all three steps in the same wave.

***

## Adopting a screen

1. Wrap content in `SectionCard padded={false}` and put a `PageHeader` at the top. Delete the local `<Heading>`.
2. Reduce header actions to one solid button; the rest go outline or ghost.
3. Replace the list with `DataTable`. Mark money and quantity columns `numeric`. Move units out of cells into headers. Pick a `density`: leave the default for a grid people act on, pass `density="compact"` for a long read-only reference list.
4. Replace every status chip with `StatusBadge`, and every kind or role chip with `KindChip`.
5. Give each dialog a `size` and an `actions` prop; put fields in `FormGrid` with spans.
6. Replace every `<Input type="date">` with `DateField`, and every local date/money/unit template with the `format` helpers.
7. Check the diff for literal palette props and raw identifiers in visible text. There should be none.

***

## Traps to avoid

**No literal palette props.** `bg="red.50"`, `color="green.600"`, `colorPalette="purple"` are now CI failures unless the file is already baselined. Use the `status.*` semantic tokens, or the primitive that owns the decision (`StatusBadge` for a state, `KindChip` for a kind).

**No native date inputs.** `<Input type="date">` renders the browser's locale. Use `DateField`.

**No hand-rolled table, page header, or dialog footer.** They exist in the library; a second one is a new variant, and variants are what the review is removing.

**Leave a `Button` unsized** unless you mean something other than the default, which is now `sm`. The exceptions are icon buttons that sit inside something — the `RowMenu` kebab and the `DateField` calendar button — and they set their own size already.

**Give a `Dialog` one of `sm` / `md` / `lg`** — never `xl` or a custom `maxW`.

**A slot recipe's `slots` array merges by index.** A shortened or reordered list rewrites the merged one. Always write `slots: <part>Anatomy.keys()`.

**A palette slot keyed only `base` is overruled in light mode.** Key `base` and `_light`, and assert the emitted token CSS — the config object cannot show this.

**`textStyles` keys are dotted strings**, not nested objects. `{ label: { caps: … } }` makes `textStyle="label.caps"` resolve to `{}` silently.

**Calculator screens are not in this package.** From `@stralos-co-nz/ui` 0.51.0, trench, pit, and volume calculator modals live in `@stralos-co-nz/calculator-ui`. The `./calculators` entry point was removed from `@stralos-co-nz/ui` at the same release. Replace any import from `@stralos-co-nz/ui/calculators` with `@stralos-co-nz/calculator-ui`. Export names and props are unchanged.


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