# axon web client

Preact + TypeScript + Vite SPA for axon (ADR 0046). This package is
self-contained: it is not a Cargo workspace member and has its own pnpm
lockfile.

Authenticated Axon sessions with no active Matrix account always default to
the centralized `/accounts` portal.

**Status (through M-W10, plus M19-W1 through M19-W4 and M19-W6):** a usable read/write client — SSO sign-in
through Axon OAuth Path A with token-paste fallback, the full account
lifecycle on the unified `/accounts` page (account list, active-account choice,
status, recovery, megolm backup snapshot and enable, logout, reactivation,
deletion, and password acquisition),
Matrix OAuth QR account acquisition (display or selectable-camera/image scan,
check codes, authorization, reload resume, and cancellation) plus trusted-account
QR authorization of new clients such as Element X, browser sign-in for
homeservers that only sign in on a web page (ADR 0117: a link to the
homeserver's own page and a code, reload resume, cancellation, and a prompt to
verify the new device), theme +
schema-versioned settings, history routing with the permanent deep-link
shape `/:accountId/rooms/:roomId?thread=&event=` (signed off), the
cross-account room list with TUI semantics (pinning ADR 0038, sort/filters
ADR 0042, server-calculated titles for unnamed rooms per ADR 0118,
server-derived room unread counts), the room
timeline (cursor-paginated scroll-back, date jump, DOMPurify-sanitized
formatted bodies with spoilers, state-events toggle, ADR 0033 relations
display, UTD/redaction placeholders, `?event=` highlighting, plain-text URL
linkification), media (M-W8, ADR 0064: inline images and stickers with a
full-size lightbox, download cards for files/audio/video, all fetched through
the authenticated media proxy as blob URLs; inline `<img>` re-admitted to the
sanitizer for `mxc://` only), on-demand audio/video/PDF/text previews
(ADR 0072) and syntax-highlighted code blocks and text attachments
(ADR 0073, highlight.js), media _send_ (M-W8.5, ADR 0065: attach by
paperclip, drag-and-drop onto the room or thread pane, or paste; the composer's
text becomes the caption and renders with the same markdown as a message body;
staged upload then `send-media`, with an optimistic echo that shows the picked
image while its bytes are still going up, and Retry/Discard on failure — one file at a time, images and files only), full
messaging: composer with markdown-on-send, member/room autocomplete that emits
pill links in sent and edited text, reply (ADR 0032), edit,
redact-with-confirm, reaction toggle, and threads (badges, panel via
`?thread=`, send-in-thread, and a threads drawer that lists unread threads
across rooms, or — when you are in a room — that room's threads, toggled from
the drawer title; hidden thread replies stay unread until their thread panel
loads), full-text message search (M-W10, ADR 0066: a URL-addressed overlay
opened with `/`, `Ctrl-G`, a topbar button, or `/search`, with chip/token filters and server-side
relevance/newest/oldest ordering across all matches), `/leave`, `/part`, and `/forget` room-membership slash commands
(M19-W1), an incoming-invite inbox (`/invites`, Accept/Reject and Accept
all/Reject all; an Invites row appears at the top of the room list when any
are pending), `/invite` and `/cancel` room invite commands, room entry via
`/join <room-or-matrix-link>` and `/knock <room-or-matrix-link> [reason]`
(M19-W2: parses raw room ids/aliases, matrix.to links, and `matrix:` URIs;
intercepts Matrix room links in the signed-in shell, showing an unknown target
as a join pill; can register the web origin as a browser-level `matrix:`
protocol handler from Settings, with a matching PWA manifest declaration for
browsers that register manifest protocol handlers with the OS), an "Add a Room"
surface with Join, DM, Create, and Find flows (M19-W3/W4: direct room
ID/alias/Matrix-link entry, public room-directory search defaulting to the
account homeserver, DM creation/opening, and room creation), Room Information
actions for copying a room link, inviting users, canceling pending invites,
leaving with confirmation, opening member DMs, room-state details (access,
encryption, pinned messages, space relationships, and upgrade links), editing
the room's name, topic and avatar (M19-W6/ADR 0100: an Edit button beside
Close, offered only when your power level allows it, with avatar images staged
through the existing upload flow), and a
Spaces picker that filters the room list, TUI-parity keyboard
shortcuts with a `/shortcuts` help overlay (ADR 0078, web keyboard shortcuts),
live WebSocket updates for timelines/room previews/unread state, live
ephemeral overlays for typing indicators plus public read receipts, and an
offline-first content cache (ADR 0085: warm per-room timeline stores across
room switches in one session, plus a durable IndexedDB copy of the room list
that paints before `/v1/rooms` answers and reconciles in place, marked
"Updating…" until it does — room metadata only, no message bodies; on by
default and switchable off under Settings → Room list, which also erases what
was stored).

