Skip to main content
stralos-feature-workspace is the workspace Module Federation remote. It owns the projects list and project overview, the companies register and company detail, the locations register and location detail, and the /clients* / /suppliers* redirects. Current shell source hands these routes to the workspace remote. stralos-core includes the workspace descriptor in the manifest seed and dev reconciliation set, and the shell no longer carries duplicate page implementations.

What this remote owns

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

Controller

WorkspaceControllerProvider (in workspaceController.tsx) reads the project a route addresses by id with getProject, and its client with getClient for the name. It also owns project and client creation. It does not read the client or project registers: the project selector reads the project directory itself. Retry clears a load failure or a refusal and checks the route project again. The persistentChrome export keeps one WorkspaceControllerProvider alive across authenticated route changes. Its Header renders the project selector, its optional Notice renders workspace load and missing-project messages, and the provider also owns the active workspace route outlet. This gives the selector and pages one data set, editor state, and toast scope. The export also provides two optional rail components, which the shell portals into its project rail slots (see Frontend architecture — Project rail slots):
  • RailHeader: the open project’s name, linked to its overview, then the project’s status as a StatusBadge, then the client name, at the top of the rail. The status label comes from useProjectStatusLabel (the platform-managed project statuses list), and the badge is kept to one line without an ellipsis. Because the controller re-reads the project after every reported write, a status change made on another page shows here. Nothing is rendered until the project has loaded, so the shell’s fallback (an All projects link) shows in the interim. Implemented in WorkspacePersistentChrome.tsx.
  • EstimateSwitcher (RailEstimateSwitcher) — the estimate dropdown beneath the Estimate rail label; see below.

Estimate rail switcher (RailEstimateSwitcher)

RailEstimateSwitcher (src/RailEstimateSwitcher.tsx) is the estimate-switching dropdown in the project navigation rail. It sits as leadingContent in the shell’s Estimate rail section — that is, it appears directly beneath the Estimate label row. Visibility. The switcher replaced the PostHog multi-estimates flag. It is shown only when multipleEstimatesEnabled is true on the response of listRailEstimates, which reflects the project’s Multiple estimates setting. When the setting is off, the list has not loaded, the load failed, or the list is empty, the component renders nothing and the Estimate label stands alone. Data loading. listRailEstimates is called on mount and again whenever:
  • The stralos:estimates-changed event fires (estimate created, renamed, made primary, or deleted). The event detail carries projectId; events for other projects are ignored.
  • The stralos:estimate-settings-changed event fires (the project’s Multiple estimates setting was saved).
  • The user opens the dropdown menu (to ensure the list is fresh before picking).
If a canonical estimate path names an estimate the loaded list does not include (for example, one just created in another tab), the switcher requests one more load before rendering. Display. The trigger is a full-width outline button showing the name of the currently open estimate, or the primary estimate’s name outside a canonical estimate path. A star (★) precedes the name when that estimate is the primary. The menu lists the live tender estimates, primary first, each with its status badge. Not-awarded estimates are grouped behind a Show not awarded (N) / Hide not awarded (N) toggle and carry a Not awarded badge. Switching. Selecting an estimate navigates to the same screen within the new estimate. Builder screens keep the section (for example builder/rates); deeper ids that belong to the current estimate, such as a calculator or subcontract package, are dropped to the section root. Selecting the current estimate is a no-op. Manage estimates. The Manage estimates… menu item dispatches stralos:manage-estimates (OPEN_MANAGE_ESTIMATES_EVENT, detail { projectId }), which opens the Estimates sheet over the current page.

Estimates sheet (ManageEstimatesSheet)

