> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Onboarding frontend

> The onboarding checklist widget and launcher (stralos-feature-onboarding)

`stralos-feature-onboarding` is the Module Federation remote that owns the onboarding checklist. It renders a persistent launcher in the bottom-right corner for company admins and expands into a step-by-step panel that guides a new company through its initial configuration. It also owns the welcome dialog shown to admins while the Projects area is locked for setup.

## What this remote owns

The remote exposes `./widget`, which exports the widget together with its pass-through `RemoteProvider` and `REMOTE_KEY`. The shell wraps the widget once. The always-loaded entry stays Chakra-free; the full panel loads on first open.

Routes: none. The checklist is a floating overlay, not a page.

## Checklist sections and steps

The catalogue (`src/catalogue.ts`) defines every section and step in display order. Step ids are persisted server-side in `organisation_settings.onboarding.reviewedSteps` and must never be renamed.

### Sections

| Id | Title |
| - | - |
| `organisation` | Your company |
| `items` | Items and rates |
| `companies` | Companies |
| `team` | Your team |
| `project` | First project |
| `estimate` | First estimate |

The `estimate` section is locked until a project id is known.

### Section open state

The panel keeps at most one section open at a time (`openSection: SectionId | null` in `OnboardingPanel`). On mount, `openSection` is initialised to `progress.firstIncompleteSection`. Selecting a section header opens it and closes whichever was open. Selecting the already-open section's header closes it, leaving `openSection` as `null`. When a section transitions to complete (detected via `progress.firstIncompleteSection` changing), the panel sets `openSection` to the new first incomplete section, or to `null` if none remains.

### Step detection

`src/checks.ts` runs all reads in parallel under `Promise.allSettled`. A failed read, a non-2xx response, or a missing field yields `undefined` (shown neutral, never counted as done or not done). Steps are ticked by detection or by a person clicking **Looks good** (`markReviewed` PATCH).