Attachment cards show captions across their full width, with an icon-only Download control at the top right.
Click or tap a previewable card, or activate its filename with Enter or Space, to open the shared media viewer; there is no separate Preview button.
Text and audio previews use the same viewer as PDF and video, with Escape, Close, or the backdrop returning to the card.
Caption links and spoilers keep their own actions, and selecting caption text does not open a preview or message actions.
A failed download remains visible on the card after closing the viewer; opening a fresh viewer or retrying the download clears the previous error.
A single tap opens the preview; configured double-tap, touch-and-hold, and swipe actions still work over the card, while links and the download icon keep their own actions.
Unsupported or oversized attachments remain download-only, and preview bytes load only after opening the card.

Messages with bundled URL previews (`m.url_previews` or Beeper's `com.beeper.linkpreviews`) show cards beneath the body in room and thread timelines, including attachment captions.
Compact pinned thread headers omit the cards; the full root message retains them.
Cards show the destination hostname, site name, title, and description when supplied; the destination is the matched URL in the message body, rather than the preview's canonical URL.
Two cards appear initially, with an expansion control for additional previews (up to eight).
PNG, JPEG, and WebP preview images use a fixed thumbnail slot and Axon's authenticated media proxy, including server decryption for encrypted images.
Images load as their cards approach the viewport, with a 2 MiB download ceiling; missing, invalid, or unsupported images leave the text and destination usable.
Temporary media failures offer **Retry image** and retry after reconnect without navigating to the article.
Large plain source images can still use bounded server thumbnails; encrypted originals retain their stricter metadata limits.
Cards never fetch the linked webpage or an external image URL.
Preview authoring and opt-in generation are not yet exposed in the web composer; the server [send and generation APIs](../../docs/bundled-link-previews.md) are available to other callers.
Formatted messages containing spoilers omit preview cards, even after the spoiler is revealed.
Malformed or oversized preview metadata is ignored.

On touch devices, Settings → Messages configures double tap, touch and hold,
and swipe left as unique bindings for reply, thread, reaction, edit, or delete.
The default preset reacts with 👍 on double tap, opens a thread on hold, and
replies on swipe left; swipe right always keeps its mobile back-navigation
behavior.
Individual images and loaded tiles in collapsed galleries use the same
configured actions while retaining a single tap to open the full-size viewer.
Each gallery gesture targets the tile where it begins; gallery whitespace and
tiles that are loading, failed, or still uploading remain gesture-free.
During swipe-right navigation, the current pane follows the finger while the
room list or timeline underneath is revealed and settles fully into view.
Choosing an action already assigned to another gesture swaps the two
assignments, keeping mappings unique without requiring an intermediate Off.
Holding a timestamp copies the message body while touch and hold is enabled,
and tapping it continues to copy the Matrix.to event link.
On desktop, double-clicking a timestamp copies the message body while a single
click copies the link; message text always remains selectable with a mouse.
Double-clicking a message body runs the action assigned to Double tap and
replaces the browser's native word selection; setting Double tap to Off
restores that selection behavior.
Turning touch and hold off restores native text selection and link previews.

Note for deployment: history routing means the host must rewrite unknown
paths to `index.html` (the Vite dev server already does). ADR 0030's
`sync_state` is rendered when present, but the server does not emit it yet —
see the note in `src/stores/accounts.ts`.

The Spaces picker hides spaces once a successful membership read confirms they have no joined child rooms.
Spaces with unknown membership and failed reads remain visible, and Retry remains available for failed reads.
Confirmed empty spaces stay hidden during background refreshes.
The picker checks direct child rooms only; a parent containing only subspaces is hidden, while each populated joined subspace appears independently.
A stale or failed room-list read also keeps spaces visible until joined membership can be confirmed.
If the selected space becomes empty, the picker returns to All rooms.
Hidden spaces retain their saved order and can reappear after membership updates.

Received custom reaction images render in room and thread reaction chips through the authenticated media proxy.
Tooltips and accessible labels use the optional reaction `shortcode` when a server supplies it, and fall back to “Custom reaction” for older servers or missing names.
Supplied names are rendered as plain text, never interpreted as HTML.
Loading and failed images retain a compact placeholder; counts and click-to-toggle behavior work as for Unicode reactions.
To select a custom reaction, open React → More reactions → Custom emoji in a room or thread.
The picker groups personal and current-room packs, supports search, and sends the selected name alongside its image key.
Pack snapshots refresh whenever this view opens and after a live-connection reconnect; Refresh also checks for packs that finished syncing.
Loading, partial results, empty results, and failures have explicit states with retry controls.
Older servers without pack discovery continue to support the Unicode picker and received reaction images.
Custom selections do not enter the global Unicode recent-reaction or gesture preferences.
To send a standalone sticker, choose **Send sticker** beside the room or thread composer, search the personal and current-room packs, and select an image.
You can also type a complete shortcode such as `:adam:` and press Send (or Enter on desktop) to send that sticker.
Matching is case-insensitive; personal packs take precedence, followed by the server's room-pack order when names repeat.
Typing a standalone shortcode prefix such as `:ad` includes sticker suggestions, with previews and pack names, in the existing emoji autocomplete menu.
Choose a suggestion with Tab, Enter, or a pointer to insert the original shortcode, then submit it to send the sticker.
Duplicate names after case-folding show their effective first match; the picker remains available to choose a specific image from another pack.
Reaction commands (`/react` and `/+`) complete reaction-capable custom emoji from the same server discovery snapshot used by the reaction picker, alongside Unicode emoji.
Type a prefix with or without a leading colon, choose its preview with Tab or a pointer, and submit the command to react to the current target message.
An exact custom shortcode also works without selecting a suggestion; matching is case-insensitive and preserves the pack's original name in the sent reaction metadata.
Sticker-only entries are excluded from reaction commands, and servers without pack discovery retain Unicode reaction support.

Only a whole-message shortcode becomes a sticker: names in prose, attachment captions, and edits keep their text behavior.
Names absent from a successful discovery response, and servers without pack discovery, use the existing text/Unicode shortcode handling.
Sticker discovery has a ten-second deadline; recognized Unicode names still send as text if discovery fails or times out.
Unknown custom names retain their draft for retry on discovery failure or timeout.
Changing accounts, rooms, or API clients cancels pending sticker discovery and resets its status; signed-out pickers show a sign-in message.
Sending again during a sticker lookup shows a waiting notice and preserves edits for the next message.
Known Unicode reaction names still send their Unicode fallback if pack discovery fails or times out; custom image reactions never enter global Unicode recents.
Only sticker-capable images appear; mixed-use images also remain available in the reaction picker.
Selecting a sticker preserves any unsent text or staged attachments, and failed sends offer Retry and Discard in the timeline.
Replies and thread context are preserved, and received stickers display the original image so animated stickers retain their animation.
Older servers show an unavailable or empty sticker state without affecting text and reaction sends.
Available packs follow server discovery, including canonical ancestor spaces when supported by the server.
Shared packs subscribed from other rooms and pack editing remain outside this version.

To test with the companion server changes, run the server from `codex/server-room-emoji` alongside this client.
Build the web bundle in this workspace and point the embedded-server build at it:

```bash
pnpm --dir clients/web install --frozen-lockfile
pnpm --dir clients/web build
AXON_WEB_DIST="$PWD/clients/web/dist" cargo build -p axon-server --features embed-web
```

Run that server with your test configuration, open a joined room with a personal or room pack, and use the picker steps above.
For reactions, both room and thread sends should display the chosen shortcode in their tooltips; selecting an owned chip withdraws it.
For standalone stickers, use **Send sticker** in each composer and confirm that the selected image appears as its own timeline event while any unsent draft remains in place.

## Prerequisites

- **Node.js 24 (LTS)** — recommended, and what CI uses. The hard floor is
  Node 22.13: pnpm 11 requires the `node:sqlite` builtin (unflagged only in
  22.13+/23.4+), and Vite 8 requires 20.19+/22.12+. Older or odd-numbered
  releases (18, 20.x < 20.19, 21, 23.x < 23.4) fail with errors like
  `ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite`.
- **pnpm 11** — version pinned in `package.json`'s `packageManager` field.

## Environment setup from scratch

1. Install Node 24. With [nvm](https://github.com/nvm-sh/nvm):

   ```sh
   nvm install 24
   nvm alias default 24
   ```

   (Or use your OS package manager / [nodejs.org](https://nodejs.org)
   installer, as long as `node --version` reports ≥ 22.13.)

2. Install pnpm:

   ```sh
   npm install -g pnpm
   ```

3. Install this package's dependencies:

   ```sh
   cd clients/web
   pnpm install
   ```

4. Have an axon server to talk to. By default the dev server expects one at
   `http://localhost:8080`; see [CONTRIBUTING.md](../../CONTRIBUTING.md) for running the
   server, or set `AXON_SERVER_URL` (below) to point at an existing one.

5. Start the dev server:

   ```sh
   pnpm dev
   ```

   Vite prints a local URL (default `http://localhost:5173`) — open it in a
   browser.

On older MacOS Intel-based devices, you may see this error executing pnpm:

```
[ERROR] Cannot verify the identity of the @pnpm/exe.darwin-x64 native binary: it is missing from pnpm-lock.yaml.
```

This is an upstream bug. If that occurs, switch to `corepack` to install pnpm:

```sh
cd clients/web
npm install -g corepack@latest
corepack enable
corepack use pnpm@11
```

## Development

```sh
pnpm install
pnpm dev
pnpm exec playwright install  # if you want to run playwright (browser simulation) tests
```

The axon server serves no CORS headers, so the browser cannot call it
cross-origin. In development the Vite dev server proxies `/v1` (HTTP and
WebSocket) to the axon server instead — the app makes same-origin requests
and no CORS is involved. The proxy targets `http://localhost:8080` by
default; point it elsewhere with:

```sh
AXON_SERVER_URL=http://host:port pnpm dev
```

Vite only answers dev-server requests addressed to localhost. To test
through another hostname (a tunnel, a LAN name, a reverse proxy), allowlist
it without editing the config:

```sh
AXON_DEV_ALLOWED_HOSTS=axon-web.example.net,axon-dev.local pnpm dev
```

Deployed builds make real cross-origin calls and depend on server-side CORS
support (ADR 0046, milestone M-W1.5). Unlike `AXON_SERVER_URL` above (a
dev-server-only proxy target), a separately-hosted deployment bakes the
server's origin into the built bundle at build time:

```sh
VITE_AXON_SERVER_URL=https://axon.example.com pnpm build
```

Leave it unset for a same-origin deployment (the default Docker Compose
stack's single front door, or the Vite dev proxy) — the client then requests
`/` and relies on the reverse proxy or dev server to route it, with no CORS
involved.

### Build identity

The Settings page shows the bundle's build identity so testers can confirm which
copied client they are seeing. It reads `release+version` — for example
`0.1.0+ab12cd34ef56`:

- **release** — `version` from this package's `package.json`. The human-readable
  number; bump it when you cut one. That file is the _only_ place it lives: the
  desktop bundle reads it too, via `"version": "../package.json"` in
  `src-tauri/tauri.conf.json`, so a bump is one edit and the `.deb`, `.dmg` and
  installer names follow. (`src-tauri/Cargo.toml` also carries a `version`;
  that one is crate metadata and is not the product version.)
- **version** — the exact build id. By default the current git commit
  (`--short=12`), with `-dirty` appended when `clients/web` has uncommitted
  changes. This is what identifies a build; the release alone cannot, since
  every build of a release shares it. CI bundle builds (desktop, Android, iOS
  and macOS) set the override below to the bare commit hash, so a shipped
  build never carries `-dirty`; the suffix is a development marker.

For ad hoc test deploys, override the build id with:

```sh
VITE_AXON_WEB_VERSION=iphone-swipe-test-4 pnpm build
```

A build also writes the same values to `dist/version.json`:

```json
{
  "release": "0.1.0",
  "version": "ab12cd34ef56",
  "builtAt": "2026-08-02T14:03:11.204Z"
}
```

### Automatic refresh on a new build

A running client watches `/version.json` and applies a new build on its own
(ADR 0087), so testers no longer have to force-kill the PWA or reload by hand
after a deploy. It checks when the live socket reconnects (a deploy restarts the
server, so this fires within seconds), when a hidden tab becomes visible, on
`online`, and every 15 minutes as a backstop.

What it does about an update depends on what it would cost:

- Returning to a tab that has been hidden a minute or more: flushes drafts and
  reloads silently.
- You are using the app, or you have an unsent message or an upload in flight:
  a banner under the topbar, with a Reload button. Nothing reloads under you.
- A lazy-loaded chunk that the deploy deleted: reloads immediately, since that
  failure is unrecoverable.

Every automatic reload is guarded so it can happen at most once per build per
tab — if the origin's `version.json` disagrees with the bundle it actually
serves, the client reloads once and then falls back to the banner rather than
looping. **Settings → Check for updates** asks on demand.

**None of this runs under `pnpm dev`.** HMR already owns reloading there, and
the dev stamp is not a deployment identity: it is a git hash plus a `-dirty`
flag, read once when the dev server starts, so it moves whenever your working
tree does. Without the guard, restarting the dev server after a commit or an
edit would make every open tab decide a "new build" had shipped and reload
itself. If a dev tab is refreshing on its own, it is Vite — an HMR full reload,
or the "optimized dependencies changed" reload after a dep re-optimize — not
this.

Every automatic reload announces itself first, so a refresh is never a mystery:

```
[axon:update] reloading to pick up a new build: ab12cd34ef56 → ef78ab90cd12 (attempt 1/3 this window)
[axon:update] declining to reload ab12cd34ef56 → ef78ab90cd12: already attempted from this build. The banner is the remaining path.
```

Filter the console on `[axon:update]`. Nothing there means the reload came from
somewhere else. The guard's state is readable directly, too:

```js
JSON.parse(sessionStorage.getItem('axon:update-reload'))
// { from, targets: [...], spent, windowStartedAt }
```

Note for anyone serving the built bundle: the SPA history fallback must not
apply to `/assets/*`. A missing hashed chunk has to return `404`, not
`index.html` with a `200`, or the browser tries to parse HTML as a module and
the app hangs — the exact failure this feature was built to fix. Both shipped
servers are guarded (`vite.config.ts` for `pnpm preview`, `deploy/web/Caddyfile`
for the Docker stack); a third needs the same. Check yours with:

```sh
curl -sI https://your-host/assets/nope-abc123.js   # must be 404, not 200
```

Cache headers follow from the same distinction: `/assets/*` is content-hashed
and can be `immutable`, while `index.html`, `version.json`, and the manifest
change under a fixed name and must be revalidated.

## Desktop shell (Tauri)

The same `dist` is packaged as a native desktop app by the Tauri shell in
`src-tauri/` (ADR 0102, M-W12). Build it from this directory through the Tauri
CLI, never bare `cargo`:

```sh
pnpm tauri dev     # dev loop, hot reload
pnpm tauri build   # release binary + installers
```

[`src-tauri/README.md`](src-tauri/README.md) has the rest: why `cargo` on its
own produces a binary with no frontend in it, the Linux build dependencies, and
why the shell is its own cargo workspace rather than a member of the root one.

## Icons

`public/favicon.svg` is the artwork, and it is the master for **both** clients:
the browser loads it directly (`index.html`), and the Tauri shell's 52-file set
is rasterised from it. One file, so the two cannot drift.

Everything else is generated and committed:

```sh
scripts/build-brand-assets.py                                # this client, and docs/brand/
pnpm exec tauri icon public/favicon.svg -o src-tauri/icons   # the shell
```

The `icons-regenerated` pre-push hook refuses a change to the master that does
not regenerate both. Nothing in either build reads it except as a static asset,
so a forgotten regeneration ships the old mark silently — which has happened
here before.

`docs/brand/` holds the same mark on solid backgrounds, for slides and
documents — see its README. `favicon.png` is transparent, because a browser tab
composites it onto its own chrome. The `icon-*.png` apple-touch icons are opaque on white, because iOS
renders a transparent one against black. See `src-tauri/README.md` for the rest
of the reasoning, including why the shell's iOS set is treated differently
again.

## SSO sign-in

The web client consumes Axon's OAuth authorization-code + PKCE flow
(`GET /v1/oauth/authorize` → `/oauth/callback` → `POST /v1/oauth/token`).
The server must have `oauth.enabled = true` and a `[[oauth.clients]]` entry
whose `client_id` and redirect URI both match what the client sends:

```toml
[[oauth.clients]]
client_id = "axon-web"
redirect_uris = ["https://myaxon.example.com/oauth/callback"]
```

The desktop shell is a **separate registration**, not a variant of that one
(ADR 0102 § 4). Every packaged build needs this entry as well:

```toml
[[oauth.clients]]
client_id = "axon-desktop"
redirect_uris = ["org.matrixaxon.axon:/oauth/callback"]
```

Three things about that entry are easy to get wrong, and all three fail the
same way:

- **The redirect URI is matched byte for byte**, never parsed or normalised
  (`OAuthClients::redirect_uri_allowed`). A reverse-domain scheme with a single
  slash is what the shell sends — `org.matrixaxon.axon:/oauth/callback`, per
  RFC 8252 § 7.1, because a bare `axon:` is claimed first-come and
  unauthenticated on every desktop OS. An older `axon://oauth/callback` entry
  does not match it.
- **The id and the URI are one registration.** Redirect URIs are allow-listed
  _per client id_, so `axon-desktop` with the browser's callback, or
  `axon-web` with the shell's, is an unregistered pair rather than a
  half-configured client.
- **Clients are read once at boot**, so the server needs a restart after this
  is edited.

Each is rejected at `/v1/oauth/authorize` with a `400` naming which half was
wrong — `unknown client_id`, or:

```json
{
  "error": {
    "code": "bad_request",
    "message": "redirect_uri is not registered for this client_id"
  }
}
```

The server also logs the requested `client_id` and `redirect_uri` at `WARN`, so
the pair it actually received can be compared against `[[oauth.clients]]`
without reading it out of someone's address bar (#399). A server older than
that answers `unknown client_id or redirect_uri` for all three and logs
nothing.

The iOS app signs in with Apple natively rather than through the browser, with
the same `axon-desktop` registration. It needs native Apple enabled on the
server, with the bundle ID as the audience; no Services ID, key or return URL
is involved:

```toml
[oauth.providers.apple]
native_enabled = true
native_audiences = ["org.matrixaxon.axon"]
```

Native Apple only finds an Apple ID that is already linked to the owner. Link
one from the app: sign in any other way (a token from `axon token issue`, or
Google or Microsoft), then Settings → Linked sign-ins → Link an Apple ID.
Linking is by Apple's subject alone, never by email.

The same Settings panel lists every linked sign-in and unlinks one (ADR 0109,
`/v1/management/oauth/identities`). Unlinking makes Axon forget the identity
and ends its sessions; it does not withdraw the permission given at the
provider, and the confirmation says where to do that. The server only accepts
a credential change from a sign-in in the last ten minutes, so an older session
is sent through its sign-in again first and the change then finishes on its
own. With `[server] management_api = false` the list is hidden and only the
Apple link action remains, under "Sign in with Apple".

Provider buttons are discovered at runtime from `GET /v1/oauth/providers`, so
a client can be pointed at a server it was not built against and still offer
the right ones. `VITE_AXON_OAUTH_PROVIDERS` remains the fallback for a server
that predates that endpoint or has OAuth disabled — discovery failing keeps
the configured list rather than emptying it, so an existing deployment's
buttons never disappear:

```sh
VITE_AXON_OAUTH_PROVIDERS=google:Google,microsoft:Microsoft pnpm build
```

Use `VITE_AXON_OAUTH_CLIENT_ID` only if the server registered this web app
under a different OAuth client id; it does not apply to the shell, which
carries its own id and callback together on the platform seam. If no providers
are configured and none can be discovered, the sign-in screen shows only the
manual token form. Manual tokens minted with `axon-server token issue` remain
supported as a fallback.

## Generated API client

`src/api/schema.d.ts` is generated from the repo's OpenAPI contract
(`openapi/openapi.json`) by [openapi-typescript] and consumed through the
typed [openapi-fetch] factory in `src/api/client.ts`. After any change to the
contract, regenerate and commit the result:

```sh
pnpm gen:api
```

To check drift without rewriting the committed file, run:

```sh
pnpm check:api
```

CI and the optional pre-push hook both use `pnpm check:api`. The generated file
is excluded from ESLint and Prettier.

[openapi-typescript]: https://openapi-ts.dev/
[openapi-fetch]: https://openapi-ts.dev/openapi-fetch/

## Live WebSocket state

`src/api/ws.ts` opens the authenticated `/v1/ws` connection. The live layer
fans out decoded frames to stores by tag: timeline frames update room state,
device-state frames synchronize drafts/read markers, and
`ephemeral.passthrough` frames render live typing indicators plus public read
receipts. Ephemeral state is an in-memory overlay only; it is not replayed or
restored after reload.

## Auth seam

Per ADR 0031/0046, nothing outside `src/auth/` knows how tokens are obtained:
the API layer consumes the `AuthProvider` interface (`src/auth/provider.ts` —
`getToken()`, `onAuthFailure()`, a `LoginBootstrap` UI slot). The browser
implementation is a composite provider: OAuth 2.0 + PKCE stores Axon-issued
access/refresh tokens in `localStorage`, while token-paste remains available
for locally minted tokens from `axon-server token issue`. A Tauri OS-keychain
provider can still slot in later behind the same seam.

## Scripts

| Script                              | Purpose                                                                                                                                        |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `pnpm dev`                          | Dev server with HMR and the `/v1` proxy                                                                                                        |
| `pnpm build`                        | Type-check and produce a deployable `dist/`                                                                                                    |
| `pnpm preview`                      | Serve the built `dist/` locally                                                                                                                |
| `pnpm gen:api`                      | Regenerate `src/api/schema.d.ts` from the spec                                                                                                 |
| `pnpm check:api`                    | Check generated API types for drift                                                                                                            |
| `pnpm test`                         | Vitest, single run                                                                                                                             |
| `pnpm test:watch`                   | Vitest, watch mode                                                                                                                             |
| `pnpm test:e2e`                     | Playwright e2e suite (Chromium; CI also gates Firefox and desktop WebKit, then runs iPhone-profile WebKit after a web change merges to `main`) |
| `pnpm test:e2e:perf`                | The ADR 0071 timeline→room-list perf spec, also under WebKit (sets `PERF=1`, which gates the extra WebKit project in `playwright.config.ts`)   |
| `pnpm demo`                         | Record the ADR 0086 demo videos against a seeded local stack (needs `DEMO_MANIFEST`; see § Demo recording)                                     |
| `pnpm lint`                         | ESLint + Prettier check                                                                                                                        |
| `pnpm format` / `pnpm format:check` | Prettier write / check                                                                                                                         |

An optional live round-trip suite runs against a real server when
`AXON_LIVE_URL` and `AXON_LIVE_TOKEN` are set (see
`src/api/client.live.test.ts`); it is skipped otherwise, including in CI.

### Playwright browser targets

`pnpm test:e2e` builds the client and runs Chromium. The Playwright mock server
serves the built `dist/` directory, so run `pnpm build` before invoking
Playwright directly; otherwise the browser can exercise stale or absent build
assets instead of the checked-out source.

```sh
# One desktop engine at a time.
pnpm build
pnpm exec playwright test --project=chromium
pnpm exec playwright test --project=firefox
pnpm exec playwright test --project=webkit-desktop

# The iPhone 13 WebKit profile (the post-merge lane).
MOBILE_E2E=1 pnpm exec playwright test --project=webkit-iphone

# Every configured desktop and iPhone-profile target.
MOBILE_E2E=1 pnpm exec playwright test
```

`playwright.config.ts` sets `retries: process.env.CI ? 1 : 0`. So:

- **Locally** there are no retries, so there is no "flaky" outcome to begin with:
  a test that fails its first attempt is simply a failure.
- **On CI** `retries: 1` applies, and `e2e/flaky-policy-reporter.ts` decides
  what a passing retry is worth. A test that fails its first attempt still
  fails the job, exactly as `--fail-on-flaky-tests` did, _unless_ every failed
  attempt ran on WebKit and carries the signature of WebKit itself breaking
  (`e2e/webkit-infra.ts`, #391). That is either the driver's own
  `WebKit encountered an internal error`, or a network-process crash under an
  app-initiated reload. Those are accepted, and listed as warnings and in the
  job summary.

The practical consequence is that both environments reject a first-attempt
failure of our own making, while CI still retries to capture a trace and to
ride out a browser crash. To reproduce the gate exactly, set `CI=1`. Every run
requires a fresh mock server: an existing listener on the default port 4599 is
an error rather than a server Playwright can silently reuse from another
workspace. Set `AXON_WEB_E2E_PORT` to an unused port when another workspace or
a manually driven mock needs 4599.

**A local full-suite Firefox run should pass.** #157 traced its former
order-dependent failures to the mock backend leaving every client-initiated
WebSocket close half-finished. The retained connections eventually prevented
Firefox from opening another socket, so whichever spec next entered shared
setup (`openRoom`/`expectLive`) appeared to hang. The mock now completes the
close handshake, and the CI lanes fail a passing retry (see above), so a retry
can no longer hide a recurrence.

CI runs the peer-dependency check, the schema sync check, lint, format check,
tests, and the build via `.github/workflows/web-lint-and-test.yml`
(path-filtered `pull_request` plus manual `workflow_dispatch`). The repo-root
`.pre-commit-config.yaml` runs the same web checks at pre-push time for both git
and jj users, and rustfmt/clippy/`cargo test` when rust sources change — it is
the only list of pre-push checks the repo has (ADR 0092). The web hooks use the
normal web dependencies, and the `web-install` hook runs
`pnpm install --frozen-lockfile` for you whenever the manifest, lockfile, or
`pnpm-workspace.yaml` changes, so they cannot silently test a stale tree.

Playwright is deliberately not in that hook (browsers and minutes);
`web-e2e.yml` gates pull requests, and `AGENTS.md` § "Definition of done for a
UI change" says when to run it locally.

## Demo recording

The web demo videos (ADR 0086 phase 3) are recorded by Playwright against a
**real** axon: a throwaway Synapse + Postgres + axon stack seeded from
`smoke/local-stack/corpus/demo.toml`. Search results therefore come from a real
Tantivy index, media really traverses the media proxy, and the WebSocket is the
real one. `e2e/mock-server.mjs` is not involved and `pnpm test:e2e` is
untouched — the lane has its own config, `playwright.demo.config.ts`.

Needs Docker with Compose v2, so it is Unix-only and is not a CI gate.

```sh
# from the repo root
scripts/demo-web.sh up       # seed a demo world and leave it running
scripts/demo-web.sh record   # record (from here — cds into clients/web itself)
scripts/demo-web.sh down     # tear the world down
```

Under the hood, `up`/`down` are `scripts/demo-stack.sh up`/`down` (shared with the TUI recording — same corpus, same stack),
and `record` is `DEMO_MANIFEST=<manifest> pnpm demo` run from here.
Driving those directly still works and is what the "Useful while authoring" env vars below assume —
`scripts/demo-web.sh` is the shortest path from a clean checkout to uploaded MP4s, not the only one.

Output lands in `demo-artifacts/<test>/video.webm`, one clip per scene, at
1440×900 for `demo-desktop` and 780×1328 for `demo-mobile` (an iPhone 13
descriptor, so WebKit, real touch, and a device pixel ratio of 3; its viewport
is 390×664, the screen less the browser chrome).

Both recordings are pinned to the **light** theme, and to a light emulated
`prefers-color-scheme` to match. The shipped default follows the system
preference, which would otherwise make a take's appearance depend on the
machine that made it.

**Record a real take against a freshly seeded stack.** The mutating scenes undo
themselves by redacting what they sent, and a redaction leaves a permanent
"message deleted" row that nothing can remove. Reruns while authoring are fine —
the driver clears leftovers over the API and warns when it finds any — but those
tombstones accumulate in the take.

Useful while authoring:

| Variable        | Effect                                                    |
| --------------- | --------------------------------------------------------- |
| `DEMO_PACE=0`   | Strip every dwell — a full dry run in well under a minute |
| `DEMO_PACE=1.5` | Slow the whole take down for a narrated cut               |
| `DEMO_PORT`     | Move the preview server off 4600                          |

`pnpm demo --grep rooms` plays a single scene; `--project demo-mobile` a single
form factor.

Playwright writes **WebM**, one clip per scene.
`demo.html` and the release each want a single MP4 **per platform** —
the whole tour, scenes concatenated in the order `demo.html` lists them —
so converting one `video.webm` at a time is not enough on its own.
That's what `scripts/demo-web.sh assemble` is for, and `upload` follows it to attach both MP4s to the demo release:

```sh
scripts/demo-web.sh assemble           # concat + transcode both platforms' tours
scripts/demo-web.sh upload             # attach both MP4s to the demo release

# or the whole session, start to finish:
scripts/demo-web.sh all --upload
```

`assemble` hard-codes the scene order from `demo.html` and refuses to guess if a scene's clip is missing or its glob is ambiguous —
see the script header if that order ever needs to change.
`upload` defaults to the most recently published release (`DEMO_RELEASE_TAG=<tag>` to target a specific one instead),
matching what `api-docs.yml` pulls from on a normal `main` push by default.

**Every recording landing on the same release its MP4s are pulled from is on you** —
`upload` attaches to whatever release is newest _at the moment you run it_,
which after a version bump may not be the release the other two demo MP4s (or the TUI's `tui-demo.mp4`) already live on.
`api-docs.yml`'s guard step fails loudly if a platform's video is missing from the resolved release, rather than silently publishing a broken player —
that is the case to check for after `upload`, and to fix by either uploading all three MP4s to the new release together or pointing `demo_release_tag` at the older one that still has them.

After uploading, re-run the `api-docs` workflow by hand —
re-uploading a release asset changes no path in the repository, so nothing in the workflow's `paths:` trigger fires and the site keeps serving the previous recording.

Adding or changing a scene means updating `docs/demo-coverage.md` in the same
PR. Two rules the scenes are built on, both learned the hard way (ADR 0086):
a step that changes state needs an assertion **only the new state satisfies**
(assert on what _left_, not on what stayed), and a scene that mutates
**scripts its own undo**, or its second run passes vacuously on what its first
run left behind. Both are stated in full, with the Playwright specifics, in the
`e2e/demo/demo.ts` module docstring — change them there first.
