Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/chromatic.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ on:
- main
paths:
- 'lib/**'
- 'docs/stories/**'
pull_request:
paths:
- 'lib/**'
- 'docs/stories/**'

permissions:
contents: read
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ Each spec's own `Files` / `Code Map` section is the exhaustive file→spec mappi

When updating code covered by a spec, update the spec to match. When the two specs overlap (e.g. pane header elements appear in both), layout.md documents placement and sizing while alert.md documents behavior and visual states.

**Narrative docs are not specs.** `docs/stories/pairing.mdx` is a Storybook page that walks the self-hosted remote-control setup end to end, embedding the real screens from `lib/src/stories/`. It restates specs for narrative flow rather than owning anything, `scripts/spec-lint.mjs` does not check it, and the `## Future` fold does not apply. When a remote spec changes, check whether it needs the same edit — the specs win where they disagree.

When editing specs, keep them concise but do not replace invariants or edge cases with only a code pointer. Use `Source of truth:` for implementation references, and include direction/scope for protocols, command orchestration, and cross-package boundaries. For docs-only compression, spot-check referenced symbols, message directions, and root-vs-package script ownership against code before committing.

Every spec that uses Session / Pane / Door / baseboard / passthrough vocabulary leads with a `> See \`docs/specs/glossary.md\` for ...` blockquote (see `layout.md`, `alert.md`, `terminal-state.md`). When introducing glossary vocabulary into a spec that lacks the callout, add it in the same edit.
Expand Down
1 change: 1 addition & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ The system uses **raised surfaces**, not "cards." There are no nested cards. The
### Inputs
- Used by `ThemePicker`. Style: `bg-input-bg`, `border border-input-border`, `rounded`, `font-mono`, `text-sm`.
- **Focus:** native browser focus outline; this is acceptable because the entire input lives inside a raised surface that already has `shadow-2xl` and a border.
- **Form fields inside a dialog** use the underlined pair in `design.tsx` instead, so a form mixing them reads as one: `NumericInput` for a number (filtered at the keystroke, sized in `ch`) and `TextInput` for a string (full width, `type` passed through — `type="password"` for a credential). The app has no checkbox anywhere: a boolean is an `OnOffSwitch`.

### Navigation

Expand Down
20 changes: 10 additions & 10 deletions SELF_HOST.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,21 +273,21 @@ command line.
pairing (`docs/specs/pocket-app.md` → Installable web app).

3. **The Host.** Launch the standalone or VS Code build made with
`DORMOUSE_REMOTE_CONNECT_SRC` (see Prerequisites) and enroll once from that
webview's devtools console:

```js
await window.dormouseRemoteHost.enroll('https://<laptop>.<tailnet>.ts.net', '<setup password>', 'My Laptop')
```
`DORMOUSE_REMOTE_CONNECT_SRC` (see Prerequisites) and enroll once in
**Settings → Remote control** — the sliders icon at the far right of the
baseboard. Three fields: the server origin, the setup password from step 1,
and a name for this machine. The `window.dormouseRemoteHost` console hook
carries the same four commands and stays as the scripting seam
(`docs/specs/server.md`, "Remote control, in the Settings dialog").

Enrollment persists in the Host service's own store — a mode-`0600` file
under the app-data dir in standalone, `SecretStorage` in VS Code — so later
launches connect on their own. `status()`, `reconnect()` and
`clearEnrollment()` live on the same object and are promises.
launches connect on their own. The section then shows the server, the relay
connection, and the paired-device count.

A build without the `*.ts.net` allowlist refuses this outright, before the
password leaves the machine. That is the expected symptom of a stock build,
not a server problem.
password leaves the machine, and the form renders that refusal verbatim.
That is the expected symptom of a stock build, not a server problem.

4. **A real session.** On the phone: Hosts → **Pair** → approve the modal that
appears on the laptop → **Connect** (one biometric prompt) → pick a pane and
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,6 @@ Alert-specific robustness requirements: multiple Sessions ring independently; mi
| `lib/src/components/wall/TerminalPaneHeader.tsx` | Bell button, TODO pill, notification preview |
| `lib/src/components/wall/AlertSpeechIndicator.tsx` | Whole-Pane `SPEAKING` / `SPOKEN` treatment |
| `lib/src/components/TodoAlertDialog.tsx` | TODO + WATCHING-rule switches, notification detail, watched-command list |
| `lib/src/components/SettingsDialog.tsx` | App-global Settings dialog: theme row (see [theme.md](./theme.md)), shell row (standalone, see [standalone.md](./standalone.md)), rule list, inactivity timeout, spoken alarms, push notifications |
| `lib/src/components/SettingsDialog.tsx` | App-global Settings dialog: theme row (see [theme.md](./theme.md)), shell row (standalone, see [standalone.md](./standalone.md)), rule list, inactivity timeout, spoken alarms, push notifications, remote control (see [server.md](./server.md)) |
| `lib/src/components/WatchedCommandList.tsx` | The WATCHING rule set with per-rule remove, shared by both dialogs |
| `lib/src/components/Door.tsx` | Door bell + TODO display |
88 changes: 83 additions & 5 deletions docs/specs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ called — and reaches the service over the `remoteHost:*` bridge, so the consol
API's shape is unchanged and its calls are now promises one round trip further
away.