ManageEstimatesSheet (src/ManageEstimatesSheet.tsx) is mounted once in the persistent chrome, so it is available on every project page. Anything may open it by dispatching stralos:manage-estimates with { projectId }: the rail’s Manage estimates…, the estimate overview’s Manage estimates button, and Project settings’ Manage estimates button. It opens over the page that asked and never navigates on its own. One sheet has three views: the list, New estimate and Rename. Delete and Make primary refusals are computed client-side to mirror the server’s 409s, and a duplicate is offered the name “Copy of …”. A change (new, rename, make primary, delete) fires stralos:estimates-changed ({ projectId, estimateId? }), on which the page under the sheet refreshes its data in place. The only navigations are the user’s own: choosing an estimate opens it on the same screen, and deleting the open estimate moves to the primary on the same screen. The legacy ?manage=estimates query parameter is still honoured by the estimate remote, which strips it and dispatches the same open event. The project editor (ProjectEditorModal) is opened by the controller and shared between the Projects page and the project selector. Both the New project button on the Projects page and the New project button in the selector header open the same sheet, backed by the same controller state. A client created inside the sheet is reflected in both the editor and the selector without a page reload. The remote remembers the last valid project route in a user-and-organisation-scoped stralos.workspace.lastProjectPath.v1:* entry. It publishes that path and the current projectMissing fact to the shell through the version-1 HostChromeInput contract. The shell validates and scopes those facts before using them for the Projects navigation destination or a missing-project mask. The old unscoped stralos.nav.lastProjectPath key is not adopted.

Contract-v2 model

