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

# Guided page tours

> How page remotes author tours for the @stralos-co-nz/ui tour engine, and how the stralos-feature-tours widget remote hosts them

Guided page tours are stepped balloons ("Step 1 of 4") that walk a viewer round a page. Each tour is data: one `*.tour.json` per page, kept in the remote that owns the page and pointing at `data-tour` anchors in that remote's markup. There is no per-page tour code.

Two pieces work together:

* The tour engine ships in `@stralos-co-nz/ui` from 0.76.0: the JSON schema, the `registerTours` registry, anchor helpers and the `TourHost` renderer.
* The `stralos-feature-tours` widget remote (manifest key `tours`, slot `floating-end`, local preview port `4188`) mounts one `TourHost` for the whole application and wires its adapters. See [Micro-frontends](https://help.stralos.app/internal/micro-frontends.md#widget-remotes).

Each remote bundles its own copy of `@stralos-co-nz/ui`, so the registry is plain data on `window.__STRALOS_TOURS__` rather than a module singleton. `registerTours` writes to it and `TourHost` reads from it.

***

## In a page remote

### 1. Declare anchors

Declare the anchors in `src/tourAnchors.ts` and put them on the elements the tour points at:

```ts theme={null}
export const TOUR_ANCHORS = {
  addResource: "rates.add-resource",
} as const;
```

```tsx theme={null}
<Button data-tour={TOUR_ANCHORS.addResource}>Add resource</Button>
```

Shared blocks take the anchor as a prop: `RegisterPage` (`tourAnchor`, `actionsTourAnchor`, `toolbarTourAnchor`, `search.tourAnchor`), `PageHeader` (`tourAnchor`, `actionsTourAnchor`) and `SideSheet` (`tourAnchor`).

Anchors use the form `<area>.<thing>` with lowercase kebab segments, for example `rates.add-resource`. Never reuse a `data-testid` or an `aria-label` as an anchor.

### 2. Write the tour JSON

Put the file at `src/tours/<page>.tour.json`:

```json theme={null}
{
  "$schema": "../../node_modules/@stralos-co-nz/ui/dist/tours/tour.v1.schema.json",
  "id": "rates.library",
  "version": 1,
  "match": { "routeId": "rates" },
  "trigger": { "auto": "first-visit", "minViewportPx": 768 },
  "steps": [
    {
      "id": "welcome",
      "type": "dialog",
      "title": "Your rates",
      "body": "Here's a quick look around."
    },
    {
      "id": "add",
      "anchor": "rates.add-resource",
      "placement": "bottom-end",
      "title": "Add a resource",
      "body": "Please use this to add labour, plant or materials.",
      "roles": ["admin"],
      "waitFor": { "timeoutMs": 3000 },
      "onMissing": "skip"
    }
  ]
}
```

`id` is persisted, so never rename it. Bump `version` to show a changed tour again to everyone who has already seen it. `trigger.auto` is `first-visit` or `never`.

### 3. Register the tour

Call `registerTours` once at module load with the remote's tour files. Invalid tours are dropped with a console warning; nothing throws into the page.

```ts theme={null}
import { registerTours } from "@stralos-co-nz/ui";
import ratesTour from "./tours/rates.tour.json";

registerTours("rates", [ratesTour]);
```

***

## Targeting a page with `match`

`match.routeId` is the shell's route-catalogue id. The shell matches routes by prefix, so `/rates/items/123` still reports `rates`. A tour with only `routeId` matches every pathname under that route.

To pin a tour to one page, add `match.path`:

```json theme={null}
"match": { "routeId": "rates", "path": "/rates" }
```

* The tour matches only when both the route id and the pathname match.
* `path` is an exact pathname, compared segment by segment. `/rates` matches `/rates` and `/rates/` but not `/rates/items/123`.
* A `:param` segment matches any one segment: `/rates/items/:id` matches `/rates/items/123` but not `/rates/items` or `/rates/items/123/edit`.
* Trailing slashes, the query and the hash are ignored. Wildcards, `?`, `#` and empty segments are not allowed.
* Moving to a pathname the tour's `path` does not match ends the tour, as leaving the route does.

***

## Step rules

| Field | Behaviour |
| - | - |
| `type` | `tooltip` (anchored balloon) or `dialog` (centred). Defaults to `tooltip` when `anchor` is given and `dialog` when it is not. |
| `anchor` | A `data-tour` value. Required for a tooltip step; a dialog step has none. |
| `placement` | Floating placement: `top`, `bottom`, `left`, `right` and their `-start` and `-end` variants. |
| `roles` | `admin`, `member`, or both when absent. Steps the viewer's role excludes are skipped, and "Step n of N" counts only the steps they see. |
| `waitFor.timeoutMs` | How long to wait for the anchor. Default 2000 ms, at most 30000 ms. |
| `fallback` | What to show in the step's place when the anchor is not found in time. See [Empty pages](#empty-pages). |
| `onMissing` | When the anchor, and then any fallback, never shows: `skip` (default), `center` (show the step as a centred dialog) or `end` (stop here; what was shown counts as completed). |
| `actions` | Replaces the default **Next** or **Done** button. One to three actions, each with a `label` and one of `do` (`next` or `end`), `navigate` (an in-app path run through the host's `navigate`) or `event` (one of `TOUR_ALLOWED_EVENTS`). |

**Back** and the close button are always offered and cannot be removed. Tours carry no scripts or programmatic clicks. `TOUR_ALLOWED_EVENTS` currently allows `stralos:onboarding-open` and `stralos:user-settings-open`; widening it is a reviewed edit to both `schema.ts` and `tour.v1.schema.json` in `stralos-ui`.

Write tour copy in plain, friendly language. "Please" is fine. Never use "sorry", "organisation" or "administrator".

***

## Empty pages

A tour usually runs on someone's first visit, and for a new company that page is often empty: the first row a step points at does not exist yet. Give the step a `fallback` that points at the page's empty state:

```json theme={null}
{
  "id": "first-resource",
  "anchor": "rates.first-resource",
  "title": "Your first resource",
  "body": "Open a resource to see its rates.",
  "fallback": {
    "anchor": "rates.empty-state",
    "title": "No resources yet",
    "body": "Please add your first resource here, or bring in a price list."
  }
}
```

* A fallback has the step's content fields: `type`, `anchor`, `placement`, `title`, `body` and `actions`. Its `type` defaults the same way as a step's.
* Tooltip fallback: the step's anchor and the fallback's anchor are waited for at the same time, up to the step's `waitFor`. Whichever shows first wins. When both are present, the step's own anchor wins.
* Dialog fallback: the step's anchor is waited for, and the fallback shows centred if it never arrives.
* `onMissing` applies only when neither shows.
* Render the empty-state anchor only once the list has loaded and is empty. If it renders while the list is loading, the fallback can win before the first row arrives.
* The fallback takes the step's place: same id, same `roles`, same "Step n of N" position. It has no `roles`, `waitFor` or `onMissing` of its own.
* Only a tooltip step can have a fallback. A dialog step always shows.

***

## The tours widget remote

`stralos-feature-tours` holds no tours and no page knowledge. Its `./widget` entry is mounted by the shell in the `floating-end` slot of the authenticated chromed layout. That entry is plain React and stays free of Chakra. The engine (`TourHost` with Chakra and Ark) is a lazy chunk that loads only once a remote has registered a tour or something dispatches `stralos:tour-start`.

### Feature flag: `guided-tours`

The host is gated on the PostHog flag `guided-tours`, which is default on. Tours are hidden only when a flag request that succeeded evaluates `guided-tours` to `false` for the person. Every other case shows tours:

* PostHog is off, not loaded, blocked or bot-filtered.
* The flag request failed.
* PostHog did not send the flag. An inactive flag is never sent to the browser, so it shows tours.

To hide tours for people, the flag must be active and evaluate to `false` for them, for example a release group at 0%. A local stack needs no override.

The widget waits up to 1.5 seconds for PostHog's first answer before mounting the host. A `false` that arrives later unmounts the host, which ends any open tour without saving progress. The remote reads the flag in `src/toursFlag.ts` rather than with the ui `useFeatureFlag` hook, because that hook reads a missing flag as `false`.

### Adapters

| `TourHost` prop | Source |
| - | - |
| `routeId`, `pathname` | `window.__STRALOS_NAV__.currentRoute()`, re-read on the nav bridge's `subscribe` and on router location changes |
| `role` | The `role` on `stralos.auth.user`: `admin`, otherwise `member` |
| `navigate` | The nav bridge's `navigate`, only for a path that `isKnownRoute` accepts |
| `isBlocked` | The admin welcome gate (below). `TourHost` also waits by itself while any modal layer is open. |
| `progress` | api-global `GET /api/global/me/tour-progress` and `PUT /api/global/me/tour-progress/{tourId}`, with a strict `{ version, status }` body |

Progress is stored per user and company. `TourHost` loads progress once per mount, so the widget keys it by `${organisationId}:${userId}`.

### Admin welcome gate

For an admin, tours are held back until the onboarding welcome dialog has settled for the current sign-in, so a tour does not start under a dialog that is about to cover it. The gate reads the onboarding remote's own record, `stralos.onboarding.welcome.<organisationId>.<userId>` in local storage, without writing to it.

Once the welcome has settled, tours wait a further 1.5 seconds so the dialog's lazy chunk can load. The hold always ends 8 seconds after the page started waiting, because the onboarding remote may be absent or its read may fail. Members are never held.

***

## Host behaviour

### Starting by itself

Each page's tour starts by itself on the first visit to that page. There is no per-sign-in cap: finishing the Rates tour and then opening Activities starts the Activities tour.

A tour starts by itself when all of these hold:

1. Its `match.routeId`, and `match.path` if set, match the current page.
2. The viewer has no saved progress entry at the tour's `version` or later.
3. The viewport is at least `minViewportPx` wide (default 768 px).
4. No modal layer is open and `isBlocked()` returns `false`.
5. No other tour is running.

The page settles for 800 ms (`autoStartDelayMs`) before a tour starts. Within one visit to a page a tour never starts twice, even one that showed nothing. A new route id or pathname is a new visit.

### Starting on demand

Dispatch `stralos:tour-start` on `window`:

```ts theme={null}
// A specific tour
window.dispatchEvent(new CustomEvent("stralos:tour-start", { detail: { tourId: "rates.library" } }));

// The best tour for a route id
window.dispatchEvent(new CustomEvent("stralos:tour-start", { detail: { routeId: "rates" } }));

// The tour for the current page
window.dispatchEvent(new CustomEvent("stralos:tour-start"));
```

Without a `tourId`, a tour with `match.path` is picked only when the current pathname matches it. On-demand start works even when the viewer has already seen the tour.

### Progress

`progress.save(tourId, { version, status })` is called with:

* `completed`: the viewer finished the tour, used an `end` action, or followed a `navigate` or `event` action.
* `dismissed`: the viewer closed the tour, pressed Escape, or left the page.

A tour that showed nothing saves nothing.

### While a tour is open

* `<html data-stralos-tour-open="<tourId>">` is set.
* `stralos:tour-open-change` fires on `window` when a tour opens and closes, so other floating widgets can step aside. Its `detail` is `{ open, tourId }`, where `tourId` is `null` once the tour closes.
* Read whether a tour is open, and its id, from the `data-stralos-tour-open` attribute or the event's `detail.tourId`.

### Layering

Tours render at `TOUR_Z_INDEX` (2100), above modals (1400), popovers and `SideSheet` (1500), toasts (1700), tooltips (1800) and the `Combobox` panel (2000). A step cannot point into a modal dialog or sheet, because the modal makes the balloon inert.

***

## `TourHost` props

The tours widget remote is the only place that mounts `TourHost`. Its shape:

```tsx theme={null}
<TourHost
  routeId={nav.currentRoute()?.routeId ?? null}
  pathname={location.pathname}
  role={session.role === "admin" ? "admin" : "member"}
  progress={{ load: loadTourProgress, save: saveTourProgress }}
  navigate={(path) => nav.isKnownRoute(path) && nav.navigate(path)}
  isBlocked={() => welcomeIsPending}
  headerOffsetPx={64}
/>
```

| Prop | Required | Description |
| - | - | - |
| `routeId` | Yes | The shell's route-catalogue id for the current page, or `null`. |
| `pathname` | Yes | The current pathname. A tour with `match.path` ends when it stops matching. |
| `role` | Yes | `admin` or `member`. |
| `progress` | Yes | A `TourProgressAdapter` with `load` and `save`. |
| `navigate` | Yes | Runs a step's `navigate` action. Validate the path against known routes first. |
| `isBlocked` | No | Extra reasons not to start yet. An open modal layer always blocks. |
| `headerOffsetPx` | No | Height of the sticky header, used when scrolling an anchor into view. |
| `viewportPx` | No | Viewport width. Defaults to `window.innerWidth`. |
| `labels` | No | Overrides for `TOUR_DEFAULT_LABELS`: **Next**, **Back**, **Done**, the close button's accessible name and "Step n of N". |
| `autoStartDelayMs` | No | How long a page settles before a tour starts by itself. Default 800 ms. |
| `tours` | No | Use these tours instead of `window.__STRALOS_TOURS__`, for stories and tests. |

***

## Schema and validation

The JSON Schema is exported as `@stralos-co-nz/ui/tour.schema.json` and as the `TOUR_JSON_SCHEMA_V1` constant. Point an editor at the local copy with `"$schema": "../../node_modules/@stralos-co-nz/ui/dist/tours/tour.v1.schema.json"`.

`validateTour` applies the same rules at runtime, and `registerTours` runs it on every tour. In `stralos-ui`, `src/tours/schema.test.ts` keeps the TypeScript rules and the JSON Schema in step.

Other exports include `getRegisteredTours`, `TOURS_REGISTRY_KEY` (`"__STRALOS_TOURS__"`), `TOURS_REGISTERED_EVENT` (`"stralos:tours-registered"`), `tourAnchorProps`, `TOUR_ANCHOR_PATTERN`, `TOUR_ID_PATTERN`, `TOUR_ROLES`, the `TOUR_*_EVENT` and `TOUR_OPEN_ATTRIBUTE` constants, and the `Tour*` schema and progress types.


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