Skip to main content
The frontend is composed by stralos-shell and separate feature packages.

Shell responsibilities

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

Version detection and reload

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

Detection

The shell polls two documents every five minutes per tab:
  • index.html — carries the deployed shell’s git SHA in its asset URLs. Changing when the shell is activated; unchanged by a remote-only deploy.
  • remotes-manifest.json — carries each remote’s git SHA in its version field. Changing when a remote is deployed; unchanged by a shell-only activation.
Either document changing is treated as a new version. Polling only one would miss a whole class of deploys. Each poll is a conditional GET (cache: "no-cache" with an ETag), so the steady-state cost is two 304 responses per tab every five minutes. In addition to the interval, the shell triggers an immediate check on tab visibility change (visibilitychange) and on back-forward cache restores (pageshow with persisted: true). These cover the long-idle tabs that are most likely to be holding a stale document and whose intervals have been throttled or frozen by the browser.

Reload on navigation

When a newer version is detected, the shell waits for the next completed in-app navigation before reloading. A navigation in progress when the version is found is skipped; the reload is deferred to a subsequent navigation. Reloads are suppressed for full-document navigations, downloads, form submissions, and explicit reloads — all of which already fetch a fresh document. The shell uses the native window.navigation API when available. On navigatesuccess the shell checks whether a reload is eligible and, if so, issues location.reload() after one task to allow popstate handlers to run first. When the native Navigation API is unavailable, the shell falls back to observing the pathname prop passed to useNewVersionAvailable. A pathname change from one render to the next triggers the same reload check. Both paths are active and exclusive — the native path takes precedence, and the fallback runs only when the native API is absent — so a tab in any environment reloads at its first navigation after a new version is detected.

Reload banner

New version available with Reload and Dismiss controls is shown only when the shell’s runtime-config.json reports environment: "development" or environment: "test". In production, the reload happens automatically at the next navigation with no user-visible notice. When a development or test hostname shows an environment banner, the update message appears inside it. If the runtime environment is development or test but the hostname has no environment banner, ShellNotices.tsx renders the standalone notice. Production shows no update notice. The versionAvailability.ts module owns detection. The NewVersionNotice component owns the standalone banner. EnvironmentNotice renders the embedded update message when the host has a non-production banner. The useNewVersionAvailable hook in NewVersionNotice.tsx wires them together and manages the reload trigger. MVP navigation is gated by the PostHog mvp feature flag in stralos-shell. App.tsx evaluates it through useFeatureFlag("mvp", { fallback: true }) on the shell’s single PostHog instance; no separate SDK initialisation is required. While the flag is on, filterMvpNavigation in src/mvpNavigation.ts removes the areas that are not part of MVP. Because the fallback is true, MVP navigation is also shown before flags load and whenever PostHog is unreachable or disabled, so a customer never sees unfinished areas by accident. Only a successful evaluation that returns mvp as off shows the full navigation. Global navigation items appear in this order: Dashboard, Projects, Resources (group), Settings. The Resources group is a non-routing navigation header whose items are, in order: Rates Library (/rates), Construction Activities (/activities), People (/people), Crews (/crews), Plant (/plant), Companies (/companies), Locations (/locations), Company scheduler (/scheduler). It appears as a desktop popover or in the mobile navigation drawer. Within the Resources popover, the shell groups items into three labelled sections based on the remote key that owns each path: This grouping is applied by the shell’s withResourceSections() compositor, which reads the remote key from the manifest. A section header is omitted when no items belong to it. The destinations themselves — their labels and paths — come from the served descriptors, so a rename or relocation in the manifest takes effect without a shell release. If the popover composition already carries nested groups (from a future owner-provided section), withResourceSections leaves it unchanged. Navigation labels come from the navLabel field in each route descriptor. There is no shell-side override map; the organisation descriptor supplies Settings for /settings. Workforce owns People (/people), Crews (/crews), and the route-only Compliance report (/compliance). Fleet owns Plant (/plant); the retired /plant/subcategories path redirects there. The workspace remote owns Companies (/companies) and Locations (/locations). Legacy /clients and /suppliers paths redirect to /companies.

Legacy company-register redirects (M5)

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

Locations page

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

OrganisationLocation shape

api-global returns location address and coordinate fields nested under geoTag, matching the shape every other geo-bearing entity in the estate uses:
latitude and longitude are omitted by the server when the row has no coordinates, so both fields are optional. A location with no coordinates is selectable for pricing but cannot resolve a route — the page shows a “No coordinates” indicator only for those rows. A retired location shows a “Retired” badge. The CompanyDetailPage Locations panel reads the same nested geoTag shape for a company’s own branches and sites.

Projects list routed actions

Navigating to /projects?action=add-project opens the New project modal. The Projects page removes the action parameter before opening the modal, preserves other query parameters, and prevents React StrictMode from opening the modal twice. The modal is owned by the workspace remote. Use this route from feature remotes that need to start project creation. Do not import or duplicate the project editor in a remote.

Project navigation (navigation M2)