* **Enrollment** (console hook, once): server URL + setup password →
* **Enrollment** (Settings dialog, or the console hook, once): server URL + setup password →
`POST /api/host/enroll` → the service persists `{ serverUrl, hostId,
hostToken, origin, rpId }` through its `HostStateStore` — a 0600 JSON file
under the app-data dir in standalone, `SecretStorage` in VS Code — then opens
Expand Down Expand Up @@ -491,9 +491,11 @@ away.
reconnecting on it would evict the newer Host, which would reconnect and
evict this one, forever. Coming back is an explicit act —
`window.dormouseRemoteHost.reconnect()` (or `RemoteHost.start()`), which
takes the slot back and displaces the other Host in turn. Nothing surfaces
`displaced` in the UI yet; `window.dormouseRemoteHost.status()` reports it as
`connection`, distinct from the retrying `disconnected`. A close event from a
takes the slot back and displaces the other Host in turn. `displaced` is the
one connection state the user has to act on, so it is the only one the
Settings dialog gives a button (Remote control, below);
`window.dormouseRemoteHost.status()` reports it as `connection`, distinct from
the retrying `disconnected`. A close event from a
socket the controller no longer owns is ignored, so a dead socket's late
eviction cannot stand down the live one. Disposing the service is terminal:
an enrollment or ACL read already in flight cannot construct a relay socket
Expand Down Expand Up @@ -534,6 +536,79 @@ away.
existing resize path. The "tethering to \<label\>" grey-out display on the
local pane is staged — see remote-api.md `## Future`.

### Remote control, in the Settings dialog

Enrolling is the one step a self-hoster cannot skip, so it is UI rather than a
console incantation: a **Remote control** section at the bottom of the
app-global Settings dialog ([alert.md](./alert.md) -> Settings dialog). Source
of truth: `lib/src/components/RemoteControlSection.tsx` over
`lib/src/remote/host/host-status-store.ts`.

It renders **nothing at all** where `getPlatform().remoteHost` is absent — the
website and the lib dev server have no Host service behind them, and offering
the form would promise something the build cannot do.

The push-devices line above it must key on that same seam, and **not** on its
own `no-host`, which is a superset: `no-host` covers both a Host service that
has not enrolled *and* a build with no Host service at all
([alert.md](./alert.md) -> Push notifications). Only the first has a section
beneath it, so only the first says "below" — otherwise the website points the
reader at nothing. Source of truth: `describePushTargets` in
`lib/src/components/SettingsDialog.tsx`, which takes the seam as an argument;
the `PushNoHost` / `PushNotEnrolled` story pair holds the two apart.

Un-enrolled it is a three-field form (server, setup password, name for this
machine) calling the service's `enroll`; enrolled it shows the server URL, the
relay connection state, and the paired-device count, with `Disconnect` and —
only on `displaced` — `Reconnect`. Rules the UI exists to honor:

- **The password is passed through, never held.** It goes straight to the
service, which is the party that talks to the server, and is cleared on
success. `hostToken` never comes back into the webview realm: `enroll`
answers `{ hostId, serverUrl }`.
- **Refusals are shown, not swallowed.** An origin outside this build's baked
allowlist is refused before the password leaves the machine (above), and that
error is what the form renders — so the failure reads as "this build will not
talk to that server" rather than as a wrong password.
- **Disconnect asks first**, because clearing the enrollment drops every paired
phone until each pairs again.
- **Status is re-read, not patched, and the connection is polled.** The
service's `status` event carries only `{ enrolled }` — the edge its webview
gate arms on — so every event triggers a full `status` command, and the dialog
re-reads on open since another window may have enrolled meanwhile. The
*connection* moves with no event at all (`connecting -> connected`,
`-> disconnected`, `-> displaced`), so the store also polls every 2 s **while
something is subscribed**, which is the seconds the dialog is open rather than
a standing timer in every window. Status reads are serialized: ticks that
arrive during a slow read coalesce behind it, so a 15-second Host-service
timeout is allowed to become the visible error instead of being superseded by
newer polls. Without the poll a machine that finished connecting a moment
after the dialog opened would read as permanently "Connecting…".
- **A repeat answer is not published.** The service returns a fresh object every
poll, so the state is compared field-wise before it is stored — otherwise the
section would re-render twice a minute to paint identical text. This matches
the sibling store the same dialog reads (`setPushDevices` in
`lib/src/lib/push-devices.ts`).
- **Coalescing stops at anything that changes the answer.** `enroll`,
`reconnect` and `clearEnrollment` each drop the read in flight and start their
own, because a `status` issued before the command answers the question as it
stood beforehand — joining it would report the old enrollment as though the
command had not run, the inverse of the delete-first ordering the service uses
so a failed delete never claims to have succeeded. Losing the last subscriber
drops it for the same reason: a reopened dialog must not be answered with a
status fetched for the closed one, and would otherwise sit on "Checking…"
until that read settled. Source of truth: `dropInFlightRead` in
`lib/src/remote/host/host-status-store.ts`.

The `window.dormouseRemoteHost` console hook keeps the same four commands and
remains the scripting seam. Pairing approval is deliberately *not* here: it is a
modal, because it must interrupt
([remote-security-model.md](./remote-security-model.md), Pairing Ceremony).

`docs/stories/pairing.mdx` walks this section and the pairing modal in sequence
with the rest of the setup, rendering the real components; it is a narrative
Storybook page, not a spec, so this section is what it defers to.

## Pocket side (phone)

Served by the server, built from `lib`:
Expand Down Expand Up @@ -604,7 +679,10 @@ scheme. A local server therefore needs the override at build time, which
DORMOUSE_REMOTE_CONNECT_SRC='http://localhost:3000 ws://localhost:3000' pnpm dev:standalone
```

Then enroll once from the devtools console of the standalone webview:
Then enroll once, in **Settings → Remote control** (the sliders icon at the far
right of the baseboard): server `http://localhost:3000`, the setup password, and
a name for this machine. The same thing from the devtools console of the
webview, which is the scripting seam:

```js
await window.dormouseRemoteHost.enroll('http://localhost:3000', 'hunter2', 'My Laptop')
Expand Down
Loading