Skip to main content
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. 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): 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. 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.
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.
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:
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.
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 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. 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.
pagination is the one load-more pattern. Pass it instead of building a pager yourself.
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.
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)
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: 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.
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.
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.
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.
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.
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.
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.
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. 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.
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: 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:

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

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").
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: 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:
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:
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.
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.
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.
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.
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.
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.
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.
Props: RateLocationPickerSelection is what a pick emits:
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.

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

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

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.
  • 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.
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 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. 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.
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():
It emits “Can’t save yet” with a message counting the invalid fields and noting the first one is highlighted. See Form feedback pattern 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.
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)

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.
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.
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. Back link. There is no railBackLink prop. The rail’s “All Projects” link is the first project-scope NavigationItem in navigation:
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.
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.

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.

Rules that CI now enforces

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