This remote follows the thin-host PRD contract v2 (D1/D3). The host provides react, react-dom, and react-router-dom as strict singletons; @stralos-co-nz/ui and @stralos-co-nz/domain are bundled inside the remote as exact pins from this repository’s own pnpm-lock.yaml. A ui or domain release is a pull request here, not a suite-wide pin wave. The singleton list lives in .ci/shell-federation-contract.json. vite.config.ts derives SHARED_SINGLETONS and FEDERATION_SHARED from it; scripts/verify-federation-contract.mjs refuses a shared entry for ui or domain, an exact pin where a range belongs, or a vite config literal outside the contract file. UI is pinned at 0.75.4. @stralos-co-nz/maps-ui is also bundled as an exact pin at 0.10.0; it supplies the LocationPicker used in LocationFields and ProjectForm. Fonts are the shell’s: nothing under src/ may import a @fontsource/* path or ui’s fonts subpath, and build:remote fails if dist-remote carries a font file. scripts/check-package-compat.mjs runs before pnpm install in CI and refuses a lockfile that resolves a known-incompatible first-party package pair. The current guards are: when ui >=0.57.0 is resolved, maps-ui must be >=0.3.0; when ui >=0.73.0 is resolved, maps-ui must be >=0.5.0. maps-ui 0.1.x and 0.2.0 import SupplierEditorModal, which ui 0.57.0 removed, and maps-ui 0.4.0 and earlier import rate-reference exports that ui 0.73.0 removed; pnpm resolves that pair without error because the published peer ranges of those maps-ui releases still admit the newer ui. The check is a pre-install lockfile read and needs no install itself. When a ui release removes something a published downstream package imports, add the pair to INCOMPATIBLE in scripts/check-package-compat.mjs, and in the same file in every other remote that carries it.

Build and release pipeline

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

Routes contract

build:remote writes dist-remote/routes-contract.json alongside built-against.json. Every publisher syncs dist-remote/ to remotes/workspace/<sha>/, so the routes contract is available at that prefix without cloning this repository. The shell can fetch it by the manifest-named version (Stage 3 of the shell-reduction federation-routes-contract programme). The committed contract file is .ci/routes-contract.json. It is code-only: remoteKey, contract, exposes, and routes[].path in code order. Presentation metadata (navLabel, order, group, and so on) is the shell/seed’s responsibility and is not emitted here. The contract is derived and verified in src/routes.contract.test.tsx. That test imports the real ./routes module, runs the vendored deriveRoutesContract helper over it, and asserts byte-identity with .ci/routes-contract.json. The vendored helpers (scripts/derive-routes-contract.mjs and scripts/verify-routes-contract.mjs) are copied byte-identical from stralos-core; scripts/routes-contract-vendor.test.mjs keeps them in sync by running a self-test that detects a one-byte drift. Adding, removing, or reordering a route fails src/routes.contract.test.tsx — this remote’s own CI — before build:remote runs. build-remote.yml re-asserts the published artifact carries routes-contract.json and that it is contract version 2 before upload.

Size gate

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

Deterministic builds

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

Project membership access

Projects use explicit membership. ProjectDirectoryEntry carries three access flags:
  • canEnter: true when the current user is a member of the project team. The projects list shows a lock icon (Lock from ui) beside the name when false. The project command palette and project selector also show “Access required” and suppress the job and quote numbers for inaccessible projects. For an accessible project they show the job number, or the quote number when there is no job yet.
  • canManage: true when the current user can administer the project team (project lead or org admin). Drives the Manage team button in the directory and the Manage project team link in the access teaser.
  • canSelfJoin: true when the user is not on the team but the project’s office is one of the user’s self-assign locations. Drives Join project in the directory row (in place of Request access) and in the access teaser. Optional on the wire; absent means false.
listProjectDirectory returns membershipCount (projects the user is on), organisationCount (total projects) and an optional canSeeAllProjects, read as true when absent. It is false for a contractor person in a company they do not manage: api-global then lists only the projects they are on, so there is no “all” scope to show. The Projects page subtitle reads “You’re on N of M projects.” when canSeeAllProjects is true, and “You’re on N projects.” (or “1 project”) with no company total when it is false.

Access check window

Project-scoped reads use createProjectAccessChecks (in projectAccessChecks.ts) to decide when to send a GET /projects/:id access check. A confirmed check answers for READ_CHECK_WINDOW_MS (30 seconds): a read that arrives within that window is not treated as new evidence and does not schedule another check. This prevents a page that triggers several parallel reads from firing two to four trailing checks after the entry check has already confirmed access. The window is voided by any outcome that is not a confirmation: a 403 or 404 refusal, a failed document load, or a route change each trigger an immediate check regardless of how recently the last one confirmed. A check that does not confirm (for example an outage 503) also voids the window so the next read checks again.

Self-join

SelfJoinConfirm is the one confirm dialog (“Join this project?”) shared by the directory and the access teaser. Confirming calls selfJoinProject (POST /api/global/projects/:id/self-join), which adds the caller as a member. api-global allows it only when the project’s office is one of the caller’s self-assign locations, returning 403 self_join_not_permitted otherwise, and it is idempotent for someone already on the team. When officeLocationName is present, the dialog’s consequence line reads “It is run out of {office}, one of the offices you can join projects at.” On success a toast (“Added to the project team”) is raised after the dialog closes. In ProjectDirectoryPage, clicking the name of a project with canSelfJoin opens the confirm instead of navigating to the teaser; joining then opens the project. If the server refuses the join (any ApiError), onRefused re-reads the directory, because the offer was stale.

Access teaser

ProjectAccessTeaser is shown when navigating to /projects/:projectId and canEnter is false. It displays the project name, client, office (“Not set” when absent), project lead, key dates, and a request form. The user can:
  • Join the project (when canSelfJoin is true) through SelfJoinConfirm; onJoined then re-evaluates access.
  • Submit a new request via requestProjectAccess (POST /api/global/projects/:id/access-requests).
  • Withdraw a pending request via decideProjectRequest with state "withdrawn".
  • Re-request after a declined request (the form re-enables when no pending request exists and the most recent request was declined).
The teaser note for a pending request reads: “Your request is pending. A project lead will decide; unanswered requests go to organisation admins after five working days.” This matches the escalation window enforced by api-global.

Project team sheet

ProjectTeamSheet is a SideSheet (size lg) that loads team and request data in parallel via getProjectTeam, listProjectRequests, and getProjectAccessHistory. getProjectTeam returns items (current ProjectMember[]) and candidates (org users not yet on the team). ProjectMember.provenance is "migration" for members migrated at rollout; those rows show “Added at rollout” instead of a grantedBy name and date. Write operations use setProjectMember (PUT /api/global/projects/:id/team/:userId) and removeProjectMember (DELETE). Both accept an optional replacementUserId for the last-project-lead guard: when removing or demoting the sole project lead, a replacement must be chosen before the operation fires. Org admins who are not yet on the team see an Add myself to project banner at the top of the sheet (rendered when !project.canEnter && !members.some(m => m.userId === ownId) && user.role === 'admin'). Pending ProjectAccessRequest items (state "pending") are shown at the top of the sheet. Deciding a request calls decideProjectRequest (PATCH /api/global/projects/:id/access-requests/:requestId). Access history is loaded via getProjectAccessHistory (GET /api/global/projects/:id/access-history) and shown on demand behind an Access history toggle.

Access requests inbox

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

Project team action

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

Entry point for scope=all

Navigating to /projects?view=access with an admin session initialises the directory scope to "all" so an admin responding to an access escalation notification lands on the full organisation list. ProjectDirectoryPage carries a search box via RegisterPage. The search runs server-side: the page holds one page of 25 rows at a time, so client-side filtering would search a twenty-fifth of the register. The server matches project name, client name, quote number, and job number. The search box debounces keystrokes by 200 ms before sending the request.

Projects page count and range

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

Projects page pagination and scope

ProjectDirectoryPage paginates in pages of 25. Previous and Next buttons appear in a single row below the table, together with a range label. When canSeeAllProjects is true, the page also provides scope toggle buttons (Mine and All in company); a contractor person without it sees no toggle. The page provides dropdown filters for status, office, client, and project lead. Changing scope or any filter resets pagination to page one. The Office column shows officeLocationName, or “Not set”. The Office filter replaced the old region text field; it sends officeLocationId to listProjectDirectory, and its options are the distinct offices across every project in the current scope (ignoring the other filters), sorted by name. The Office, Client, and Lead filter choices are loaded lazily, not on every page load. The page waits until the pointer enters the filter row, a filter control takes focus, or a filter is already set (so the selected option must be present in the list). When the table’s unfiltered first page (up to 200 rows) already covers the whole scope, it is reused as the options source with no further request. A larger scope pages through the directory to collect all choices, capped at ten pages of 200 rows each. Scope defaults to Mine. An admin landing on the page with ?view=access in the URL opens on All in company. The empty state message (“No projects match. Browse all organisation projects to request access.”) guides members who have no projects in scope. A user can pin individual projects. Pins are stored in localStorage under projectPreferences and appear first in the project selector.

Project navigation

ProjectOverviewPage reads the resolved project navigation from the host’s reactive window.__STRALOS_NAV__ snapshot. It does not rebuild links from a local manifest copy. This keeps the overview aligned with the routes the current host actually resolved, including degraded compositions. The overview page header carries a Back to projects link (via PageHeader.backTo) to /projects.

New project sheet

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

Office field

Office is required on create; the create action stays disabled until one is chosen. listOfficeLocations reads every page of GET /api/global/locations?owner=ours&status=active, so only the organisation’s own active locations are offered. Once both reads land, the sheet pre-selects the person’s own default office (GET /api/global/me/default-office) if it is in that list, or the only location when there is exactly one. The organisation’s defaultLocationId is retired and no longer read. The pre-selection updates the form’s baseline, so it does not count as an unsaved change. A Prefer resources from this office checkbox sits beside the picker and defaults to ticked. Both officeLocationId and preferResourcesFromOffice are sent on create.
  • When the office read fails, the field shows the error with a Retry offices button.
  • When the organisation has no active locations, the picker is replaced by a note linking to the Locations page (/locations).
  • When the save is refused with 422 office_location_invalid (the save failure carries field: "officeLocationId"), the message appears under Office, the field is cleared, and the office list is re-read. Picking another office clears the message.

Suppliers for this project step (SearchRadiusStep)

Once create succeeds, the sheet becomes the Suppliers for this project step (src/SearchRadiusStep.tsx), with Save as the primary action and Skip for now as cancel. The project now exists and has an address point, so supplier sites can be measured. Skip for now saves nothing, and pricing then steps through 50, 100 and 150 km by itself. The rules and wording live in src/searchRadius.ts and src/projectSuppliers.ts; the same distance rules are written separately in stralos-feature-project-settings’s SearchDistanceField, so change both. Search distance. A radio group offers RADIUS_CHOICES (5, 10, 20, 35, 50, 100, 150 km) and Other with a Distance in km input (1 to 2000 km, at most one decimal place). The choices are wrapping radios, not a SegmentedControl, because eight choices do not fit one line at phone width. suggestedRadius still steps only through 50, 100 and 150 km (RADIUS_STEPS), with 50 km when nothing could be measured. Site list. Sites within the radius are listed by siteLine (company and site label, then the road distance in whole km), nearest first. useRowCap (src/rowCap.ts) caps the list at MAX_LIST_ROWS (8) rows, measured from the drawn rows; past that the list scrolls inside itself, becomes a focusable labelled region, and a line below it reads scrollCue (“Scroll to see all N sites.”). A polite live region carries countSentence, NO_RATES_NOTICE or MEASURE_FAILED_NOTICE. Supplier groups. SUPPLIER_GROUPS asks the same question for Bulk materials (quarry_materials) and Disposal: “Let Stralos pick the cheapest within N km” (selected first, saves no rule) or Use a preferred supplier. The picker lists suppliers with a rate in the group within the radius, nearest first (pickerSuppliers, which always keeps the picked supplier), with Show all suppliers for the rest. A group with no supplier rates disables the preferred option. A pick saves one group source rule with the supplier and no site (all its sites) through PUT /api/rates/project-rate-settings/rule-set, keeping every stored rule in order. Save order. The distance is saved first; a distance failure keeps the person on the step with the error inline. A rule-set failure does not hold them: the step finishes and shows RULES_NOT_SAVED (“Preferred suppliers not saved”), pointing to Project settings → Rate preferences.

Project selector

The project selector in the persistent chrome header shows a searchable dropdown with two panels: Pinned on the left and Recent on the right. When searching or viewing all projects, the right panel switches label to Results. The trigger button always reads Projects with a chevron. It does not show a project name. The selected project context is tracked internally by the controller and used for smart navigation (switching projects while inside a workspace keeps the same sub-route).

Project switch destination rules

projectDestination(projectId) computes the destination at render time so each row can be a real <a> element. The rules, in order:
  1. Inside a project route, switching to a different project — keep the sub-path but replace the project id. If the current sub-path contains an open estimate id (the estimates/:estimateId/… form), strip it to the bare estimate/… form before swapping the project id. An estimate id belongs to one project; carrying it to another would attempt to open that project’s pages under a foreign estimate id. The bare path is the estimate remote’s primary resolver and lands on the destination project’s own primary estimate (PRD 210 §12.2).
  2. Inside a project route, re-selecting the current project — return the current pathname as-is, including any open estimate id.
  3. Outside a project route — return the per-project detour stored in the in-memory history map (the last path recorded for that project this session), or that project’s workspace home if no detour exists.
If the computed destination fails isProjectChromePath validation, the destination degrades to the project’s workspace home. This keeps the row’s href valid even when the current route carries a fragment or query string that cannot be decoded. The withoutOpenEstimate function strips the estimate id: /projects/p1/estimates/e1/takeoff → /projects/p1/estimate/takeoff. It matches the estimates/:estimateId segment and replaces it with estimate, leaving the remainder of the path intact. It does not match estimates without a following id segment, or segments that merely start with the word estimate. The popover header holds a Mine / All projects SegmentedControl and a New project button that opens the shared editor. The toggle renders only when canSeeAllProjects is not false; a contractor person sees only their own projects, so the header shows just New project. Scope defaults to Mine when the popover opens. The search input sits below the header and is always visible. The status line reports the number of results and how many are loaded, or shows “Your projects: X pinned and Y recent” when not searching. Pinned projects come from the user’s stored preferences and are resolved against the directory in bounded batches. Stale pin ids (projects the user can no longer access) are silently omitted. Pins can be toggled from both the selector and the ProjectDirectoryPage register. A project row is a real <a> element so Ctrl/Cmd-click and middle-click open the project in a new tab rather than switching the current tab. The row for currentProjectId (the selected project, or the most recently opened one when none is selected) shows a teal Current badge, whose text is part of the link’s accessible name. The result list uses virtual scrolling (VirtualProjectRows). The viewport is fixed at 320 px tall and only the visible rows (plus a two-row overscan) are mounted in the DOM. The active row is always mounted regardless of scroll position, so keyboard navigation keeps it reachable. A further page is requested automatically when the user scrolls or arrows within one row of the loaded end and more results exist. Keyboard navigation via ArrowDown triggers the same load-more path when the active index approaches the end of the loaded set. The footer of the popover shows Browse projects, an outline button linking to /projects, and the count “X of Y projects” (“X projects” when canSeeAllProjects is false).

Project editor client picker

The Client field in ProjectEditorModal uses SelectCombobox from @stralos-co-nz/ui with two mutually exclusive create paths:
  • A typed create row: when the user has typed something, the list shows Create client "<typed text>". Selecting it carries the typed text into the new client’s name field.
  • A standing create row: when the field is empty, New client appears at the bottom of the list and opens the client editor with a blank name.
Selecting either row opens the client editor as a step inside the project side sheet. The client step has its own dirty guard, inline error path, and primary action. When the client step saves, the new client is merged into the list and selected in the project form, and the sheet returns to the project step. There is no nested modal or separate dialog. Inline outcomes are used rather than toasts: an error that fires while the sheet is open is displayed above the action row, not in a floating toast. A toast under an open sheet layer is outside the modal stack and is neither reliably visible nor read out by screen readers. A staleness guard (clientReadRevision) prevents a concurrent list refresh from overwriting an optimistic client merge. The createdClient ref and the onClientsChanged callback carry the new row out to the controller on success.

New client location

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

Project editor date fields

Target submission and Proposed start date in ProjectEditorModal use DateField from @stralos-co-nz/ui. The previous <input type="date"> rendered in the browser locale — a US-locale machine showed and parsed mm/dd/yyyy, so a date typed as 03/04/2026 meaning 3 April stored 4 March. DateField displays dates as d MMM yyyy and parses day-first (3/4/2026, 7 Sep 2026, 2026-09-07). It hands the form an ISO date string; a blank field stays "" so an untouched form is not dirty and sends no date in the upsertProject payload.

Project location geocoding

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

API error text

All screens in this remote use userFacingError (in errorText.ts) to turn a caught error into a sentence. The rules:
  • An ApiError with a detail field shows the server’s sentence. When the server also returns a reference, it is appended as Reference: <value>.
  • When ApiError.code is non_json_response, the server sent a non-JSON body. The caller’s fallback sentence is used instead.
  • A browser TypeError (for example Failed to fetch) uses the caller’s fallback sentence.
  • Any other Error whose .message is non-empty passes through as-is.
  • ApiError.message carries the HTTP status and the full API URL for logging purposes. It is never shown on screen.

Cross-remote shared reads

src/sharedReads.ts implements a short-lived in-page cache for GET requests that multiple federated remotes may issue independently during one page load. Each federated remote bundles its own HTTP transport, so two remotes that need the same resource would otherwise send two identical requests to the server. The cache lives on globalThis under the key __stralosSharedReadsV1. An entry stores the in-flight or settled promise and the time it was started. Callers pass a scope string (${organisationId}:${userId} from the session) and the exact request URL; a read with no scope bypasses the cache entirely. A hit that is younger than 30 seconds is reused: the reusing caller receives a structuredClone of the result so mutating the response is safe. If the shared promise rejects (for example, a 403 from a different remote’s transport), the reusing caller re-issues the request through its own transport so every remote sees its own ApiError class and HTTP status. Callers that must hit the network pass force: true (access checks, reads after a write, explicit refreshes). A forced read still stores its result in the cache, refreshing the entry for other callers. After a write, callers call invalidateSharedReads(scope, urlPrefix) to drop every cached entry whose key starts with that prefix. This file is copied verbatim into every participating remote. To change the contract, bump the global key to __stralosSharedReadsV2 (or higher) in every copy simultaneously so mixed deploys from different releases bypass each other’s entries instead of misreading them. Never edit one copy alone.

Busy and transient failure handling

A GET request that receives HTTP 503 with a Retry-After header of 10 seconds or less is retried once, after the delay the server specified. The retry fires in the same listAll* call so callers see it as a single promise. Only GET requests are retried. Write operations (POST, PUT, DELETE) are never replayed automatically: a write that received a 503 raises ApiError immediately so the caller can decide whether to surface it or retry explicitly. A second 503 on the retry attempt raises ApiError and is not retried again.

Save and refresh behaviour

Saving a project makes a single write to api-global (POST /api/global/projects or PUT /api/global/projects/:id) and nothing else. The estimate is not provisioned during save. The estimate, documents, and project-settings pages each call api-estimate when they open; api-estimate creates the estimate on first GET. The organisation’s margin ladder and hours per day freeze into the estimate at that point, not at project creation. This decoupling means an api-estimate outage does not cause a project save to fail or report a “Could not save the project” error. Previously the save also awaited a GET to api-estimate to trigger estimate creation; an api-estimate outage would mark a successfully-saved project as failed, and pressing Save again created a duplicate. When a project save succeeds but the follow-up list refresh fails, this remote reports the save as successful, navigates to the new project, and sends the refresh failure to the retryable load-error banner. This prevents a successful create from leaving the editor open and prompting a duplicate create.

Company and location detail pages

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

Layout

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

Back navigation

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

Save changes placement

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

Form field widths

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

Date fields

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

Tables

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

Status indicators

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

SettingsPanel removal

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

Confirmed deletes for contacts and documents

Deleting a contact or a document from a company record requires confirmation. Selecting Delete from the row action menu opens a ConfirmDialog that names the record and uses a destructive primary action. The delete does not fire on the first click. Both deletes are soft. The dialog reads “The contact is removed from . It’s kept for history.” or “The document is removed from . It’s kept for history.” in place of the default “This cannot be undone.” The reversibility prop is { reversible: false, note: "It's kept for history." } on both cases, so the default irreversibility sentence is suppressed. No estimates clause appears on the document variant: a company document can never be routed to an estimate.

Location detail geocoding

LocationDetailPage reads VITE_STRALOS_GEOCODE_COUNTRY via geocodeConfig() and passes the result as countryCodes to LocationPicker. This biases address search toward the configured country. When the variable is unset the picker falls back to its own default of nz. See Shell geocoding. Companies and Locations are route-manifest entries. The shell derives their navigation from the resolved workspace descriptor and retains only the ordering and visibility declarations needed to place them in the global rail.

Legacy client and supplier redirects

/clients, /clients/:id, /suppliers, and /suppliers/:id redirect to /companies rather than rendering their own pages. The redirect behaviour differs by path:
  • /clients and /suppliers redirect to /companies with the matching role filter pre-applied (?role=client or ?role=supplier) and an info banner explaining the move. Dismissing the banner clears it but keeps the filter.
  • /clients/:id and /suppliers/:id attempt to open the corresponding company record directly by using the id segment as a company id. If the id resolves to a company, the company record opens. If the API returns a 404, the banner reads “That client link no longer matches a company — search below for the one you wanted.” Any other failure shows the ordinary “Companies now cover clients, suppliers and subcontractors” banner (or “Companies now cover clients and suppliers” when useMvpHidesSubcontractors returns true), because a non-404 failure is not evidence the record is gone.
CompaniesPage reads its initial role filter from the ?role= query parameter and ignores unknown values.

Project selector chip

The project selector button always reads Projects with a chevron. It does not show the selected project name in the button text, and it does not fall back to the first project in the register when no project has been chosen. The controller tracks a selectedProjectName internally (exposed to the selector as a prop). When no project has been visited, the value is "Select project". The only project the controller names without a direct user choice is the one the user last navigated inside, recalled from the stralos.workspace.lastProjectPath.v1:* storage key. On a fresh session with no stored path, selectedProjectName is "Select project" and the visible button is unchanged. The project switcher popover omits client groups that have no projects. Previously an empty group appeared with a “No projects for this client” placeholder, which caused the word “All” (a common client name) to appear above a large list of projects belonging to other clients.

MVP flag: subcontractor visibility

useMvpHidesSubcontractors (in src/mvpFlag.ts) reads the PostHog mvp feature flag through useFeatureFlag("mvp", { fallback: true }). This is the same key, hook, and fallback the shell’s App.tsx uses to gate the MVP navigation (see Navigation order). When the hook returns true, the following UI elements are hidden:
  • The Subcontractor role checkbox and its help line in the company create and edit forms.
  • The Subcontractor role chip on company rows in the Companies register.
  • The Subcontractors role filter button in the Companies register (the ?role=subcontractor deep-link value is still accepted so existing links continue to work, but the button does not appear in the filter row).
  • The Subcontractor role in the company detail page’s roles list.
  • The Companies register subtitle changes from “One company can be a client, a supplier and a subcontractor.” to “One company can be both a client and a supplier.”
Because the fallback is true, subcontractor elements are also hidden while flags load, when the load fails, and when PostHog is unavailable. They appear only once a loaded flag says mvp is off. The shell nav behaves the same way, so both surfaces stay in sync. Display-only gate. Stored isSubcontractor values are never changed by this flag. An existing subcontractor company retains its role on save; its isSubcontractor: true still satisfies the “at least one role required” check even though the checkbox is hidden. The flag controls what the user sees, not what is stored.

Companies and Locations behaviour

Register row display

Each row in the Companies register shows the trading name (t/a …), the NZBN labelled as NZBN <number>, and location and contact counts sourced from GET /parties. The name is a link to the company record, alongside the Open button. Location and contact counts are optional on the wire. An absent count is not displayed rather than rendered as zero: a remote that predates the count fields in the API response would otherwise report every company as having zero locations, which is indistinguishable from an empty record and leads to duplicate entries.

Location register owner bands

The Locations register groups rows by owner. When the visible row count exceeds 20 (COLLAPSE_ABOVE), every owner band opens collapsed by default so the page loads as a list of owner headings rather than one long table. An Expand all / Collapse all button appears when there is more than one group. Clicking it overrides the default state for every group at once. Individual groups can also be toggled independently. Applying a search or ownership filter resets any per-group overrides: a filter is a request to see the matches, so results open expanded.

Company record back-navigation

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

Scroll-to-top on navigation

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

Location record back-navigation

LocationDetailPage derives its back-navigation target from backPartyId:
  • On the create form: the ?partyId= query parameter, which is set when Add location is invoked from a company record.
  • On a saved record: location.partyId from the loaded location.
When backPartyId is set, the back link goes to /companies/:partyId; otherwise it goes to /locations. Previously the back link always read ownerPartyId, which is only populated on the create form, so a saved company location offered Back to locations — the one register it is not filed in.

Company-owned location editability

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

Locations register foldable groups

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

Address display de-duplication

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

Location kind labels

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

Country select on location detail

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

Location form dirty guard

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

Location form required fields

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

Projects register row actions

The kebab menu on each project row carries three items: Edit project (opens /projects/:id/settings), Project rates (opens /projects/:id/rates), and Project documents (opens /projects/:id/documents). The name column remains the primary link to the project record. The kebab is labelled by the project name. DataTable falls back to the row id as the accessible label when getRowLabel is absent; without it screen readers would announce “Actions for row 0f9c1a…” instead of the project name.

Companies register row actions

Each company row carries an Open button and a kebab menu. The kebab holds Company locations (navigates to /locations?partyId=<id>) and Add location. The Open button is kept beside the kebab rather than folded into it: the name is the primary destination, and the kebab holds the row’s other verbs. Add location opens the LocationCreateSheet overlay in place rather than navigating to /locations/new?partyId=<id>. The sheet opens preselected to the company’s owner and returns to the same page on create or cancel. The /locations/new?partyId=<id> deep-link route still works for direct links.

Location register owner band counts

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

Location address display in register

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

Contract basis layout

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

DataTable scroll behaviour

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

List page scroll ownership

As of ui 0.69.1, each list register page owns its own scroll container. RegisterPage wraps its content in a bounded scrollport so the list scrolls independently of the browser window. This applies to:
  • Companies (CompaniesPage)
  • Locations (LocationsPage)
  • Projects (ProjectDirectoryPage)
  • Project directory (ProjectDirectoryPage via ProjectsRoute)
The company detail page’s Contacts, Locations, and Documents SectionCard panels opt out of this by passing scroll="page" to SectionCard. Those panels scroll with the page rather than inside their own bounded box, so a reader scrolling through a company record moves continuously down without entering a nested scrollport.

Register list region labels

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