On a registered /projects/:projectId/* route, the AppFrame project navigation surface shows the Project navigation section for the routed project. It is absent from /projects and global routes, even when the header selector has a selected project. Project pages no longer render a persistent workspace header or horizontal sub-navigation. The desktop rail and mobile project drawer list entries in this order: Overview · Estimate · Delivery [Delivery estimate, Compare to tender, Sign-off, Variations in Delivery, Programme, Schedule] · Commercial [Variations] · Documents · Project Settings.
  • Overview (/projects/:projectId) is owned by the workspace remote and is the index route for the project workspace. It shows project identity, status, key dates, the contract basis panel, and quick-links to the other visible workspaces.
  • Delivery and Commercial are non-routing group headers with their entries indented beneath them.
  • Estimate (/projects/:projectId/estimate), Documents (/projects/:projectId/documents), and Variations (/projects/:projectId/commercial/variations) are served by the estimate remote.
  • Delivery estimate (/projects/:projectId/delivery), Compare to tender (/projects/:projectId/delivery/compare), Sign-off (/projects/:projectId/delivery/sign-off), and Variations in Delivery (/projects/:projectId/delivery/variations) are served by the estimate remote (PRD 210 §12.3). These appear before Programme and Schedule in the Delivery group.
  • Programme (/projects/:projectId/delivery/programme) and Schedule (/projects/:projectId/delivery/schedule) are served by the projectSchedule remote. Programme is declared as a paths entry on the projectSchedule descriptor alongside the primary Schedule path.
  • Project Settings (/projects/:projectId/settings) appears at order: 260 in the project section when the projectSettings remote is available in the active composition.
  • Tender and Finance have no shipped descriptor destinations, so they do not appear.
There is no All Projects item in the project rail any more. The global Projects entry in the header is the way back to the project register from inside a project workspace. The rail’s top block is the project-context slot, which shows the current project and client name when the workspace remote renders it; until it does (an older workspace build or a load failure), the shell’s fallback, an All projects link to /projects, shows there instead. See Project rail slots. A project can hold multiple estimates (for example a tender estimate and a Delivery estimate). The estimate a page works in travels in the route: /projects/:projectId/estimates/:estimateId/.... The bare /projects/:projectId/estimate/... form stays as the estimate remote’s primary resolver — it looks the primary up and redirects to the canonical URL — so inbound links that predate multiple estimates still land correctly. When the user is on a canonical estimate URL, every estimate nav link in the project drawer and every estimate command in the command palette carries the same :estimateId. This keeps the user inside the open estimate when they navigate between sections. A project-level page (Delivery, Documents, Settings) has no estimate ID in its URL; its estimate links fall back to the bare resolver path, which opens the primary. The bare /projects/:projectId/estimate path is the only declared nav destination. The canonical /projects/:projectId/estimates/:estimateId mount is nav: "hidden" — the shell rewrites each displayed link at render time using the open estimate from the URL, but the catalogue entry stays on the bare form so routeId lookups and help-page routeId frontmatter are unaffected across both URL forms. The shell reads the open estimate ID from the URL alone on canonical routes. On non-canonical routes (any path without /estimates/:estimateId), the estimate remote publishes the ID via setActiveContext so features that need it — such as the rate-exception badge — can still read it from the nav bridge.

Rate exception badge (PRD 04 M4)

The Project Rates item in the project rail carries a live exception-count badge. The badge is published by the Estimate remote using publishNavigationBadge on the nav bridge, so the shell renders it without owning the polling policy, count source, or display logic. The Estimate remote reads the persisted count endpoint (GET /api/estimate/estimates/:id/rate-exceptions/count) rather than running the exception pass itself. A stale response is published with tone: "muted" and refreshing: true. The badge renders differently depending on whether the project rail is expanded or collapsed:
  • Expanded rail — the count chip appears in a trailing badge slot (data-nav-badge) to the right of the item label. The glyph slot (data-nav-glyph) does not contain the chip.
  • Collapsed rail — only a dot indicator (data-nav-badge-dot) is shown. The count chip is hidden to save space, but the full accessible label (for example Project Rates — 128 rate exceptions) remains on the link so screen-reader users receive the complete count.
Badge states: The shell suppresses the badge chip when the published displayText resolves to a settled zero (numeric value 0 or empty string) and refreshing is false. A zero that arrives while refreshing is true is not suppressed — it says the number may be stale and is being re-read, which is the polling state the rail exists to show. The accessibleLabel is preserved either way, so screen-reader users are still told there are no exceptions when the chip is hidden. The publisher (RateExceptionBadgePublisher in stralos-feature-estimate) sends String(state.count) without a zero guard; the shell-side suppression is the host’s own presentation decision. A one-line guard in the publisher would be the more durable fix and is compatible with this behaviour. The remote waits for an estimateId from the nav bridge before issuing any network call. On canonical estimate routes the shell reads the ID directly from the URL; on other routes the estimate remote publishes it via setActiveContext. If the active context is absent (for example, when the user has not yet opened the estimate workspace on this visit), the item renders without a badge. A route change clears the prior context so a badge from one visit cannot leak into a fresh visit to the same path. The accessibleLabel field in the badge value becomes the full accessible name of the navigation item — for example Project Rates — 3 rate exceptions or Project Rates — rate exception count unavailable; open to retry — while the visible label stays Project Rates.

Project rail slots (shell)

The shell renders two host-owned RailSlotTargets in the project rail (src/App.tsx), which the workspace remote’s persistent chrome portals into (see Persistent workspace chrome). Each target shows the host’s fallback for as long as the owner has rendered nothing there. withEstimateSwitcherSlot applies the switcher slot to the top-level group whose label === "Estimate" only; groups at any other level are left unchanged. The Estimate label always stays visible as a rail row and is never replaced by the switcher. When the workspace remote has portalled nothing (a single-estimate project, an older workspace build, or a load failure), the row beneath the Estimate label is empty.

Overview page

The workspace remote owns the Overview page (/projects/:projectId). It sources all its data from api-global and shows:
  • Project panel — client and status.
  • Key dates panel — start date, target submission date, and end date.
  • Contract basis panel — contract form, on-site overhead percentage, off-site overhead and profit percentage, and special conditions note. If the contract profile has not been set, the panel says so. If the load fails an inline Retry button appears; the other panels remain visible.
  • Go to panel — buttons linking to each visible workspace destination (Overview itself is excluded).
Key dates are stored as YYYY-MM-DD strings and displayed as stored to avoid UTC-midnight timezone shift.

Project selector behaviour

The workspace remote provides the persistent header project selector. The shell mounts the remote’s persistent chrome capability once in the authenticated layout, outside its route tree, so the same workspace provider and controller survive navigation between owners. The workspace remote owns selector data loading, project and client editors, and remembered project navigation; the shell does not call those domain APIs. From inside a project workspace, selecting a different project in the header selector keeps the current workspace. For example, switching from project A while on Delivery › Programme lands on project B’s Programme page. From anywhere outside a project workspace, selecting a project opens that project’s Overview. When the current project ID cannot be resolved, the workspace remote publishes projectMissing: true. The shell suppresses the unmatched project body only for that committed route and clears the fact on navigation.

Destination resolution (projectDestination)

useProjectChrome exposes projectDestination(projectId), a pure render-time function that resolves the destination href for a given project. The rules, in order:
  1. If the current path is a project-chrome path, swap the project ID segment to produce the equivalent path in the target project.
  2. Otherwise, use the remembered per-project detour stored for that project.
  3. Otherwise, fall back to that project’s workspace home (/projects/:projectId).
The function is total: every project ID resolves to a concrete path. A path that isProjectChromePath refuses (for example, because the current route is outside a project workspace) degrades to the workspace home so the selector row always has a valid href. selectProject remains the imperative API: it calls projectDestination then navigate, so the click-to-navigate path continues to work unchanged.

Anchor rendering and modifier-click support

Each project row in the selector is rendered as a real anchor element (<a href>). This lets the browser handle Ctrl/Cmd-click and middle-click natively — those gestures open the project in a new tab without affecting the current tab. Prior to this change, rows were div[role=menuitem] elements that called navigate on click. Modifier clicks silently switched the current tab instead of opening a new one. Selection state is not set on click. The projectChrome effect already adopts routeProjectId whenever the route addresses a loaded project, so a plain click’s navigation sets the selection and a modifier click correctly leaves it alone. Keyboard activation uses a navigate override on Menu.Root: zag passes an anchor item to its navigate prop rather than clicking it, so the override ensures Enter activates the router link instead of dispatching a bare MouseEvent that would trigger a full-page load.

Persistent workspace chrome

The workspace remote’s ./routes module exports persistentChrome alongside workspaceRoutes:
Provider receives remoteKey, its children, and the optional hostChrome input. It owns the shared workspace controller and UI provider. Header renders the project selector. Notice renders load failures and a missing-project message. RailHeader renders the routed project’s name and client in the top of the project navigation rail. EstimateSwitcher is the estimate-switching dropdown placed beneath the Estimate rail entry; it is visible only when the project’s Multiple estimates setting is on. Palette provides the project command palette. The standard route array supplies the workspace outlet pages. RailHeader and EstimateSwitcher are optional. An older owner that omits them renders nothing in the corresponding host targets, so the host’s fallback for that slot shows instead. The shell portals each component into its RailSlotTarget when the owner provides it. The shell exposes this module as RemoteRuntime.PersistentChrome and mounts it with a non-credential session identity, host-owned header and notice targets, the active workspace outlet target, two optional rail slot targets, and an onChromeState callback. A null session or missing header target prevents the remote from loading. Workspace route elements register outlet targets; the persistent provider portals the matching route into each target. The two rail slot targets are railHeaderTarget (the rail’s top block, provided when a project is routed) and estimateSwitcherTarget (the first row of the Estimate section). Their placement and fallbacks are in Project rail slots. The shell accepts only the four fields in OwnerChromeSnapshot. It verifies the requestKey and rememberedProjectPath, then scopes the accepted snapshot to the current runtime, session identity, complete route location, and active request lease. Invalid snapshots are reported and cleared; late publications and clean-ups cannot overwrite a later visit. The workspace stores the remembered route under a user-and-organisation-scoped key. The retired unscoped shell key stralos.nav.lastProjectPath is neither read nor written. After the owner publishes a remembered project route, the global Projects rail entry resumes that route even when the user is already inside a project. There is no All Projects item in the project rail; while the project-context slot is empty, its host fallback links to /projects (see Project rail slots). The initial module load is shared. A missing descriptor shows a retriable header fallback only because no workspace outlet can be validated. A degraded runtime, module-load failure, or provider failure shows retriable fallbacks in the header and in a registered active route outlet; there is no separate notice fallback at that level. After the provider loads, the Header, Notice, and route leaves have independent render boundaries: Project selector unavailable in the header, Workspace status unavailable in the notice area, and the manifest’s workspace fallback copy in the route outlet. A stale-chunk error attempts the guarded one-time reload before showing a fallback.

Legacy route redirects (M3)

Three project paths moved to canonical locations at navigation-migration M3. The shell registers permanent redirects for the old paths so bookmarks, pasted links, and old copilot citations continue to resolve: Redirects preserve the full URL: the project id, any deeper path segments, query string, and fragment are carried across intact. A redirect is only registered when a canonical target exists in the active manifest — if the remote is absent, both the old and new paths 404 rather than looping. The Documents title route uses end: false. Its M4 review descendants (/projects/:projectId/documents/review and /projects/:projectId/documents/review/:documentId) therefore remain titled Documents. Schedule is served by the stralos-feature-project-schedule remote at /projects/:projectId/delivery/schedule.

Route catalog

The shell maintains a labelled inventory of navigable sections in src/routeCatalog.ts. The assistant service uses the catalogue for navigation questions and deep links. Each entry in ROUTE_CATALOG has the following shape:

Generated JSON artifact

src/routeCatalog.generated.json is generated by pnpm gen:route-catalog and must be committed with catalogue changes. The drift test (routeCatalog.drift.test.ts) cross-checks the catalogue against the live nav sources in both directions. The assistant service vendors and parity-checks this generated JSON. It consumes the catalogue through navigation tools and the deterministic navigation fast path.

Drift test nav sources

The drift test validates every catalogue pathTemplate against four nav sources and checks that every path in those sources has a catalogue entry:
  1. Remote manifest paths — paths entries from DEFAULT_MANIFEST, read via remotePaths.
  2. Nav builder output — the global rail and the project workspace drawer, in both static and manifest-composed modes (buildDefaultNavigation, buildRemoteNav, buildProjectWorkspaceNav).
  3. Remote-host sibling constants — *_BASE_PATH declarations in remote-host.tsx, enumerated explicitly in REMOTE_HOST_SIBLING_CONSTANTS. This map has been empty since shell-reduction §4.7 act 5: every former sibling is now a descriptor paths entry on the relevant remote. The equality guard still applies so a new constant cannot slip in unenumerated.
  4. Committed routes contracts — the routes-contract.json files vendored in integration/__contracts__ and imported via COMMITTED_CONTRACTS in integration/contracts.ts. Each file is the exact contract a remote publishes at its served SHA. A catalogue entry whose path template matches one of these contracts corresponds to a route the remote actually declares, so a typo or a dropped route fails direction (a) rather than passing silently.
The contracts source replaced a hand-written SHELL_NAV_HIDDEN_PATHS map that had no equality guard back to anything real. Only workspace, organisation, and estimate currently emit a contract; routes belonging to activities, rates, and project-settings still rely on the manifest source until those remotes adopt the contract. To add a catalogue entry for a route belonging to a contract-emitting remote, the path must appear in that remote’s committed routes-contract.json. A path that is only typed in the catalogue fails the drift assertion at direction (a).

Command palette

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

Item source

Commands are built from ROUTE_CATALOG — only entries with nav: "primary" become commands. Hidden and chromeless entries are not offered. When a project is open, project-scoped entries are resolved against the current projectId and listed before global destinations. When no project is open, project-scoped entries are omitted entirely. The palette searches pages, not entities. Project search (by name, a rate, or an activity) is not wired here — the shell holds no project list; that lives in the workspace remote’s project selector.

Group headings

Every item carries a group. Global destinations with no catalogue group are placed under Go to. Project-scoped destinations with no catalogue group are placed under This project. The palette renders a heading whenever the group changes from one row to the next, so if items from the same group are non-contiguous the heading appears more than once. buildCommandPaletteItems guarantees contiguity: items are sorted so each group’s entries are adjacent, in order of first appearance, with catalogue order preserved within each group. This means a project’s ungrouped pages (Overview, Estimate, Documents, Project Settings) and its grouped pages (Delivery, Commercial) all appear under a single This project heading.

MVP navigation flag

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

Launcher

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

window.__STRALOS_NAV__ bridge

The shell installs a nav bridge on window.__STRALOS_NAV__ at bootstrap (synchronously in main.tsx, before any remote loads). It is the navigation analogue of window.__STRALOS_AUTH__.
getSnapshot and subscribe implement the useSyncExternalStore contract. Remotes use them to subscribe to route, context, and badge changes without depending on React Router directly. Remotes that remain compatible with older shells guard publishNavigationBadge before calling it. The returned function releases the published badge when called (for example, on component unmount). currentRoute() resolves the current pathname against the catalogue without a router dependency. It returns:
Matching picks the most specific entry (longest pathnameBase) so a deep sub-route such as /projects/abc/estimate/work-items/42 still resolves to routeId: "estimate". getSnapshot() returns a NavSnapshot — a stable, referentially frozen object that is replaced only when the route, published context, navigation badges, or the resolved composition changes. It is the read path for useSyncExternalStore-style consumers.
projectNavigation carries the full resolved project workspace nav for the project identified by route.projectId. Paths and labels reflect the live composition — remote-backed destinations use the path from the resolved manifest rather than a static fallback. The array is empty when the user is outside a project route, or before the composition resolves. The shell calls setNavigationManifest immediately after the remotes manifest resolves, before registering any remote, so projectNavigation is populated from the first snapshot a remote receives at registration time. subscribe(listener) registers a change listener compatible with useSyncExternalStore. It returns an unsubscribe function. The listener is called synchronously on registration, then on every snapshot change. navigate(path) calls the live React Router navigation function. Calls made before the router registers are queued and flushed in order when NavBridgeRegistrar mounts. isKnownRoute(path) returns true when a concrete app path resolves to a known catalogue route. The assistant remote calls this before rendering any inline link, citation, or server-driven navigate action, so an absent bridge member suppresses every deep link. The function strips a query string and fragment, requires an app-relative path (/ prefix, not //), and matches catalogue templates with end: false so a section sub-route resolves to its section. It returns a real boolean — the assistant compares with === true, so a truthy non-boolean would read as false and silently remove links. setActiveContext(context) lets a remote publish record context that is not represented in the URL. The Estimate remote publishes its selected estimateId and clears it on unmount. For canonical estimate routes (/projects/:projectId/estimates/:estimateId/...), currentRoute() reads estimateId directly from the pathname — it does not depend on a setActiveContext call. For non-canonical paths (for example, the Delivery estimate pages at /projects/:projectId/delivery), setActiveContext is the only source for estimateId. The shell drops the published context on a pathname change and on browser history navigation so a stale estimate ID cannot carry forward to a later turn. The call is best-effort and uses optional chaining so a newer remote remains compatible with an older shell:
publishNavigationBadge(routeId, value, context) lets a remote attach a badge to a navigation item by its stable route ID. The shell renders the badge alongside the item’s icon without needing to know the remote’s polling policy, count source, or display logic. routeId is the stable catalogue ID for the destination (for example "estimate.builder.rates"). The badge is only applied to a navigation item that carries a matching routeId field — a label match alone is not sufficient. value carries the display content:
context binds the publication to the current route and active context revision so the shell can reject a stale result arriving after a navigation or context change:
Obtain context from the bridge snapshot rather than constructing it manually:
The return value is a cleanup function. Call it when the badge is no longer valid — for example when the remote unmounts or when a fresh fetch supersedes the previous result. A cleanup from an earlier publication cannot clear a newer one for the same route ID; the bridge compares by object identity. The badge is also discarded automatically on any route or context change. The shell freezes both the badge value and the context object before storing them. The bridge validates every field and silently ignores publications with a mismatched path, a wrong context revision, or invalid field types.

Project visit signal

On every committed route, the shell records which project the tab is in, so a remote that remembers per-project state can tell whether the person has been in another project since it last looked. feature-estimate’s tender check review memory uses it. The shell writes sessionStorage key stralos.shell.projectVisit.v1 with the value { projectId: string, seq: number } from recordProjectVisit in src/projectVisit.ts, called by commitRouteVisit in src/nav-bridge.ts:
  • Moving to a different project increments seq, starting at 1. Returning to the same project, or visiting a route outside a project, changes nothing.
  • The first document load counts, so a tab opened straight at a project URL (link, typed URL, bookmark or reload) records the visit before any remote loads.
A remote notes seq and compares it on its next read. A different seq means the person has visited another project in this tab, even if they are back in the original one. The shell is not a published package, so a remote reads the key itself and validates the shape (a non-empty projectId and a positive integer seq), as the shell’s own readProjectVisit does.
  • sessionStorage lasts as long as the tab.
  • Storage can be missing, full or blocked, and reading window.sessionStorage can throw. The shell guards every access and records nothing on failure.
  • A shell without this feature never writes the key. Treat an absent or malformed value as no information, not as no visits.

Feature packages

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

Shared packages

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

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

From @stralos-co-nz/ui 0.23.13, the brand typefaces (Sora and IBM Plex Mono) are no longer imported inside Provider. They are published as a separate subpath and must be imported once by the application that owns the page:
Add this import to the root entry point of stralos-shell, login-web, and admin-web. A host that upgrades to 0.23.13 without adding this import will lose its typefaces. Federated remotes must not import this subpath. A CSS side-effect import anywhere on a remote’s import graph causes the bundler to emit font files into every remote artifact (34 orphaned font files, approximately 338 KB, measured at thin-host M0). The @stralos-co-nz/ui/fonts subpath is intentionally unreachable from the package’s root barrel or from Provider, so a remote that only imports named UI exports cannot accidentally pull fonts in.

Stralos theme (stralosConfig)

@stralos-co-nz/ui exports stralosConfig, a Chakra UI v3 defineConfig preset. Every feature remote and the shell apply this config through createSystem(defaultConfig, stralosConfig). The theme carries the full colour ramps (green, orange, red, blue, and gray now run 50–950), colorPalette slots for all five of those palettes, three named text styles (heading.page, heading.card, label.caps), and a table slot recipe that applies the review’s header treatment — uppercase letter-spaced headers, strong hairline, middle-aligned cells, and numeric right-alignment — to every Table.Root in the estate. See Design library for the composition rules and per-component API.

Typeface loading (ui 0.23.13)

From ui 0.23.13, the five @fontsource typeface imports moved out of Provider and onto a separate @stralos-co-nz/ui/fonts subpath. A federated remote that bundles ui no longer carries font files. The application that owns the page (the host) is responsible for loading the typefaces once. stralos-shell imports this subpath in main.tsx, directly below the bootstrapScrub import:
The fonts import must stay below bootstrapScrub. That module is the first thing main.tsx evaluates, and placing any import above it would push a third-party module ahead of the session-ticket scrub. login-web and admin-web each need the same import at their own entry point when they upgrade to ui 0.23.13 or later. A host that upgrades without adding the import will render without the suite’s typefaces.

Compact type scale (0.23.4)

The suite uses a compact type scale. All token values sit below the browser’s 16 px default — md (13 px) is the suite-wide text default, set via a global body { fontSize: "md" } rule so unstyled text matches sized controls throughout. Consumer rule: use Chakra size tokens (fontSize="md", textStyle, etc.) rather than hard-coded px values. A hard-coded value that matched the old default will appear oversized after the compact scale lands — the named token is the correct abstraction.

Border radius

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

Focus ring

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

Colour palette slots and the _light key requirement

Each palette block in stralosConfig provides eight colorPalette slots that Chakra’s Badge, Button, and other components read: fg, muted, subtle, emphasized, border, solid, contrast, and focusRing. Slots whose values differ between light and dark mode are expressed as objects. Chakra’s own built-in palette blocks key those objects with _light and _dark. Because createSystem deep-merges object values, a Stralos override that keys only base and _dark leaves Chakra’s _light entry intact. The :root, .light rule then emits Chakra’s value rather than the brand value, making the override appear unwritten on screen. The required shape for a conditioned slot is:
base and _light must point at the same token. Scalar slots (solid, contrast, focusRing) replace Chakra’s object outright and are not affected by this requirement. All five brand palettes (navy, teal, green, orange, red, blue, gray) follow this convention. theme.test.ts pins the conditioned slots for each palette through a real createSystem call and asserts that all three CSS emissions (base, _light, _dark) match the brand tokens.

Teal palette fix (stralos-ui #137)

Prior to this fix, the teal palette block keyed its conditioned slots (fg, muted, subtle, emphasized) with base and _dark only. Chakra’s own _light block survived the merge, so :root, .light emitted Chakra’s values. The most visible effect was teal.muted: the hover background of every teal outline and ghost control rendered as teal.200 (stronger mint) in light mode instead of the brand’s teal.50. The fix adds _light to all four conditioned teal slots, matching the convention already in place for the other palettes. The affected controls — teal outline buttons, ghost buttons, and any element reading teal.muted — now hover to the correct brand value in light mode.

Tabular numerals

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

ErrorBoundary (@stralos-co-nz/ui)

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

Props

Behaviour

  • The fallback renders without a Chakra UI provider, so it is safe at the shell root and around federated remotes that may not have a Chakra context.
  • The fallback carries data-ph-no-capture; the shell’s PostHog configuration blocks matching elements from session replay.
  • If onError throws, that reporter failure is swallowed. Console reporting occurs only when no onError callback is supplied.
  • Clicking Retry resets the boundary state and calls onRetry if supplied. The child subtree re-renders from scratch.
data-ph-no-capture protects session replay only. The fallback renders error.message to the user without sanitising it. Callers must ensure render errors never carry credentials, capability tokens, or other sensitive values. Telemetry sanitisation does not change the fallback text.

Usage

Feature flag hook

useFeatureFlag from @stralos-co-nz/ui reads flags through the shell’s single PostHog instance. Remotes must not initialise their own SDK.
The flag name above is an example. Create your flag in PostHog before shipping its consumer. The hook subscribes to window.__stralosPosthog and stralos:posthog-ready, including later evaluations after identity changes. Successful refreshes can update an already-resolved flag. If the shell never publishes a decision, the hook remains loading; it has no independent timeout. Outside a browser it resolves to the fallback. Choose the fallback that is safe for the feature. An unknown key after a successful evaluation returns false even when the fallback is true. Flags control browser behaviour. API permission checks remain responsible for authorisation regardless of the flag value. The package also exports FeatureFlagState, FeatureFlagOptions, FeatureFlagReader, FeatureFlagCallback, and FeatureFlagContext. See Feature flags for the shell publication contract.

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

@stralos-co-nz/ui ships a standardised set of components and hooks for form feedback: useFormValidation, FormField, SubmitButton, notify, notifyValidationErrors, Dialog, DialogCancelButton, useDialogClose, and ConfirmDialog. Use this pattern for new or migrated modal editors and calculator forms. See Form feedback pattern for full API documentation and usage examples.

AppFrame grouped navigation

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

Types

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

Scope partitioning

AppFrame partitions top-level entries by scope:
  • scope === "global" entries render as Global navigation (desktop header strip, mobile drawer).
  • All other entries render as Project navigation (desktop collapsible rail, mobile drawer).
Items inside a group inherit the group’s scope; their own scope field is ignored.

Rendering — desktop

Global navigation renders as a horizontal strip inside the sticky header. Flat items are routed buttons. Groups render as popover triggers whose content lists their items; nested groups remain available within that popover. Project navigation renders as a collapsible left rail (<aside>). The rail is 220px wide when expanded and 48px wide when collapsed to icon-only. Items use their icon; collapsed items expose the label as their accessible name and tooltip title. The preference is stored in localStorage under stralos.projectNavigation.collapsed and restored on the next load. Groups render as section headers and may contain nested groups.

Rendering — mobile

Below the md breakpoint, the desktop surfaces are hidden and two header controls are available:
  • Open top menu opens a start-edge drawer for global navigation.
  • Open project menu opens a start-edge drawer for project navigation.
Both drawers are lazy-mounted and unmounted while closed. Each closes on a navigation click, its close button, or a route change, including programmatic navigation. When a NavigationItem carries a content node, the drawer closes only when an anchor element inside that content is activated. A button within the control (for example, one that opens a picker without navigating) does not close the drawer, so the person can continue interacting with the control before picking a destination.

Accessibility

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

Backward compatibility

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

Shell usage

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

Project rail slots (ui 0.75.1 and 0.75.2)

Three additive AppFrame slots let feature remotes inject owner-rendered content into the project rail and mobile project menu without a shell release. None is available to global navigation. AppFrame.projectNavigationHeader (0.75.1) — an optional ReactNode rendered at the top of the project rail and the mobile project menu, above all navigation entries, for example the current project name and client. The block is hidden when the rail is collapsed.
NavigationGroup.headerContent (0.75.1) — an optional ReactNode shown in place of the group’s label text in the project rail and mobile project menu. label still names the group for assistive technology and is shown when the rail is collapsed. NavigationGroup.leadingContent (0.75.2) — an optional ReactNode shown as the first row inside the group, beneath its label. It is hidden while the rail is collapsed and shown in the mobile drawer. The shell places the estimate switcher with leadingContent, not headerContent, so the Estimate label stays visible (see Project rail slots):
All three slots are backward-compatible: callers that omit them see no change.

API base URL resolution

The shell and the migrated Activities, Dashboard, Estimate, Rates, Scheduler, Workforce, Fleet, Project Schedule, and Project Settings clients use this precedence model:
  1. The module’s VITE_STRALOS_*_API_URL build-time variable.
  2. The matching property in window.__STRALOS_API_URLS__.
  3. The matching localStorage["stralos.<module>ApiUrl"] support override.
  4. sameOriginFallback(devDefault).
The terminal fallback returns window.location.origin in a browser production build. Under a Vite development build, or when window is unavailable, it returns the module’s localhost default. Explicit configuration always takes precedence. Global clients check VITE_STRALOS_GLOBAL_API_URL before the legacy VITE_STRALOS_API_URL. The shell also supports VITE_STRALOS_ESTIMATE_API_URL, VITE_STRALOS_DOCUMENTS_API_URL, and VITE_STRALOS_ASSISTANT_API_URL; their local defaults are ports 3303, 3304, and 3307 respectively. A production build without an explicit Assistant URL uses the serving origin. The Activities client supports VITE_STRALOS_ACTIVITIES_API_URL (default port 3302) and VITE_STRALOS_RATES_API_URL (default port 3301); the Rates API URL is used to load item summaries from /api/rates/rate-summaries and exact per-item options from /api/rates/rate-items/{id}/options for all three calculator modals. Dashboard uses this chain for the Global API. Project Settings resolves its Global, Estimate, and Documents API bases independently through the same four stages. Its Global configuration accepts VITE_STRALOS_GLOBAL_API_URL and the legacy VITE_STRALOS_API_URL; the Estimate and Documents build-time keys are VITE_STRALOS_ESTIMATE_API_URL and VITE_STRALOS_DOCUMENTS_API_URL. Their development defaults remain ports 3300, 3303, and 3304 respectively.

Billing API (stralos-api-billing)

The Billing panel (BillingPanel) in Settings talks directly to stralos-api-billing using the signed-in user’s bearer token. It is intentionally not routed through the Global API or any admin service. The base URL resolves through the same four-stage chain:
  1. VITE_STRALOS_BILLING_API_URL build-time variable.
  2. window.__STRALOS_API_URLS__.billing.
  3. localStorage["stralos.billingApiUrl"] support override.
  4. sameOriginFallback("http://localhost:3308").
The billing panel is visible to organisation admins only (role === "admin"). Members do not see the Billing menu item in Settings. When /settings contains a billing query parameter, the shell opens the Billing section on mount. Checkout and portal return URLs use this route so the admin lands in the right place. If stralos-api-billing is not configured for the environment (configured: false in the summary response), the billing panel shows a “Billing is not configured” state and does not offer a checkout button.

Billing access boundary (BillingAccessBoundary)

The shell wraps the authenticated product in BillingAccessBoundary. It calls GET /api/billing/access on mount, every 60 seconds, on window focus, and after a session projection change. Each answer is scoped to user.id:user.organisationId, so state from one organisation cannot leak into another. Navigation between product routes does not trigger a re-check. The three standing triggers (poll, focus, session projection) keep the answer current without a per-click request. The only navigation that re-checks is crossing the billing-recovery boundary: when an admin leaves /settings/billing, the boundary re-checks immediately so a fixed subscription takes effect before the user returns to the product rather than waiting for the next poll or focus event. When enforced is false, the product renders regardless of mode. Failure handling. How a failed check behaves depends on when it occurs and why it failed.
  • A first check with no previous answer, or a definitive failure (4xx other than 408 or 429, or a parsed but invalid response body), applies at once: the product is replaced with Billing is unavailable.
  • A transient failure — a network rejection, timeout, 5xx, 408, 429, or JSON parse error — on a background poll after a known answer keeps that answer and schedules a retry in 10 seconds. Only three consecutive transient failures (~20 seconds) replace a known answer with Billing is unavailable.
  • Any 200 answer, including a denial, always applies at once.
Billing is unavailable is shown with a Check again button. It does not leave product content mounted. Provisional first frame. Organisation tokens can carry { mode, reason?, seats, readOnlyEndsAt?, coveredBy? } in a signed billing claim (src/billingClaim.ts). The boundary decodes it as a UI hint while the endpoint request is pending, so a covered company gets the covered-company rules below from the first frame. A missing claim renders the product immediately. The endpoint answer then replaces the provisional state, and product APIs enforce billing independently on every request. An admin with reason: "subscription_required" is redirected to /settings/billing. The recovery routes /settings/billing and /settings?billing=required remain available during blocked access and suppress product chrome so project/client reads are not mounted. They show the billing panel with account actions and a Back to app link. Covered companies. When the billing answer carries a non-empty coveredBy, the company was created by a contractor company and its billing is that contractor company’s. Nobody at the covered company can fix it from their own Settings → Billing, so the boundary changes three things:
  • An admin is never redirected to /settings/billing for subscription_required.
  • No Manage billing link is shown, for any role.
  • The read_only bar and the blocked screen name the contractor company, for example “Acme Contracting’s billing needs attention, so this company is read-only for now.” The blocked screen adds “Please ask an admin at Acme Contracting to check Settings → Billing.”
The contractor’s name comes from GET /api/global/org/context (src/contractorContext.ts), read only after the billing answer reports coveredBy. Any failure returns null and the messages fall back to an unnamed form (“Your company’s billing needs attention…”), so a failed read never blocks a screen. Contractor access ended. When any product API answers 403 with code: "contractor_access_ended", authFetch dispatches the stralos:contractor-access-ended window event (src/contractorAccessEvent.ts). The person reached this company through a contractor company and that link, or the contractor company itself, has ended. The billing boundary replaces the product with Your access to this company has ended and the message “Your access to this company has ended. Please ask your company admin if you need it back.”, with the account actions still available. Refreshing would not help, because api-login refuses to mint a token for that company again. The screen is scoped to the identity it was raised for, so switching to another company clears it. Free beta ended. GET /api/billing/access can carry beta: { endsAt, daysLeft, seats } (api-billing returns null for a company not on the beta; an older billing service leaves the field out, which reads the same). A beta company has an active billing row with no card that ends 14 days after sign-up; after that the normal rule applies, read_only for 7 days and then blocked. src/betaAccess.ts parses the marker: anything that is not an object with a string endsAt and numeric daysLeft and seats counts as no marker. The boundary treats the beta as ended when billing is enforced, the mode is not full, the company is not covered, and the marker is well-formed. It then changes the wording only:
  • The read_only bar is labelled “Free beta ended”. Admins see “Your 14-day free beta has ended. Please add a card to keep working in Stralos. Everything you’ve set up is safe.” with an Add a card link to /settings/billing. Members see “Your company’s free beta has ended. Please ask your admin to add a card. Everything you’ve set up is safe.” with no link.
  • The blocked screen is titled Your free beta has ended, with “Please add a card to restore access.” for admins and “Please ask your admin to add a card to restore access.” for members, each followed by “Everything you’ve set up is safe.”
  • An admin is redirected to /settings/billing once per sign-in, keyed on the stralos.auth.signInId stamp in localStorage. Reaching a billing recovery route, by the redirect or directly, records that stamp under stralos.billing.betaEndedShownFor, so the redirect does not repeat in that sign-in. With no stamp (storage blocked, or a session from before the stamp existed) there is no redirect; the banner link still goes to billing.
The provisional billing claim in the token does not carry beta, so the beta wording appears once the endpoint answers.

Projects setup lock

SetupAccessBoundary (src/SetupAccessBoundary.tsx) locks the Projects area while a company is still being set up. It sits inside BillingAccessBoundary: billing can block the whole product, while the setup lock only blocks Projects. The lock is behind the onboarding-setup-lock PostHog flag (ONBOARDING_SETUP_LOCK_FLAG in src/organisationSetup.ts), read with fallback: false. Until PostHog answers true (still loading, missing, inactive, blocked or failed), the setup status is never requested and nothing is locked. Turning the flag on mid-session starts the check as a page load would. Turning it off clears the lock and restores navigation without a reload. The onboarding welcome dialog and the dashboard’s setup empty state use the same flag. Status check. With the flag on, useOrganisationSetup calls GET /api/global/organisation-setup once per identity (userId:organisationId). A complete answer is not asked again in that session. While the answer is incomplete, the hook asks again on every pathname change, so Projects comes back as soon as the admin finishes the last step on the Locations, Companies or Rates page, without a reload. fetchOrganisationSetup fails open: a 404, any other non-2xx response, a network error, a timeout after 10 seconds, or a body without a boolean complete returns null, which the shell treats as complete. The lock is a guide through setup, not an access control. Every API still enforces its own permissions. The response is { complete, exempt, steps: { office, client, rate }, nextStep }. api-global decides from real data: exempt is true once the company has a live (non-archived) project, and an exempt company is always complete. The shell locks only on complete: false. A step missing from the answer is treated as done. While locked:
  • isSetupLockedPath matches /projects and every path under /projects/. Those routes render the locked panel instead of the remote page.
  • filterSetupLockedNavigation removes Projects entries from the global rail, and the command palette drops destinations whose path template is in the Projects area (isSetupLockedDestination).
  • The locked panel is titled Projects open once setup is done. An admin sees “Projects need your office, a client company and at least one rate. Next, please add …” and a Do this next button to the next step’s page, taken from nextStep or else the first missing step in the order office, client, rate. A member sees “Your admin is still setting things up. Please ask them to add …”, listing the missing steps, with no button.
  • Arriving at a locked path while the answer is being re-checked shows an empty busy area rather than the panel, so finishing setup and then opening Projects never flashes the locked panel.

Shell geocoding

The shared location picker uses Places Autocomplete (New) for address search. VITE_STRALOS_GEOCODE_COUNTRY is passed to the picker as countryCodes and defaults to nz.

window.__STRALOS_GOOGLE_MAPS__ seam

The shell installs Google Maps configuration on window.__STRALOS_GOOGLE_MAPS__ during boot by calling loadGoogleMapsRuntimeConfig(). The value is read from the googleMaps block in /runtime-config.json. Feature remotes read this object to initialise the Maps SDK without fetching the runtime document themselves.
countryCode is a live getter. It reads the signed-in user’s active organisation from the session on every access, finds the matching countryCode in the stored memberships, and returns it. The value therefore updates automatically after an organisation switch and clears to undefined after sign-out. Remote components pass it to the Maps SDK as a region hint without needing to subscribe to session events. If /runtime-config.json does not include a googleMaps block, or if apiKey is missing or blank, the object is not installed. The /auth/handoff and /login routes skip the boot preload; installGoogleMapsRuntimeConfig() registers a lazy window.__STRALOS_LOAD_GOOGLE_MAPS_CONFIG__ function for those routes so their first map render can trigger the same load on demand.

Auth contract

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

localStorage session keys

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

Employee support session notice

When impersonatedBy is set on the current session user, the shell renders EmployeeSupportSessionNotice — an orange banner — above the application header for the duration of the session. The banner identifies the session as an audited employee support session and names the account being viewed. When impersonationExpiresAt is present and parseable as a finite timestamp, the banner shows a live countdown (Ends in HH:MM:SS or MM:SS). The timer updates every second and stops at zero, showing Session expired once the absolute expiry has passed. When the field is absent or unparseable, the banner shows a static Expires within one hour. fallback. The banner also renders an End session button that calls signOut() when clicked. The notice is suppressed on the route that replaces the session, /auth/handoff. On that route, stralos.auth.user still describes the outgoing session until setSession completes, so showing the notice would misidentify a completing hand-off as an ongoing employee support session. The shell tracks this state with sessionIsBeingReplaced and excludes that route from the render condition. The notice renders outside the AppFrame header so it cannot be obscured by the application chrome; the DOM position is enforced in tests. The role="status" and role="timer" attributes are set on the relevant elements for accessibility. The timer uses aria-live="off" — screen readers read the initial value but are not interrupted by each second-by-second update; the full accessible label (aria-label) is kept current for assistive technology that polls the element.

Non-production environment banner

The shell renders EnvironmentNotice at the top of every page when the browser hostname identifies a non-production environment. The banner does not affect routing or session behaviour. The development banner has a close button (Hide development environment banner) that hides it for the current page load; it returns after a reload. The test banner has no close button. environmentFromHostname classifies the hostname: The development banner reads Development environment with the description Changes and data here do not affect production. The test banner reads Test environment with the description Use this environment to verify changes before production. When a newer deployment is detected, New version available — Reload to pick up the latest release appears inside the environment banner alongside Reload and Dismiss controls. The outdated-version message is in a live region so screen readers announce it when it appears. Dismissing hides the message for the current page load but does not cancel the automatic reload that fires on the next navigation. If the development banner has been hidden, ShellNotices shows the standalone NewVersionNotice instead when a newer deployment is detected. The same standalone notice is used when no environment banner applies. The classification logic requires the suffix to include the Stralos registrable domain, so a hostname such as dev.stralos.app.example.com is not classified as a Stralos development environment and no banner is shown. The notice is mounted through ShellNotices in App.tsx; the component itself returns null for production hostnames.

Shell notices and AppFrame height

Both the employee support session notice and the non-production environment banner are grouped in ShellNotices and rendered in a Box above the AppFrame slot. Because AppFrame’s root has min-height: 100vh, a page was previously that combined notice height taller than the browser window when any notice was visible. List pages that own their own scroll (from ui 0.69) had no content to scroll but gained an unwanted scrollable area equal to the banner height. The shell measures the combined height of the notices container using useElementHeight and constrains the AppFrame slot to min-height: calc(100vh - <noticesHeight>px). The measurement is reactive: adding, removing, or resizing a notice updates the frame’s minimum height without a page reload. The sticky project rail (aside[aria-label="Project menu"]) is shortened by the same amount: calc(100vh - var(--stralos-sticky-top, 41px) - <noticesHeight>px). On a page that scrolls, the notice scrolls away while the rail stays stuck under the app bar, so the rail would end noticesHeight above the bottom of the window. Growing the rail back would make every non-scrolling page taller again, so appFrameSlotCss (src/appFrameSlotCss.ts) paints the gap instead. When noticesHeight is above zero it adds two box shadows offset downward by noticesHeight: one in the rail’s surface colour, 1px narrower, and one in the border colour that shows as the rail’s right edge. A shadow is paint, not layout, so it never adds to page height or scroll.

Login client configuration

Sign-out’s hosted-logout leg and the Security menu link read configuration from the auth block of /runtime-config.json, fetched same-origin at boot. The /login redirect does not use this block. There is no VITE_* build-time variable for it — a build-once-promote-many bundle cannot carry a per-environment value.
src/runtimeDocument.ts fetches the document once (cache: "no-store", 1500 ms ceiling) and shares it with observability and Google Maps configuration. A boot that needs all three makes one request. The auth and googleMaps blocks are additive, so omitting either does not affect the other consumers. auth.loginOrigin is a full origin pointing to the central login application (login.<env>.stralos.app). The shell uses it to build the Security link in the user menu. It must not be derived from auth.hostedDomain, which is the Cognito ceremony host on a different subdomain. A deployment that omits auth.loginOrigin keeps all other behaviour and loses only the Security menu item. The injected-config escape hatch for local development is window.__STRALOS_LOGIN_HOST_ORIGIN__. window.__STRALOS_LOGIN_ISSUER_ORIGIN__ and window.__STRALOS_OIDC_CLIENT_ID__ are an injected-config escape hatch for a host that writes them into index.html. They take precedence over the document and skip the fetch entirely. The window seam carries a full origin; the document carries a bare hostname — both are normalised through the same resolveOrigin validator, so neither form can carry a path, query, or credentials. Neither source is localStorage: the issuer decides where sign-out navigates, so it may not be repointed from writable storage. /login stores a validated return path in the regional origin’s sessionStorage, derives the sibling login.<envdomain> origin from the browser origin, and hard-navigates there. It does not use auth.hostedDomain, which identifies the Cognito ceremony host. The cross-origin URL remains bare; /auth/handoff consumes the stored return path after the central service establishes the session. /login has two states: The same route handles explicit sign-out and failed-refresh redirects from the shell and feature bundles.

Google Maps runtime configuration

The shell loads Google Maps browser configuration from the googleMaps block of /runtime-config.json and publishes it on window.__STRALOS_GOOGLE_MAPS__ before any remote component renders.
All values are trimmed before publication. A missing or empty apiKey means the block is absent from the window global. mapId and linzApiKey are omitted from the published object when absent, blank, or not a string; without linzApiKey the LINZ aerial layer stays hidden. Remotes that render maps read window.__STRALOS_GOOGLE_MAPS__ directly. If the property is absent, the maps block has not been configured for this environment and the remote should degrade accordingly. There is no VITE_ build-time variable for these values. A build-once-promote-many bundle cannot carry a per-environment key, so the document is the only configuration path. Local development. The Vite dev and preview servers synthesise /runtime-config.json in vite.config.ts, reading GOOGLE_MAPS_* and LINZ_BASEMAPS_* variables from .env and the process environment. With GOOGLE_MAPS_API_KEY set, the googleMaps block carries apiKey, plus mapId from GOOGLE_MAPS_MAP_ID and linzApiKey from LINZ_BASEMAPS_API_KEY when set. Without GOOGLE_MAPS_API_KEY the block is omitted, so maps and the LINZ aerial layer stay off locally. The fetch is memoised by src/runtimeDocument.ts and shared with auth and observability. Preloading at boot costs one request regardless of how many consumers read it. Bootstrap behaviour. The shell calls installGoogleMapsRuntimeConfig() at startup, which installs a lazy reader at window.__STRALOS_LOAD_GOOGLE_MAPS_CONFIG__. The normal boot path also preloads loadGoogleMapsRuntimeConfig() directly, so the window global is populated before the first remote renders on standard page loads. The /auth/handoff and /login routes skip the boot preload to avoid delaying those time-sensitive paths. A remote on those routes can call window.__STRALOS_LOAD_GOOGLE_MAPS_CONFIG__() when it first needs the configuration.

Login email prefill (removed at IDP-M2)

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

Regional provider hints (removed)

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

Cross-bundle refresh bridge

The shell publishes a refresh bridge on window.__STRALOS_AUTH__ at bootstrap (before any remote loads) so that feature remotes can delegate token refresh, org switching, session teardown, and organisation name refresh to the shell’s single-flight auth serialiser.
refreshAccessToken — when a feature remote receives a 401, it calls this method after detecting the seam rather than attempting its own refresh. All callers delegate to the same refreshSession(), which calls POST /api/login/auth/refresh, so concurrent bundles coalesce onto one refresh rotation and do not race the rotating refresh token. The method resolves to the new access token, or null if refresh is impossible and the shell has cleared the session. Token rotation is serialised across browser tabs as well as within a single tab. The shell acquires the Web Locks API lock stralos.auth.rotation before executing any token-rotating mutation (refresh or organisation switch). Tabs that do not support the Web Locks API fall back to the existing within-tab serialisation. Tabs that request a rotation while one is already in flight in another tab wait for the origin-wide lock to be released before proceeding; the waiting tab then reads the freshly written token from localStorage rather than issuing a second rotation. If an organisation switch in another tab has already triggered a page reload, waiting tabs park until the navigation replaces their JS context. The current feature clients retry the original request once. They use the returned token when present, otherwise they re-read the access token from storage. This fallback supports a remote running without the shell and covers a bridge that could not refresh. A second 401 means the session is dead. The remote must clear the session before redirecting to /login?reason=expired&from=<encoded-return-path> — leaving the dead keys in storage causes a redirect loop because the shell’s /login route treats a session as live on the presence of stralos.auth.accessToken and stralos.auth.user alone, ignoring reason=expired, and sends the visitor straight back to the page that just failed. clearSession — clears the browser session and any session-related telemetry identity. The shell publishes it with logout as an alias of the same synchronous local teardown. Feature remotes call this method before redirecting to /login on a dead session. A standalone remote without the bridge falls back to removing the three documented keys (stralos.auth.accessToken, stralos.auth.refreshToken, stralos.auth.user) directly from localStorage. Bootstrap session refresh. During bootstrap, before any remote renders, the shell awaits refreshExpiringSessionBeforeFirstRequest(). This call checks whether the stored access token is expired or within its expiry window and, if so, rotates it before the first render. Feature remotes read the access token directly from localStorage rather than calling authFetch, so without this preload a reopened tab with an expired token would send its first burst of requests with a stale bearer and receive 401 responses before any in-flight refresh could complete. The preload is skipped on the ceremony callback, hand-off, and /login routes, which write a new session of their own and do not benefit from rotating an outgoing one. Shell authFetch — proactive token refresh. Before sending a request, authFetch reads the stored access token’s exp claim and calls refreshSession() first when fewer than 30 seconds remain. Because refreshSession() is single-flight, concurrent calls crossing that window coalesce onto one rotation. The 401 path is kept as a fallback for early server-side revocation and for tokens whose exp cannot be read. Shell authFetch — stale-401 waterfall prevention. The shell’s own authFetch wrapper records the access token that was in storage when a request was issued. A slow endpoint can return a 401 after a faster concurrent request has already triggered a refresh and written a new token to storage. In that case authFetch detects the mismatch — the current storage token differs from the issued token — and retries with the already-refreshed token rather than calling refreshSession() again. Only a 401 whose storage token still matches the issued token triggers an actual refresh. This prevents a page load that issues several API calls simultaneously from serialising into a chain of sequential refresh round-trips when the session expires mid-load. Shell authFetch — one automatic retry for busy GETs. When a GET request receives 503 with a Retry-After header of 10 seconds or fewer, authFetch waits the requested delay and retries the request once. A second 503 is returned to the caller without a further retry. Write requests (POST, PUT, PATCH, DELETE) are never replayed: a 503 does not confirm the write did not happen. A 503 with no Retry-After header, or with a value above 10 seconds, is also returned immediately without retrying. Session mutation endpoints (/refresh, /switch-org, /logout) call fetch directly and are unaffected. switchOrg (org-users-directory M3) — switches the active organisation for the live session. It shares the same single-flight serialisation lock as refreshAccessToken because both /api/login/auth/refresh and /api/login/auth/switch-org rotate the refresh token; a concurrent pair would trip family-reuse revocation. This lock extends across browser tabs via the Web Locks API: a switch in one tab waits for any in-flight refresh in another tab to complete before it sends the switch request, so it always sends the most recently rotated refresh token. On success the shell persists the new session and hard-reloads the entire application so every feature bundle re-reads the new active-org token. The SwitchOrgResult resolves to { ok: true } on success, or { ok: false, reason } where reason is one of: The shell’s OrgSwitcher popover surfaces forbidden outcomes as an inline message and stays open so the user can choose another organisation. network and error outcomes clear the session and redirect to login inside the flow. unauthenticated redirects are also handled inside the flow before the component receives the result. refreshOrganisationNames — fetches the live list of organisations the signed-in user belongs to (GET /api/global/org/organisations) and writes the result into the membership name cache. A feature remote that has displayed an organisation name and needs to reflect a rename can call this method. The method resolves to true when the cache was updated and the session is still valid, or false when the session has changed or the fetch failed. Two guards prevent a stale response from overwriting a newer session:
  • The session identity is captured before the async module import. If a teardown happens during that import, the identity will have changed and the method returns false without making a network call.
  • After the fetch completes, the session identity is checked again. A response that arrived after a teardown-and-new-session is discarded.
The method fails soft on network error or API refusal — the existing cache is not cleared. clearSession — removes the three stralos.auth.* storage keys and resets the telemetry identity. The call is synchronous and local: it does not POST to a logout endpoint. Feature remotes that receive a second 401 (meaning redirect to login) call this before navigating to /login so the login route does not read a dead session as authenticated and bounce the user back. Feature-detect the seam before calling (window.__STRALOS_AUTH__?.clearSession?.()). logout — an alias for clearSession, published under the name that stralos-feature-organisation’s second-401 branch calls. Both names are bound to the same function, so clearSession and logout always behave identically. Do not call the shell module’s own logout() export from a remote — that function posts to /api/login/auth/logout and the navigation that follows routinely races the request. signOut — runs the host’s complete awaited sign-out sequence for shell controls and remote calls. It attempts POST /api/login/auth/logout under a two-second ceiling, clears the local session, resolves the login configuration under a 1.5-second ceiling, then hard-navigates to hosted logout or /login. Each leg is guarded so one failure cannot prevent the final navigation. A remote calls this member rather than rebuilding the host-owned sequence. Consumer rules:
  • Retry the failed request at most once. A second 401 means the session is dead — clear the session (via clearSession if available, otherwise remove the three stralos.auth.* keys) and redirect to /login?reason=expired. Never trigger another refresh for that request. Skipping the clear causes a redirect loop.
  • Never run a second refresher of your own alongside this call. A parallel refresher races the rotating refresh token and invalidates the session for every bundle.
  • Do not call switchOrg while a refresh is in flight, and do not call refreshAccessToken while a switch is in flight. Both serialise through the same lock, so queuing is safe, but the caller must not assume the token state is stable between the two calls.
  • Feature-detect each member before calling it. A member present on the StralosAuthBridge type but absent from the published object means the shell version pre-dates that member.
The bridge is installed by installAuthBridge() in main.tsx.

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

A ghost button carrying the current company’s name and a building icon sits in the app header between the project selector and the user menu. Clicking it opens the OrgSwitcher popover, the same popover that the Switch company item in the user menu opens. Both entry points control the same Popover.Root; a test pins this so they cannot drift. The displayed label is currentOrganisationName(user) trimmed, falling back to orgShortLabel(user.organisationId) (the first eight characters of the organisation ID, prefixed with Business …). This is the same fallback the switcher’s own list rows use, so the header and the modal cannot disagree about what an organisation is called before its name has loaded. At viewport widths below md the button shows only the building icon (no text label). The full aria-label — Switch company — currently <name> — is always present.

Organisation switcher (org-users-directory M3)

The shell renders the active organisation’s name as a button in the application header, between the project selector and the user menu. Clicking it opens the OrgSwitcher popover, anchored on the org pill. The button shows the organisation name when loaded, or falls back to orgShortLabel (the first eight characters of the organisation ID prefixed with Business …) before the name resolves. On screens narrower than the md breakpoint the button shows only the building icon to preserve header space; the full name appears from md upward. The same popover is also reachable from Switch company in the user menu. Both entry points control the same Popover.Root state and both are always present in the authenticated layout. The Switch company item in the user menu opens the OrgSwitcher popover. It is always present in the menu regardless of what the persisted session contains. The popover dismisses on Escape and on an outside click. It does not have a Close button. The popover cannot be dismissed while a switch is in flight — the panel reports its busy state upward through onBusyChange and the root refuses onOpenChange until the switch completes. This guard matters because switchOrg clears an uncertain session and redirects on every failure except the recoverable 403, and that 403 message is rendered inside the panel. A dismissal mid-flight would discard the only report the user gets. Popover.Root and its trigger belong to the header chrome because the pill is the anchor and the user menu’s Switch company item is a second entry point onto the same controlled state. OrgSwitcher renders content only; it does not own its own visibility.

Behaviour

When the popover opens, it calls GET /api/global/org/organisations via listAvailableOrganisations() to fetch the live list of organisations the signed-in user belongs to. It also calls cacheAvailableOrganisations to write the name list into the stored membership cache, so a subsequent hard reload retains the correct list and name data. The same cache is written at startup by ensureOrganisationNamesCached and after a rename by refreshOrganisationNames; both use the same cacheAvailableOrganisations function so the stored shape cannot drift between callers. The popover uses the same shape as the project selector: flat ghost-button rows with a trailing Current badge on the active entry. A filter field appears above the list when the loaded membership count is three or more (FILTER_FIELD_MIN_ROWS). Below that threshold the field is hidden because there is nothing to narrow. The filter field is cleared each time the popover opens, so a leftover query from the previous session cannot hide entries on the next open. The filter field is rendered inside a SectionCard with stickyHeader so it remains visible as the user scrolls the list. The list scroll area is capped at min(60dvh, var(--available-height, 60dvh)) so the popover respects the available viewport height on short screens and in landscape mobile layouts. The --available-height custom property is set by AppFrame when it measures the space below the app bar; it falls back to 60dvh when the property is absent. Each organisation row shows its display name and role (Admin or Member). When the signed-in person reaches a company through a contractor company, the name is followed by a muted · via <contractor name> label, from the optional viaContractorName field on each AvailableOrganisation in the GET /api/global/org/organisations response. Clicking another organisation calls switchOrg() on the auth bridge. On success, the shell persists the new session and hard-reloads the application. A muted line below the list reads “Switching reloads Stralos with that company’s projects and settings.” The popover blocks dismissal while a switch is in progress.

Failure states

Organisation labelling

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

parseMemberships validation

parseMemberships() in auth.ts normalises the raw memberships array from the OIDC exchange response or persisted session storage. It:
  • Rejects any entry missing a non-empty organisationId.
  • Accepts organisationName only when it is a string with at least one non-whitespace character — a whitespace-only value is treated as absent and the field is omitted entirely.
  • Accepts countryCode only when it is a two-uppercase-letter ISO 3166-1 alpha-2 string or explicit null. Any other value is omitted entirely.
  • Normalises role to "admin" or "member" with a safe default.
  • Falls back to "active" for status when the field is absent.
  • Accepts viaContractorName only when it is a string with at least one non-whitespace character; otherwise the field is omitted. It is present only for a company the person reaches through a contractor company.
This validation runs both at session write (setSession) and at session read (getUser). The persisted memberships are a cache and provide current-organisation label data; the switcher’s list itself comes from the live listAvailableOrganisations() response.

User menu rendering

The application header is sticky and z-indexed, so it is its own stacking context. Content rendered in place inside it can never paint above a page-level layer higher than the header’s z 10, whatever its own z-index. The floating-end widgets, such as the onboarding guide panel at z 1100, would otherwise cover the menu’s lower items, including Sign out. The user menu therefore renders its content through Portal to document.body, the same strategy as the OrgSwitcher popover, with positioning={{ strategy: "fixed" }}. The trigger stays in the header. Escape still closes the menu and returns focus to the trigger. src/UserMenu.portal.test.tsx pins this by asserting that the open menu content is not a descendant of the header.

Organisation name cache (organisationNames.ts)

The organisation’s display name is stored in the memberships array of stralos.auth.user, keyed by organisationId. Consumers of that display name — currentOrganisationName, internalOwnerLabel, the assistant’s org group, and organisationNameOf in feature-rates — resolve it from that key. No new storage key or bridge function is involved. Organisation-scoped pricing uses this current name directly. The shell fills the cache at startup via useCachedOrganisationNames. The hook calls ensureOrganisationNamesCached once per session identity, which is a no-op when the active organisation already has a name in the stored list. When it does not — the session was started through api-login, which builds the user record with no memberships — the function calls GET /api/global/org/organisations (listAvailableOrganisations) once and writes the result into the cache. The cost is zero extra requests for OIDC sessions; one request, once, for api-login sessions. After the startup fill completes, useCachedOrganisationNames triggers a re-render so the repaired name appears without waiting for an unrelated render to occur. cacheAvailableOrganisations is the single write function used by all three callers: the startup fill, the switcher open, and the post-rename refresh. Keeping the mapping in one place prevents the stored shape from drifting. After an organisation rename, saveOrganisationName in GlobalSettingsPage calls refreshOrganisationNames, which fetches the live list from listAvailableOrganisations and writes it into the cache unconditionally. Because this endpoint reads from stralos-admin-api — the service that just accepted the rename — the switcher, the Locations register, and organisation-owned pricing groups in feature-rates can show the new organisation name immediately. The organisation-settings projection on this region lags the admin event and updates asynchronously; the settings panel shows a message to this effect while the lag resolves. The whole-list approach is what makes the cache survive an org switch. switchOrg hard-reloads the page and changes user.organisationId. Because the cache holds a name entry for every organisation the person belongs to, the switched-to organisation is already named and the previous organisation’s entry cannot be returned for it. The fill degrades silently on refusal, outage, or unconfigured relay. Every consumer keeps its existing fallback label until the cache is populated.

Personal security settings

Personal security settings — TOTP, passkeys, password, and sign out everywhere — are served by stralos-login-web on the login host (login.<env>.stralos.app) against a server-side broker in stralos-api-login. The previous regional paths (/settings/security and /settings/security/callback) are no longer served by the shell or stralos-feature-organisation. Both paths now redirect to the login host’s /security page. The redirect uses window.location.replace so the browser’s Back button does not return to a URL whose only behaviour is to leave again. If auth.loginOrigin is absent the path falls through to the unmatched-page view instead of guessing at a destination. The Security item in the user menu is an anchor to the login host’s /security path. It is not a client-side navigation: a navigate() to another origin does not exist in a single-page application, and the router would render the unmatched-path page instead. The page allows a person to manage their authenticator-app factor, passkeys, and password, and shows linked sign-in providers read-only. Its Sign out everywhere action calls the security broker under the page’s live step-up cookie and CSRF echo. The regional shell retains a separate refresh-token-authenticated revoke-all endpoint. These controls apply to the person’s own identity and do not configure organisation-wide SSO. The link is built from auth.loginOrigin in /runtime-config.json (see Login client configuration for how the shell reads this document). The shell resolves the URL at runtime from securityHome.ts:
  • When auth.loginOrigin is present and valid, the menu shows Security as a link to <loginOrigin>/security.
  • When the key is absent, the menu item is hidden — not disabled. A deployment that has not yet set the key keeps sign-in and settings but loses this one menu item.
auth.loginOrigin is a full origin (https://login.dev.stralos.app), not a bare hostname. It is read from auth.loginOrigin explicitly and must not be derived from auth.hostedDomain, which is the Cognito ceremony host — a different service on a sibling subdomain. The regional shell no longer runs a settings OAuth ceremony, scrubs a settings callback, or publishes window.__STRALOS_SECURITY__. Its only bootstrap scrub is the hand-off ticket scrub (handoff/bootstrapScrub.ts). The pre-auth allow-list (UNAUTHENTICATED_PATH_PREFIXES in remotes.ts) is now empty: a remote descriptor cannot create an unauthenticated route. There is no longer a host-authored exact-string list of security paths: every URL on this host requires a session except /login and /auth/handoff.

Email prefill on the step-up form

Both the Security menu link and SecuritySurfaceRedirect append the signed-in user’s email address as a ?email=<encoded> query parameter to the security URL. The login host’s step-up form reads this parameter and opens pre-filled, so a user who is already authenticated to the regional shell does not need to re-enter their address. The parameter is appended by withStepUpEmail (src/securityStepUpEmail.ts). Key properties:
  • The parameter is omitted entirely when the shell has no email address for the current session. ?email= (an empty value) is not written.
  • URL.searchParams.set performs the encoding. A plus-addressed mailbox (user+tag@example.com) arrives as %2B rather than a literal +, which a form decoder would misread as a space.
  • The URL is a navigation target only. It is not logged, not captured as an analytics property, and not stored. The anchor element carries data-ph-no-capture so session replay does not record the attribute value.
  • The field is editable on the login host and is never auto-submitted. It is a convenience, not an automatic authentication step.

OAuth relying-party login (IDP-M1)

The central login application runs the OAuth 2.0 / OIDC ceremony and redeems the code on the login host. The email and OTP form has been removed, and the regional shell’s /login route redirects to the central application. The session comes back through the hand-off receiver. The shell’s regional /auth/callback page and its ceremony code (AuthCallbackPage, state and PKCE storage, the code exchange, the region chooser, at-login invite acceptance, and the /login?authError= cards) were retired after production showed no traffic to them. /auth/callback is no longer a route of its own: a stale callback URL falls to the ordinary guards. api-global’s POST /api/global/auth/oidc/exchange still exists, but no web client calls it. The shell still reads the login client configuration (auth.hostedDomain and the app client id, or the window.__STRALOS_LOGIN_ISSUER_ORIGIN__ and window.__STRALOS_OIDC_CLIENT_ID__ seams) for one purpose: sign-out’s hosted-logout leg (src/hostSignOut.ts, src/oidc/logout.ts) must name the same client.

safeInternalPath open-redirect guard

The from return destination survives a round trip through sessionStorage while the user signs in on the central login application. Before IDP-M1, the shell accepted any path starting with / (but not //). That predicate was an open redirect: /\evil.com passes the prefix check but the WHATWG URL parser treats a backslash in the authority position as /, resolving it to https://evil.com/. safeInternalPath in src/safePath.ts replaces the old helper for all from-destination validation. It parses the candidate against the current origin using the URL constructor and checks that the result is same-origin — defeating backslash variants, tab characters, and other non-obvious spellings without enumerating them. It also validates that the normalised output is itself same-origin (fixed-point check), since URL normalisation can in principle manufacture a hostile value from a same-origin input. The function returns the normalised path rather than the caller’s original string.

IDP-M7c hand-off receiver

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

Bootstrap scrub and observability gate

main.tsx imports handoff/bootstrapScrub.ts before every other module. The import captures the ticket and removes the fragment with history.replaceState before React, feature modules, or observability initialise. main.tsx contains no JSX so the compiler cannot inject react/jsx-runtime ahead of that import. If the browser refuses replaceState, the shell does not redeem the ticket and does not initialise observability for that page load. The Sign-in can’t complete here card is best-effort: BrowserRouter also calls replaceState during initialisation, so the same restricted browser can fail before React renders the card. The security decision does not depend on the card appearing; the ticket remains unredeemed and expires within 60 seconds.

Redemption and navigation

The page consumes the scrubbed ticket once and sends it in the JSON request body:
In a deployed build the request is pinned to the serving origin. A Vite development build can use VITE_STRALOS_LOGIN_API_URL, which defaults to http://localhost:3400. Before sending the POST, the shell calls clearSession() to end any previous regional session. This step runs after confirming a ticket is present but before the network call, and it applies regardless of what the redemption returns. A ticket has been presented, so whoever is arriving arrives as its subject; the session in localStorage belongs to whoever was here before and no redemption outcome can turn it back into the right answer. Without this step, every failure arm would leave the old session intact, and the only path off the error screens (Back to sign in → /login) redirects an authenticated visitor straight to the dashboard, silently returning them to the previous identity — which may be an employee support session — after a sign-in they watched fail. The clear runs after the missing guard (when no token is present on the URL). A reload of /auth/handoff after a hand-off has already consumed its token arrives with no ticket; clearing there would sign out the session that just succeeded. clearSession() drops the old refresh token from browser storage rather than revoking it at the server. The ticket expires in under 60 seconds, so a revocation round trip before the POST would spend that budget and could itself fail; the family expires on its own. On 200, the shell validates the session response, stores the access token, refresh token, and user, and reads each value back. It then performs a document navigation so all feature bundles start with the new session. The receiver consumes an optional same-origin destination from sessionStorage under stralos.handoff.from; an absent or invalid value falls back to /dashboard. The ticket is single-use, so the shell never retries it automatically.

Hand-off outcomes

AI copilot chat panel (stralos-ui)

The ChatPanel, DraftReviewCard, and supporting components in stralos-ui form the prop-injected presentational layer for the AI copilot. The assistant remote owns network calls, state, route gating, and the 401-refresh seam. The shell owns the toggle button, availability bridge, and toggle-state persistence. See AI copilot frontend for the component and review-card contract, and AI copilot shell draft controller for the shell-side toggle and bridge.

Estimate feature module structure

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

Layer assignment control

The Used by column in a calculator modal is a per-layer slot injected by the host through the renderLayerAssignment prop on CalculatorModalHost. The UI package takes a slot — not a fetch — and renders whatever node the host returns inside each layer’s row. WorkItemsPanel mounts CalculatorModalHost for project-level calculators, which pass renderLayerAssignment. Legacy mounts for the embedded component-stored and work-item-level jsonb calculators remain in place for any surviving unconverted data, but the Estimate API no longer writes those columns — all embedded configs were converted before migration 0058 deployed. Destination grouping. groupDestinations in calculatorAssignments.ts builds the destination list in the following order:
  1. The focus work item (the item whose row was clicked to open the modal), if any.
  2. All other work items and their eligible activity containers, grouped by the estimate’s own section headings in the outline’s order.
  3. Unsectioned items last.
Previously every non-focus item appeared under a single “Other work items” heading regardless of section. Items within each section group are sorted by item number. Unassigned row. layerAssignmentChoices prepends an Unassigned row as the first real option in the picker. Selecting it holds a clear for that layer, like any other pick. Nothing is written until the estimator saves the calculation. flushPendingAssignments in useLayerAssignments then runs after the config write, and applyPendingLinks sends that layer with no links. The result is an explicit unassignment that persists, rather than a layer re-seeded from the calculator’s default. This replaces the previous placeholder behaviour where the unassigned state was not selectable. Split strata. A stratum that D67 has partitioned across more than one destination shows its allocations as a read-only summary and links to the split editor instead of offering a whole-layer combobox. Offering a combobox there would silently collapse the partition to a single destination with no undo.

API client modules

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

Estimate feature load guard

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

Why a visit and not an id

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

useVisitGeneration(key)

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

useGuardedLoad(currentVisitRef)

Call once per loader — each call owns its own monotonic token so a documents load and a summary load cannot cancel one another.
The guard enforces three checks in order:
  1. Refuse at issue time — if the caller’s visit differs from currentVisitRef.current, the load is not started. Checking only after the await would bump the shared token and cancel the current load before discarding the stale one.
  2. Per-loader monotonic token — each issued request increments an internal counter. An earlier response from the same visit cannot overwrite a newer one.
  3. Visit check on commit — the response is discarded if the visit changed while the request was in flight.
For the current Activities remote routes, package pins, and list scroll behaviour, see Activities frontend.

Activities feature module structure

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

Activities list scroll containment

From @stralos-co-nz/ui 0.69.1, the Activities list and the New rate sheet package picker scroll within their own bounded box rather than scrolling the full page. Each list owns its scroll container, so the page header and navigation remain fixed while the list scrolls independently. Sections that opt out of this containment and retain page-level scroll (scroll="page") are:
  • Rate sheets list and detail (shown together on one page)
  • Activity rates and production profiles sections
  • Sheet preview and the rate sheet refresh report
The three rate-sheet pages (RateSheetsPage, NewRateSheetPage, CreateRateSheetPage) are gated behind the PostHog feature flag rate-sheets (fallback: false). withRateSheetsFlag wraps each lazy page component. The HOC holds the page unmounted until the flag resolves, then either renders it or redirects to /activities. This prevents the wrapped pages from issuing data-loading requests before the flag is known. Redirect behaviour by flag state: Enable the flag in PostHog targeting for an organisation to make the Rate sheets page accessible to its members.

Activities calculator save model

Nothing about a calculator reaches the server until the estimator presses Save in the modal. Opening the modal does not create a calculator record. All choices — geometry edits, Used by assignments, and renames made inside the modal — are held in client state and flushed in a single request sequence when Save is pressed. CalculatorEditorState tracks what the modal is open on. stored is null until a calculator has been saved; config is the geometry as last saved (or the engine’s defaults for a new calculator); name is the estimator’s typed name, or null if untouched (omitted from the create so the server allocates “Calculator n”). On Save, saveCalculator runs:
  1. Calls POST /activity-calculators (create) or PUT /activity-calculators/:id (update) with the geometry and, when changed, the name.
  2. If any Used by choices are held (pendingRef), calls flushPendingAssignments to write only the applicable choices via PUT /activity-calculators/:id/links.
  3. Closes the modal only when both steps succeed. A refused create leaves the estimator’s geometry on screen.
A rename made via the header pencil inside the modal is held the same way — it rides the geometry PUT rather than sending a name-only request at the moment of typing.

CalculatorAssignmentsDialog layout and warning behaviour

CalculatorAssignmentsDialog (opened from an activity’s action menu) lists every calculator feeding the activity and lets the estimator assign or reassign each layer. Single footer Save (F-CALC-01): one Save destinations button sits in the side-sheet footer beside Cancel. It saves every calculator whose destinations differ from the stored register in a single action. The button is disabled while nothing has changed. Previously the button appeared inside each calculator’s card; moving it to the footer keeps the save action consistent across the editor. Warning display: a successful save that returns allocation warnings (warnings from the service, engineWarnings from the engine) keeps the dialog open so the estimator can read them. Warnings are displayed as orange-bordered notices. The dialog does not auto-close after a warned save; the estimator closes it manually once they have read the messages. Common warning messages:
  • "<Layer> is N% allocated; the other N% lands nowhere." — a partitioned stratum whose shares sum to less than 100%.
  • "N m³ of <layer> is not allocated anywhere." — the engine’s own under-allocation report.
  • "The reinstatement stack asks for N m³ more material than this excavation produces, so that much has to be bought in." — reuse exceeds available excavation volume.
Multiple-calculator saves: when more than one calculator is shown, each is attempted in sequence. A refused calculator keeps its drafts on screen beside the refusal that names it. A calculator that saved successfully still triggers a row refetch even if another was refused.

Activities layer assignment control

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

Assignability states

layerAssignability (from src/layerAssignment.ts) decides what the control renders for a given layer: The no-quantity state covers two distinct sub-cases detected by layerPresence:
  • no-output — the layer is in the saved config and the engine emits a row for it, but the row carries no outputs (zero depth, or a bedding layer fully displaced by a pipe). The layer is saved; the geometry simply produces nothing.
  • declared-only — the layer appears in the stored config but the engine emits no row for it at all (e.g. the engine could not derive a measurement from the current geometry). This is “saved but produces nothing”, not “not saved yet”.
The distinction matters for error messages: absent means “not saved yet” (correct for a just-added layer); declared-only means “saved, but produces no quantity” (which previously read as “not saved yet” before this fix, misleading the estimator about what they needed to do). configDeclaresLayer performs a generic recursive walk of the stored config object, matching any id field value — rather than a per-arm enumeration of layer arrays. This ensures new singleton layer fields added to future calculator types do not silently fall through to the “absent” arm.

All choices are held until Save

Every Used by choice — whether the layer is new, existing, or being cleared — is held as a PendingAssignment in host state until Save. The previous model wrote immediately when the layer was already saved; this caused the estimator to write partial state before pressing Save and made the two paths (saved vs unsaved layer) diverge silently over time. flushPendingAssignments runs after the config write. It calls partitionPending against the post-save vocabulary and classifies each held choice:
  • applicable — the layer exists in the post-save vocab and the choice can be written. Pending clears (activityId: null) are applicable whenever the layer is still present.
  • noQuantity — the layer is saved but the engine emits no row. The choice is not applied; the estimator is told which layer and why. The cell also shows this reason, but the modal that contains it closes on a successful Save, so the message is raised at the save boundary.
  • missing — the layer no longer exists in the post-save config. The choice is silently dropped; a deleted layer’s links are gone with it.
Dropped choices (DroppedChoices exception) are raised at Save, not in the cell, because a deleted layer has no cell left to render into. The shared modal catches the throw and shows the message inline without closing.

layerPresence

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

Library calculator workbench routing

Library calculators open as full-page workbench routes instead of a cover dialog. Two routes are registered: Both routes are keyed by calculator. The activity ID appears only as the draft’s default; a stored calculator’s address does not name an activity. ActivityCalculatorPage renders the calculator workbench. useCalculatorLeaveGuard intercepts navigation away from the page and browser reloads, asking Discard changes? when unsaved work is present. Save calls allowLeave before navigating so the guard does not block a completed save. When a user arrives from the Activities list, router state records the list as the back destination, preserving its scroll and search state. A direct or pasted link has no prior list state, so leaving replaces the page with /activities instead of stepping backward through unrelated browser history. The Used by pickers load the whole activity library independently of the filtered list. If that whole-library read fails, they fall back to the first page instead of appearing empty.

Calculator row ownership

Calculator engines publish physical layer outputs without price or classification fields. The Estimate and Activities services reconcile those outputs through project or library assignment registers. Each destination work-item or activity-component row owns its rate item, rate option, unit cost, category, and rate group, so recomputing geometry does not overwrite the estimator’s pricing selection. The embedded calculator columns on estimate work items and components are retired (migration 0058). The Estimate API now ignores the calculator key on write. Rows previously generated from embedded calculators survive as ordinary components and are no longer cleared by a null write or by a quantity reaching zero.

Rates input formatting

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

Work-items-capture M2: pricing snapshot and autosave

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

Inline grid editor (InlineQuantity / InlineMarkup)

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

State model

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

Commit rules

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

Grid keyboard traversal

Cells opt into cross-cell movement by passing a gridCol prop. The nearest [data-grid-nav-scope] ancestor is the traversal scope. Busy-cell navigation — aria-busy is read via getAttribute, not the reflected .ariaBusy property (which is undefined under non-reflecting engines such as happy-dom). The current (anchor) cell stays in the candidate list while mid-save; only destination candidates are screened, skipping disabled or aria-busy cells so focus never lands on one mid-save.

Project settings feature module structure

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

API wire invariants

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

Project status label resolution

Every surface that shows or names a project status resolves its label through the project_statuses setting list, using resolveProjectStatusLabel from @stralos-co-nz/domain (projectStatusLabels.ts). Nothing in the suite carries a value→label map; the words themselves are seeded into the list. The list is platform-scoped (organisation_id NULL) and relabel-only — it is edited in the admin console (PLATFORM_ENUM_KEYS in stralos-admin-api), not by an organisation admin, who sees it in Settings but whose writes return 403. A relabel is therefore suite-wide, not per-organisation. useSettingList seeds a fallback options array while the real list is in flight — and again if the fetch fails or the list is empty — whose entries are { value, label: value }, i.e. raw lowercase enum values. Echoing those verbatim puts “estimating” on a badge. resolveProjectStatusLabel therefore takes the hook’s state as one StatusLabelSource object rather than a loose options array, so its isFallback flag travels with the options and cannot be dropped at a call site. When the flag is true every status goes through formatStatusLabel; when it is false the stored platform label wins. PROJECT_STATUS_VALUES (same module) is the eight lifecycle values in order — lead, estimating, submitted, awarded, active, completed, lost, archived — and doubles as the built-in fallback and as the option list for status pickers. Usage rule:
  • Use resolveProjectStatusLabel whenever copy names a project lifecycle status. Pass the complete StatusLabelSource, including isFallback.
  • Use formatStatusLabel for a status enum that has no authoritative display-name list and for unknown legacy values. If another status becomes setting-list-backed, add or use its domain resolver instead of flattening it with formatStatusLabel.
  • Search the current source for resolveProjectStatusLabel and formatStatusLabel when auditing callers. That is the canonical caller inventory; a hard-coded list in this document would drift.
ResourceScheduler in stralos-ui remains presentational and has no setting-list loader. Its meta.projectStatusLabel field therefore accepts an already-resolved string, and each feature-side mapper resolves the project status before handing it to the component.

useSettingList broadcast to live consumers

useSettingList (in @stralos-co-nz/ui) caches each resolved setting list in a per-session Map so multiple components that consume the same list key issue only one fetch. Prior to 0.14.0, a successful load (or a successful retry after an earlier failure) only reached the component that triggered it. Components mounted before the successful load — for example the header project selector — would therefore keep showing humanised fallback values for the rest of the session while a concurrently mounted page showed the platform-managed labels. The two would disagree without any visible error. Fix (0.14.0): after a successful load, broadcastSuccess pushes the result to every currently mounted consumer of that list key. Only successes are broadcast; broadcasting a fallback could move a consumer that already holds real labels backwards. Each mounted useSettingList instance registers a subscription on mount and removes it on unmount; the broadcast checks the subscription set to avoid updating an unmounted component. Consumer rule: no change is required in consumer code. The fix is internal to useSettingList. The existing isFallback-aware label resolution (described above) remains the correct pattern for any new consumer.

Unit-catalogue presentation values

SettingListOption can preserve optional presentation metadata supplied by a loader alongside the stored value and full label: The setting-list API itself now returns only values and labels. Estimate, Activities, and Rates instead load the immutable GET /unit-catalogue endpoint and adapt symbol, category, and usage to the shared grouped selector’s displayValue, groupLabel, and useDescription option metadata. Consumer rule: use displayValue for compact unit labels, badges, or narrow cells, and fall back to label when it is absent. Do not expect presentation metadata on ordinary setting-list responses. The Estimate remote’s useWorkItemUnits helper applies that rule to work-item units. Grid and card labels use the matching catalogue displayValue when one exists, then fall back to formatShortUnit(value) for familiar built-in notation. The editor uses formatWorkItemUnitOption to format the open-list option as label (symbol) — for example, Cubic metre (m³). This matches the format used by the rates surfaces.

UnitCombobox component

UnitCombobox (exported from @stralos-co-nz/ui from 0.72.0) is a typeable combobox for unit fields. In the activity editors it replaces GroupedSettingSelect at both the activity Basis site and the component Unit site; the rates editor and rates import use it for their unit cells. Before 0.72.0, stralos-feature-activities and stralos-feature-rates each kept a local copy, and the two disagreed on a unit with no symbol: rates fell back to formatUnit(code), activities showed the raw code. The shared component takes the rates behaviour. Each remote moves to it when it pins ui 0.72.0 or later. ui has no API client, so load the catalogue with useSettingList and pass it in as options. The closed field shows the unit symbol only (e.g. m³). Opening the list or typing shows the full name (symbol) form (e.g. Cubic metre (m³), Tonne (t)). Typing filters by symbol, full label, or stored code. The filter is inactive when the field is first opened without typing — the full catalogue is shown, matching the behaviour of a native select. Only a catalogue option can be committed. Typing alone does not write a value; anything typed but not selected is discarded on blur, and the field reverts to showing the last committed unit. Featured values (metre, square_metre, cubic_metre, each, hour) are pinned in a Most Used group at the top of the open list, then appear again under their own category group.

Props

KeyCombobox component

KeyCombobox (in stralos-shell/src/KeyCombobox.tsx) is the shell’s standard pick-from-a-fixed-list control (PRD 09, wave 6). It replaces the five native <select> elements the shell previously owned: the Projects page status filter, the Global Settings project editor client and status pickers, the Drive Sync settings provider picker, and the Register Drive dialog provider picker. Typing is a way to search the list, never a way to write a new value. Opening the list always shows every option first — the filter is inactive until the user types. The component is generic over the stored value type so callers that hold a union (such as DriveSourceProvider or ProjectStatusFilter) receive that union back from onValueChange rather than a bare string. The main difference from shared SelectCombobox is the caller contract. KeyCombobox is generic over string keys and reports the chosen key through onValueChange; SelectCombobox accepts choice objects and returns the whole object through onSelect. Both controls fold accents when filtering. KeyCombobox always renders an unknown stored key as blank, while SelectCombobox makes token-versus-blank display configurable.

Props

Where KeyCombobox is used

Relationship to shared SelectCombobox

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

GroupedSettingSelect component (removed)

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

Dashboard lifecycle stats (lifecycle M4)

stralos-feature-dashboard owns /dashboard. The page combines project and active-estimate totals, an infinite project list, a lifecycle funnel, and a window-selectable win rate tile. Project rows link to the estimate workspace; the empty-state action delegates project creation to the workspace-owned /projects?action=add-project route. The project list, active-estimate count, and lifecycle stats are independent requests. A list error shows the page’s retry banner; retry reloads all three. A stats failure never sets that banner. The lifecycle request uses GET /api/global/projects/stats and supplies the funnel and win rate tile.

Stats endpoint contract

The window query parameter defaults to 90d server-side. Explicit from/to calendar-date bounds override window; when both are present, the server ignores window. The response echoes the computed window in winRate.window (or "custom" for an explicit date range); the dashboard always labels the tile with the echoed window rather than the locally selected value to avoid labelling stale counts with a new selection. The dashboard’s getProjectStats client (in src/api.ts) accepts all three parameters — window, from, and to. The tile UI exposes only the three window buttons; from/to are available for programmatic use.

Graceful degradation

If the stats request fails for any reason, the dashboard falls back quietly:
  • The pipeline funnel shows a dash (—) for every status count.
  • The win rate tile shows Stats unavailable.
  • The project list and active-estimate count are unaffected (they use a separate listProjects call).
Never treat a failed stats fetch as a fatal error. The error banner in the project list belongs exclusively to listProjects failures.

Status label resolution

Status labels for the pipeline funnel, project list badges, and the Active estimates tile are resolved via resolveProjectStatusLabel from @stralos-co-nz/domain. DashboardPage loads the project_statuses setting list via useSettingList and passes the full list state (including isFallback) to resolveProjectStatusLabel, so labels are always humanised — never the raw lowercase enum token — even while the list is loading or unavailable. project_statuses is a platform-scoped list. It can be relabelled in the admin console but cannot be modified by organisation admins. A relabel is suite-wide.

Win rate display rule

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

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

The @stralos-co-nz/calculator-ui package supplies trench, pit, and volume calculator modals over the domain calculator engine. Hosts inject disposal substances, Bulk Materials with both density bounds, and disposal rate items where that calculator surface supports them. Calculator screens, diagrams, and presentation helpers come from this package. General controls come from @stralos-co-nz/ui; model types and arithmetic come from @stralos-co-nz/calculators. Base UI 0.51.0 removes the former calculator exports from @stralos-co-nz/ui and the ui/calculators subpath. Each modal edits geometry and physical snapshots. The engine returns layer rows with stable keys and optional outputs: inSituM3, looseM3, tonnes, tonnesMin, tonnesMax, areaM2, and lengthM. The two density-bound outputs are display-only; assignment registers link to the single-value output vocabulary. Project and library calculators render Used by controls for assignable layers. A save updates geometry and reconciles linked destinations. A name-only update renames without saving the draft. See Trench calculator, Pit / Structures calculator, and Volume calculator for the engine contracts.

Package source

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

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

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

Resource grading in the assignment drawer

When the drawer opens for a create or edit action, loadProjectResourceOptions (in src/resourceOptions.ts) calls GET /api/scheduler/projects/:projectId/recommended?from&to and classifies each rostered resource as recommended or other. Classification rules (classifyResource):
  • If the project has no requirements of the resource’s kind (person/plant) → recommended, no reasons.
  • If at least one requirement of that kind lists the resource in its recommended array → recommended.
  • Otherwise → other, with reasons taken from the requirement of the same kind that has the fewest reasons (the closest match). If the recommendation payload omits the resource entirely (e.g. inactive user) → other with an empty reasons array.
People and crews are judged against person-kind requirements; plant against plant-kind requirements. Matching uses (resourceType, resourceId) — a bare ID match is intentionally avoided to prevent UUID collisions across kinds. Degraded state: if the recommendation fetch fails, onDegraded is called and buildResourceOptions falls back to all-recommended with an empty requirements array. The page surfaces a non-blocking toast: Showing all resources — Couldn’t load recommendations — resources aren’t graded right now. The warning fires at most once per drawer session (gated by recDegradedRef), and resets each time the drawer is opened. Incomplete window: if projectId, startDate, or endDate is absent from the draft, no fetch is attempted and all resources are returned as recommended. Saves are never blocked by a grading failure — the drawer stays fully functional in the degraded state.

Project navigation

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

Current risk

The EstimatePage.tsx split is complete. The remaining shared hooks and orchestration logic in EstimatePage.tsx are intentional: it acts as the top-level route component and state coordinator. Further decomposition should target extracting individual hooks if the file grows significantly. api.ts, TakeoffPanel.tsx, SuggestionsReview.tsx, and WorkItemsPanel.tsx have been split into flat sibling modules. The barrel in api.ts enumerates re-exports by name; do not switch it to export * because Module Federation cannot statically enumerate star barrels.