`GET /api/global/organisation-settings`, `GET /api/global/org/users`, and `GET /api/documents/drive-sources` go through the shared-read store (see [Cross-remote shared reads](#cross-remote-shared-reads)). All other detection reads go directly to the network.

| Step id | Check |
| - | - |
| `org-settings` | Review kind — person confirms only |
| `office` | `GET /api/global/locations?owner=ours&status=active&length=1` — ticked when `totalCount > 0` |
| `item-categories` | Review kind — person confirms only |
| `rates` | `GET /api/rates/rate-items?length=1` — ticked when `totalCount > 0` |
| `activities` | `GET /api/activities/activities?length=1` — ticked when `totalCount > 0` |
| `resources` | `GET /api/scheduler/people?limit=1`, `plant`, `crews` — all three must have at least one item |
| `companies` | `GET /api/global/parties?length=1` — ticked when `totalCount > 0` |
| `invite-team` | `GET /api/global/org/users` — ticked when more than one user is returned |
| `shared-drive` | `GET /api/documents/drive-sources` — ticked when at least one source exists |
| `first-project` | `GET /api/global/projects?length=1` — ticked when `totalCount > 0` |
| `open-estimate` | `hasEstimate` flag from the onboarding summary |
| `takeoff` | `hasTakeoff` flag from `GET /api/estimate/onboarding-summary` |
| `calculator` | `hasCalculator` flag from the summary |
| `work-item` | `hasWorkItem` flag from the summary |
| `linked-quantity` | `hasLinkedQuantity` flag from the summary |
| `build-up` | `hasBuildUpLine` flag from the summary |
| `review-totals` | Visit kind — ticked by visiting `/projects/:id/estimate/tender-submission/margins` |
| `submit-estimate` | `hasSubmittedEstimate` flag from the summary |

### Office step

The `office` step ticks as soon as the organisation has at least one own active location. It does not depend on the organisation's `defaultLocationId`. Each user's default office falls back to the first-created office until they choose their own.

The step copy reads: "Projects run out of an office; your first office becomes everyone's default until they choose their own."

### Resources and drive-sync steps

`resources` is hidden when the `mvp` feature flag is set. `shared-drive` is hidden unless the server's drive-sync switch answers ON (`drive-sources/onboarding-config` enabled). Hidden steps are not shown and not counted.

## State management

`src/onboardingClient.ts` handles the state reads and writes.

| Operation | Endpoint |
| - | - |
| Read settings | `GET /api/global/organisation-settings` |
| Mark reviewed / dismissed / completed | `PATCH /api/global/organisation-settings/onboarding` |

The PATCH body accepts `{ markReviewed?, unmarkReviewed?, dismissed?, completed? }` and requires at least one field. Only admins may write; any member may read. A new organisation can store a null onboarding value. The API normalises it to `{ reviewedSteps: [], dismissedAt: null, completedAt: null }`, so the checklist reads an empty progress state.

## Cross-remote shared reads

Module Federation bundles each remote independently, so the same `GET` issued by two remotes on one page load cannot coalesce at the network level. Three reads that this remote and the organisation remote (Settings page) make together are routed through the cross-remote shared-read store (`src/sharedReads.ts`).

The store lives on `globalThis.__stralosSharedReadsV1` (contract v1). An entry keyed by `${organisationId}:${userId}\x00${url}` is reused for up to 30 seconds. Reads that fail are never stored; if a joined promise rejects, the caller retries with its own transport.

| Shared path | Why |
| - | - |
| `/api/global/organisation-settings` | Settings page and estimate pages read the same endpoint on every hard load |
| `/api/global/org/users` | Settings page reads team membership on the same load |
| `/api/documents/drive-sources` | Settings page reads registered drive sources on the same load |

**Invalidation.** Every onboarding PATCH (`markReviewed`, `dismissed`, `completed`) calls `invalidateSharedReads` for the `organisation-settings` path in its `finally` block, so a write that is refused or dropped still forces the next read to go to the network. The organisation remote is responsible for invalidating `org/users` and `drive-sources` after its own writes.

**Contract version.** The global key is `__stralosSharedReadsV1`. If the store contract ever changes, the key must be bumped to `V2` in every remote that carries a copy, so mixed deploys bypass each other instead of misreading entries written with a different shape.

Callers pass `scope` as `${organisationId}:${userId}` from `sharedReadScope()` in `src/session.ts`. Passing `null` bypasses the store entirely (used for reads where the scope is not yet known). Passing `force: true` skips the cache and refreshes the stored entry.

## Detection trigger

On first load, once the flags have answered and only for an eligible admin (the checklist flag is on and the session role is `admin`), the widget reads `organisation-settings` alone. A member never costs the API a read. Only if the checklist is still open (neither completed nor dismissed) does it then call `runDetection`, passing `readSettings: false` to skip the duplicate settings read. This keeps a completed or dismissed organisation at one read per hard load regardless of how many check endpoints exist. A failed settings read shows nothing and spends no further reads.

After the initial load, `runDetection` is called when the panel opens, on a throttled route change, and on `window focus`. It never polls.

The billing read is not performed by this remote. The shell mounts the `floating-end` slot only inside its billing boundary's unblocked branch, so a separate billing read would duplicate the shell's own call.

## Launcher open state on sign-in

The guide opens expanded on every sign-in. A minimise holds for the rest of that sign-in, across reloads and route changes, and the guide opens again on the next sign-in (`src/launcher/launcherState.ts`).

The sign-in id comes from `src/launcher/signIn.ts`:

1. `stralos.auth.signInId` in `localStorage`, when the shell has written one. The shell writes it when a hand-off redemption or OIDC exchange stores a new session, and removes it in `clearSession` (see [localStorage session keys](https://help.stralos.app/internal/frontend-architecture.md#localstorage-session-keys)). Token refresh and company switching leave it unchanged. The remote reads this key and never writes it.
2. Otherwise, a per-tab id in `sessionStorage`, minted on first read. It survives reloads and route changes in that tab and is forgotten when the tab sees the session end (`watchSessionEnd`). A new tab gets a new id, so the guide opens there once.

The launcher state is stored in `localStorage` under `stralos.onboarding.launcher.<organisationId>.<userId>` as `{ "minimisedIn": [<sign-in id>, …] }`, newest last, keeping the last eight ids (`MAX_REMEMBERED_SIGN_INS`). Keeping it per company and per user means two admins sharing a browser each get their own state. Any other stored value, including the old bare `"expanded"` or `"minimised"` strings, reads as "not minimised in this sign-in", so the guide shows. Storage that is absent, full or refused never breaks the launcher.

## Welcome dialog

`src/welcome/` implements the welcome dialog: a full-screen ui `Dialog` titled **Welcome to Stralos**. It is the front door to the shell's Projects setup lock (see [Projects setup lock](https://help.stralos.app/internal/frontend-architecture.md#projects-setup-lock)) and loads as its own lazy chunk only when it is shown.

### Gate

`useWelcome` (`src/welcome/useWelcome.ts`) opens the welcome only when all of these hold:

1. The `onboarding-setup-lock` PostHog flag is on: PostHog has answered and the value is `true`. Still loading, a PostHog error, a missing or inactive flag, or a headless browser with no flags all count as off. This is the same flag that gates the shell's Projects lock, so both surfaces switch on and off together.
2. The session role is `admin`, and the person does not reach this company through a contractor company. `src/session.ts` sets `viaContractor` when the current membership in `stralos.auth.user` carries a non-blank `viaContractorName`.
3. This sign-in has not settled the welcome yet (see [Settlement](#settlement)).
4. `GET /api/global/organisation-setup` answers `complete: false` and `exempt: false`.

Conditions 1 and 2 are checked before any network read, so with the flag off, or for a member, nothing is fetched.

The welcome is not behind the `onboarding-checklist` flag or the checklist's dismissed state. While it is shown, the checklist launcher and panel are hidden.

`fetchSetupStatus` (`src/welcome/setupStatus.ts`) fails open. Anything other than a 200 whose body has boolean `complete`, `exempt`, `steps.office`, `steps.client` and `steps.rate` (a 404, a 5xx, a dropped request, or a body of the wrong shape) returns `null`, and `null` never shows the welcome.

### Contractor-companies flows

On `/settings/client-companies`, any path under `/settings/client-companies/`, and any path under `/settings/contractor-invite/` (`isContractorFlowPath` in `src/OnboardingWidget.tsx`), an open welcome stays unseen. It shows once the person leaves those flows, so it never covers creating a client company, bringing data across, or accepting a contractor link.

### Steps

The dialog lists the three steps that unlock Projects, in order:

| Step id | Title | Destination |
| - | - | - |
| `office` | Add your office | `/locations` |
| `client` | Add your first client | `/companies` |
| `rate` | Add or import your rates | `/rates` |

Each step shows a tick when its `steps` value is `true`. When every step is done, or the company is exempt, the dialog offers **Create your first project**. Focus lands on the first step still to do, otherwise on **Create your first project**.

While the dialog is open, a `window` focus event re-reads the setup status, so a step finished in another tab shows its tick when the admin returns. A failed re-read keeps the ticks already shown.

### Settlement

The welcome is asked at most once per sign-in, using the same sign-in id as the launcher. A sign-in settles as soon as the setup read gives a real answer, whether or not the welcome then shows. A failed read settles nothing, so the next load asks again, but a failure never shows the welcome.

Settlement is stored in `localStorage` under `stralos.onboarding.welcome.<organisationId>.<userId>` as `{ "settledIn": [<sign-in id>, …] }`, keeping the last eight ids. When there is no sign-in id, or storage is absent or refused, the welcome reads as already settled, so a dialog that cannot remember being closed does not come back on every load.

## Foreign-layer behaviour

The launcher and panel are aware of other remotes' overlays. When a zag dialog with `data-placement="end"` (a side sheet) is open and it is not owned by this remote, the launcher and panel slide left by `16px + sheet width` over 200 ms. When no room remains (`viewport − sheet < widget + 32`), they step out instead. A centred modal also causes a step out. Stepping out preserves the panel's open state; a Help panel that was open waits for the layer to close, then takes focus again.

### Bottom offset

The launcher and panel sit above two optional spaces, both read from custom properties on `<html>` (`BOTTOM_SPACE` in `src/launcher/launcherStyles.ts`):

* `--stralos-bottom-bar-height`: set by a page that owns a sticky bottom bar while the bar is mounted, and removed when it unmounts. Two bars publish it: the project settings `SettingsSaveBar` (`feature-project-settings`) and the **Company defaults** sticky footer (`OrganisationDefaultsSaveScope` in `feature-organisation`, rendered for admins only). Each measures its bar with a `ResizeObserver`, so the Setup pill sits just above **Save changes** without reserving empty page space. The launcher has no path-based special case: `/settings/*` shows the full pill, and only this property moves it.
* `--stralos-assistant-pill-space`: reserved for a host-owned bottom-end pill. The shell no longer sets it, because **Talk with Doug** is now a header button.

Each property falls back to `0px` when unset, so the launcher does not move on pages without a bar.

## Contract-v2 model

This remote follows the thin-host contract v2. The host provides `react`, `react-dom`, and `react-router-dom` as ranged singletons declared in `.ci/shell-federation-contract.json`. `@stralos-co-nz/ui` and `@stralos-co-nz/domain` are bundled as exact pins. Fonts are the shell's; nothing under `src/` may import a `@fontsource/*` path.

The `./widget` entry point must stay Chakra-free. The panel (`src/panel/OnboardingPanel.tsx`) is a dynamic `import()` on first open and mounts ui's real `RemoteProvider` around itself.

## Build and release pipeline

The pipeline follows the produce-gate-activate model described in [Micro-frontends](https://help.stralos.app/internal/micro-frontends.md) and [Release model](https://help.stralos.app/internal/release-model.md).

A push to `main` triggers `deploy-dev.yml`, which builds the remote, writes `built-against.json`, publishes the by-SHA bundle to `remotes/onboarding/<sha>/`, and nudges the reconciler. `build-remote.yml` builds and verifies on every push and pull request without publishing.


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