# mobux SPA migration
Porting the mobux UX to a modern client SPA, decoupled from the Rust backend.
The backend stays the API / PTY / WebSocket server; all UI state lives in the
browser. The existing Rust-rendered HTML pages keep working in parallel during
the migration — nothing here removes or changes them.
**Stack:** Vite + Preact + Wouter (`wouter-preact`) + `@preact/signals`, JSX via
`@preact/preset-vite`.
## Status (phase 4)
Phase 4 closes the last mesh parity gap: the app-shell host picker.
Phase 4 done:
- **App-shell host picker** (`components/HostPicker.jsx`) — loads `mesh-client.js`
once at the SPA level (not just in the terminal island), renders the host
trigger + dropdown in the `spa-nav` rail, and dispatches `mobux:peer-changed`
so `Home.jsx` re-fetches sessions against the selected peer. Peer discovery,
credential prompt, manual-host add/remove, and the relay path rewrite all
work identically to the old `host-picker.js`. The terminal island's own
`mesh-client.js` load becomes a no-op once the app shell has loaded it.
- Headless Playwright: host trigger visible in nav, label defaults to
"This host", dropdown opens with peer-option list.
- All 6 existing prod tests still pass (build-info FE-hash test was already
failing in this worktree due to missing `build-info.json` from a `cargo build`
vs full release build — pre-existing, not a regression).
## Status (phase 3)
Phase 3 completes Settings parity (Listen + Build-info cards) and adds
client-side QR codes to the Install page.
Phase 3 done:
- **Listen card** (`components/settings/Listen.jsx`) — Voice/Rate/Pitch
controls, Test button, Web Speech API capability gate. Reads/writes the same
`mobux.listen.prefs` localStorage key as the old `listen-prefs.js` module.
- **Build-info card** (`components/settings/BuildInfo.jsx`) — Backend version +
server bundle hash from a new `/api/build-info` endpoint; loaded bundle hash
from `/static/build-info.json`; stale-UI warning when they diverge.
- **Install QR codes** — `uqr` renders inline SVG QR codes for the CA-cert and
APK download URLs (derived from `window.location.origin`) directly in the SPA
Install page. The server `/install` page and its Rust-side QR renderer are
untouched.
- Headless Playwright verification (7 tests, all green): all phase-2 checks
plus Listen card localStorage persistence, Build-info version/hash display,
and Install page QR SVG rendering.
## Status (phase 2)
Phase 2 made the SPA loadable on a real phone from the Rust binary (no Vite),
folded the SPA build into the normal build, and brought the remaining pages to
parity. Phase 1 (below) built the foundation.
Phase 2 done:
- **Served from Rust at `/app`** (and `/app/*`, SPA history fallback) behind the
existing auth + `Cache-Control: no-store`. The old Rust-rendered pages (`/`,
`/s/:name`, `/settings`, `/install`) are untouched — both UIs coexist; the SPA
is shadow-mounted. See "Prod serving in Rust" below.
- **Build folded in** — `node web/build.js` (and thus `make build`, plus the
root `npm` postinstall used in CI/release) builds the SPA into
`web/static/spa/`, embedded by RustEmbed.
- **Pages at parity**: Home (session list + create/kill/rename + FAB),
Install (CA + APK + instructions), and the Settings cards — software update,
notifications, terminal renderer, theme, shell integration — on top of the
STT card from phase 1.
- Headless Playwright verification against the **prod-served** SPA (binary on a
throwaway port, no Vite): Home, all Settings cards + their endpoints, STT
auto-save persistence, and the terminal island + PTY WebSocket — all green.
## Status (phase 1)
Done:
- Vite/Preact/Wouter scaffold with a dev proxy to the Rust backend.
- Routing skeleton for `/`, `/s/:name` (+ `/s/:host/:name`), `/settings`,
`/install`. Home and Install are stubs; Settings and Terminal are real.
- **Terminal island** — wraps the existing engine, proven at `/s/:name`
(mounts, PTY WebSocket connects, engine renders).
- **Settings → Speech provider** section fully ported to Preact.
- Headless Playwright verification, both assertions green.
## Directory layout
```
web/spa/
index.html entry; loads /static/style.css (backend) + main.jsx
vite.config.js @preact/preset-vite, port 5173, proxy, build outDir
jsconfig.json editor JSX/preact hints
playwright.verify.cjs standalone config for the phase-1 verification run
verify.spec.mjs the two phase-1 assertions
src/
main.jsx render(<App/>)
app.jsx Wouter router (hash location) + shell chrome
app.css shell-only styling (pages reuse backend style.css)
components/
TerminalIsland.jsx the island — hosts the existing engine
settings/ one component per Settings card
Update.jsx software self-update (host-pinned)
Notifications.jsx push-trigger prefs (4 checkboxes)
Renderer.jsx xterm/sterk picker (localStorage)
Theme.jsx theme picker (imports /static/themes.js)
ShellIntegration.jsx OSC-133 installer per shell
Stt.jsx speech provider (phase 1, moved here)
Listen.jsx Web Speech voice/rate/pitch + test (phase 3)
BuildInfo.jsx version + bundle-hash display (phase 3)
pages/
Home.jsx session list: create/kill/rename + FAB
Terminal.jsx full-bleed route → TerminalIsland
Settings.jsx composes the settings/ cards in page order
Install.jsx CA + APK + Android instructions
lib/
api.js fetch helpers; mesh-aware when window.MobuxMesh
is present, plus host-pinned localGet/localFetch
stt.js STT helpers ported 1:1 from the Rust inline IIFE
```
`api.js` exposes two families: **mesh-aware** (`apiGet`/`apiPutJSON`/`apiPost`/
`apiSend`) that route through `window.MobuxMesh` (the relay) when a peer is
selected and fall back to same-origin fetch otherwise; and **host-pinned**
(`localGet`/`localFetch`) for update / shell-integration / STT-install, which
must always hit the binary that served the page (never the picker's peer).
Build output goes to `web/static/spa/` (git-ignored, regenerated by the build).
Asset base is `/static/spa/`, so the built `index.html` references its JS/CSS at
`/static/spa/assets/…`, which the existing Rust `/static` handler serves. The
binary serves the entry document at `/app` (see "Prod serving in Rust").
## Dev + build commands
From `web/spa/`:
```sh
npm install
# dev server on :5173, proxying to the backend with injected Basic auth:
MOBUX_BACKEND=https://localhost:5152 MOBUX_DEV_AUTH=mvhenten:30879 npm run dev
# open http://localhost:5173/static/spa/
npm run build # → web/static/spa/
```
The SPA build is also folded into the normal build: `node web/build.js` (run by
`make build`, and by the repo-root `npm` postinstall that CI/release already
invoke) installs the SPA's deps if missing and runs its `npm run build`, so the
assets exist for RustEmbed to pick up at compile time. CI's `check` job runs
`cargo check`/`clippy` without that step, so the SPA asset may be absent there —
that's fine: it compiles, and `serve_spa_index` returns a clear 404 hint if the
asset is missing. The e2e/release jobs run `npm ci` (→ postinstall → SPA build)
before `cargo build`, so the shipped binary embeds the SPA.
## Prod serving in Rust
The binary serves the SPA at **`/app`** (and `/app/*` for an SPA history
fallback — every sub-path returns the SPA's `index.html`). `serve_spa_index`
reads the embedded `spa/index.html`, sets `text/html` + `Cache-Control:
no-store`, and sits behind the global auth layer like every other page. The
SPA's JS/CSS at `/static/spa/assets/…` ride the existing `serve_static` handler.
Routing is hash-based (`/app#/settings`, `/app#/s/<name>`), so deep links and
reloads work without any extra server config; the `/app/*` fallback is belt-and-
suspenders for a future switch to history routing.
The old Rust-rendered pages — `/`, `/s/:name`, `/s/:host/:name`, `/settings`,
`/install` — are **unchanged**. Both UIs coexist; the SPA is shadow-mounted at
`/app`. Cutting `/` over to the SPA (and deleting the inline pages) is the
operator's call after review — see "Remaining work".
Verify the prod-served SPA without Vite (binary on a throwaway port, never
5151/5152):
```sh
make build
env MOBUX_AUTH_USER=mvhenten MOBUX_PIN=30879 MOBUX_DEV=1 PORT=5183 \
./target/debug/mobux &
cd web/spa && npx playwright test --config=playwright.prod.cjs
kill $(lsof -ti :5183)
```
Headless verification (dev server must already be up on :5173):
```sh
npx playwright test --config=playwright.verify.cjs
```
`MOBUX_BACKEND` / `MOBUX_DEV_AUTH` are dev-only env vars read by `vite.config.js`.
Never run anything on :5151 (the live phone server) or :5152's process (the
`make dev-watch` backend) — the SPA only *proxies* to :5152.
## Proxy setup
The browser at :5173 has no backend credentials; the Vite proxy attaches HTTP
Basic auth server-side (`MOBUX_DEV_AUTH`), keeping the SPA a pure client. The
backend is HTTPS with a self-signed cert, so the proxy uses `secure:false` and
`changeOrigin:true`.
Proxied routes → `https://localhost:5152`:
- `/ws`, `/r` — PTY + mesh-relay WebSockets (`ws:true`)
- `/api`, `/v1`, `/transcribe`, `/upload` — REST + transcription
- `/sw.js`, `/static` — service worker + existing assets (vendor bundles,
`terminal.js`, `mesh-client.js`, `style.css`, …)
`/static/spa/` is the SPA's own base; the `/static` proxy rule has a `bypass`
that lets Vite serve everything under `/static/spa/` while still forwarding the
rest of `/static/` to the backend.
## The terminal-island pattern
The existing engine (`/static/terminal.js`) is a side-effecting ES module: on
load it reads `window.MOBUX_SESSION` / `MOBUX_PEER` / `MOBUX_DEV`, binds to a
fixed set of DOM ids (`#terminal`, `#reader`, `#loadquote`, the `#inputBar`
ribbon, the `#cmdPickList` overlay, …), constructs `TerminalCore` (xterm or
sterk), and opens the PTY WebSocket via `window.MobuxMesh.wsUrl()`.
`TerminalIsland.jsx` is purely a host — it does **not** reimplement the engine:
1. Renders the exact DOM scaffold the engine expects (mirrors
`render_terminal_page` in `src/main.rs`).
2. Sets the window globals.
3. In a **one-time** `useLayoutEffect`, loads the same script chain the Rust
page loads, in order: renderer-picker → vendor bundle (`xterm.bundle.js` /
`sterk.bundle.js`) → `mesh-client.js` → `host-picker.js` → `terminal.js`
(module). All come from the backend through the proxy, so the *real* engine
bundle runs unchanged.
The effect is guarded so it boots exactly once; Preact never re-renders the
inner subtree (no children, the engine owns it). That is the island contract:
mount once, never re-render the engine.
Reused as-is (the whole point of the island): `terminal-core*.js`, the vendor
bundles, `mesh-client.js`, `host-picker.js`, `style.css`, and all the
gesture/input-bar/reader plumbing those pull in.
## Settings: the STT speech provider
Ported 1:1 to a component model. Instead of the old `[hidden]`-toggling on a
fixed DOM, **only the fields that apply to the selected provider are rendered**:
- **Local** — Install + a single run toggle (Start/Stop). Nothing else; host,
port, model and key are baked in server-side.
- **Network** — Host + Port + discovered Model.
- **OpenAI** — API key + Model.
Behaviour preserved from the Rust inline IIFE: auto-save on every change (no
manual Save), debounced host/port → model re-discovery → save, bare-hostname
normalization (`lab` → `http://lab`), pasted-URL splitting into host/port,
model discovery via `/api/stt/models`, a "custom…" free-text model option, and
the local install-poll + run toggle driven by `/api/stt/status`.
Consumes the STT API on `feat/stt-transcribe`: `GET|PUT /api/settings/stt`,
`GET /api/stt/models`, `GET /api/stt/status`, `POST /api/stt/install|start|stop`.
## Other Settings cards
Ported in phase 2 (one component per card, in `components/settings/`):
- **Software update** (`Update.jsx`) — `/api/update/status|check|run` + the
`/api/identify` version-watch poll. Host-pinned: always the page's own binary.
- **Notifications** (`Notifications.jsx`) — `GET|PUT /api/settings/notifications`
(snake_case `bell` / `bell_emoji` / `program_exit` / `program_exit_nonzero`),
auto-save on every checkbox change.
- **Terminal renderer** (`Renderer.jsx`) — `localStorage['mobux:renderer']`.
- **Theme** (`Theme.jsx`) — dynamically imports the backend `/static/themes.js`
module (single source of truth), persists + applies + broadcasts `mobux:theme`.
- **Shell integration** (`ShellIntegration.jsx`) —
`GET /api/shell-integration/status`, `POST .../install|uninstall` with a
`{shell}` body; OSC-133 snippets shown verbatim.
## Remaining work
1. **Old-UI teardown (operator's call).** Nothing here removes the inline pages.
Once `/app` is reviewed and trusted, cut `/` (and friends) over to the SPA and
delete the Rust-rendered `index`/`settings`/`install`/terminal templates and
the now-dead `*.js` (`index.js`, `settings.js`, `update.js`,
`settings-theme.js`, `settings-renderer.js`, `shell-integration.js`, the
terminal inline boot script). The terminal page's inline boot collapses into
the island once the SPA owns that route.
```