diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index a08490dd..680293c3 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -6,9 +6,11 @@ on: - main paths: - 'lib/**' + - 'docs/stories/**' pull_request: paths: - 'lib/**' + - 'docs/stories/**' permissions: contents: read diff --git a/AGENTS.md b/AGENTS.md index cead5c14..c1abdf5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/DESIGN.md b/DESIGN.md index edb19934..00ea0142 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/SELF_HOST.md b/SELF_HOST.md index 85addfca..5ccb1f3e 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -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://..ts.net', '', '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 diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 3fdf9676..a7a5b3b3 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -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 | diff --git a/docs/specs/server.md b/docs/specs/server.md index 8cc4f191..96b05ca0 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -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 @@ -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 @@ -534,6 +536,79 @@ away. existing resize path. The "tethering to \" 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`: @@ -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') diff --git a/docs/stories/pairing.mdx b/docs/stories/pairing.mdx new file mode 100644 index 00000000..d0fef331 --- /dev/null +++ b/docs/stories/pairing.mdx @@ -0,0 +1,331 @@ +import { Meta, Canvas } from '@storybook/addon-docs/blocks'; + +import * as RemoteControl from '../../lib/src/stories/RemoteControlSection.stories'; +import * as Pairing from '../../lib/src/stories/RemotePairingModal.stories'; +import * as SetupOrSignin from '../../lib/src/stories/SetupOrSignin.stories'; +import * as HostsView from '../../lib/src/stories/HostsView.stories'; +import * as PocketWall from '../../lib/src/stories/PocketWall.stories'; + + + +# Pairing a phone with your laptop + +Everything it takes to control a laptop's terminals from a phone, in the order +you meet it: stand up a server, build a Host that is allowed to reach it, enroll +the laptop, make a passkey on the phone, approve the pairing, connect. + +The screens below are the real components, rendered from the same stories +Chromatic watches. They are static — nothing here is wired to a live server. +Two of them carry `autoplay` because a docs page does not run a story's `play` +by default, and their state — an expanded disclosure, a painted terminal — only +exists after it runs. + +> **This is a walkthrough, not a spec.** It lives outside `docs/specs/`, so the +> fold discipline in `AGENTS.md` does not apply to it and `spec-lint` does not +> check it. Where it disagrees with a spec, the spec is right. The three it +> leans on are `server.md` (what the server and the installer do), +> `remote-security-model.md` (why the ceremony has the shape it has), and +> `pocket-app.md` (the phone). + +--- + +## 0. Three parties, four layers + +| Term | Who that is here | +| --- | --- | +| **Client** (Dormouse Pocket) | the phone. Holds a passkey and a device key, and signs Host challenges. | +| **Host** (Dormouse Terminal) | the laptop being controlled. Holds the ACL. **Final authority for access decisions.** | +| **Server** | the coordinating service: accounts, passkey registration, challenges, and relay. *Not* the authority for Host access. | + +Access is deliberately split across four layers, and a connection needs all of +them to agree: + +| Layer | Responsibility | +| --- | --- | +| Passkey | Fresh user presence | +| Device Key | Long-lived client identity | +| Host ACL | Authorization | +| Host | Final access decision | + +The consequence that shapes every screen after this one: **adding a passkey to +your account does not grant a new phone access to any machine.** Passkeys sync; +authorization does not. Each browser must be approved on each laptop, in person, +once. + +--- + +## 1. Stand up the server + +The whole self-host story today is one coordinating server on your own Mac, +reachable only from your tailnet. One idempotent command builds the current +checkout into a self-contained release and installs it: + +```sh +./deploy/local/install-macos.sh +``` + +It installs under your home directory, needs no `sudo`, and wires up a +LaunchAgent plus Tailscale Serve: + +```text +LaunchAgent (RunAtLoad + KeepAlive) + | + v +Dormouse Node server on 127.0.0.1:3100 + | + v +tailscale serve --bg terminates private HTTPS + | + v +https://..ts.net +``` + +Then check it and read the generated password out in your own terminal: + +```sh +"$HOME/Library/Application Support/Dormouse Server/bin/manage" verify +"$HOME/Library/Application Support/Dormouse Server/bin/manage" show-password +``` + +Two things to carry forward: + +- **That origin is durable WebAuthn identity.** The passkey you are about to + create is bound to `https://..ts.net` byte for byte. + Renaming or re-enrolling the Tailscale node means redoing the passkey and + every Host enrollment — which is why the installer stops rather than rewrite + a changed origin for you. +- **HTTPS is not optional.** WebAuthn needs a secure context and only + `localhost` is exempt, so a real phone needs the TLS origin. For a + desktop-only dev loop, `DORMOUSE_SETUP_PASSWORD=hunter2 pnpm dev:pocket-server` + on `localhost:3000` is the shortcut. + +The setup password gates exactly two things: creating the one account, and +enrolling a Host. It never leaves your machine except to those two endpoints. + +--- + +## 2. Build a Host that is allowed to reach it + +Shipped Dormouse builds bake in a relay allowlist of +`https://*.dormouse.sh wss://*.dormouse.sh` and nothing else, enforced inside +the Node process that holds the relay socket rather than by any webview CSP. A +self-host origin needs a local build that widens it: + +```sh +DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:standalone +DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:vscode +``` + +Skipping this has a specific, recognizable symptom, and you will see it in the +next section rather than as a mysterious failure: enrollment is refused +locally, before the setup password leaves the machine. + +--- + +## 3. Enroll the laptop + +**Settings → Remote control**, at the bottom of the app-global Settings dialog +(the sliders icon at the far right of the baseboard). Three fields. + + + +This is UI rather than a console incantation because it is the one step a +self-hoster cannot skip. Three rules it exists to honor: + +- **The password is passed through, never held.** It goes straight to the Host + service — the party that actually talks to the server — and is cleared on + success only. A failed enroll is usually a typo in one of the other fields, + and wiping the password would make every retry a fresh trip to the password + manager. +- **Refusals are shown, not swallowed.** This is section 2's symptom: + + + + The build refuses the origin before the password is sent anywhere. Saying so + in those words is what keeps it from reading as a wrong password. + +- **The bearer token never comes back.** Enrollment answers + `{ hostId, serverUrl }`; the `hostToken` stays in the Host service and on + disk, and never enters the webview. + +On success the section flips to the enrolled view. Note the last line — +enrolling connects the machine to a server; it pairs it with nothing. + + + +--- + +## 4. Make a passkey on the phone + +Open the same origin on a tailnet-connected phone. Pocket loads. + + + +There is nothing to sign in with yet, so the first visit goes through the +disclosure: the setup password from `manage show-password`, plus a label for +this passkey. + + + +That one action creates the account's first passkey and signs in with it. Two +things you cannot see happen at the same time: + +- A **device key** is minted — a non-extractable ECDSA P-256 keypair kept in + IndexedDB. This browser can sign with it and can never export it. It is this + browser's permanent identity, and it is what the laptop is about to + authorize. +- The session's **presence stamp** is set. Pairing requires a passkey assertion + from the last 30 seconds, so signing in is what makes the next step possible. + +> **On iOS, install to the Home Screen before you do any of this.** The +> installed app is a separate storage partition from the Safari tab, so it gets +> its own device key and needs its own pairing approval. Setting up in the tab +> first means doing all of it twice. iOS also delivers Web Push only to an +> installed app. + +--- + +## 5. Pick a machine + + + +One row per enrolled Host. A row is offline when no Dormouse instance currently +holds that server's relay slot. **Pair** appears only on an online, unpaired +Host — everything else is a Connect away. + +--- + +## 6. The pairing ceremony + +Tapping **Pair** sends the phone's identity up to the server and across to the +laptop: + +```text +phone server host (laptop) + |-- signin (passkey) -------->| | + | generate device key | | + |-- pair -------------------->|-- pair --------------------->| approval modal + | | | user clicks Approve + |<-- pair-result -------------|<-- pair-result --------------| ACL record saved +``` + +The server checks what it can: that the credential is a registered passkey of +the account, that its public-key hash matches the stored key, and that the +session's last verified assertion is within 30 seconds. A malformed or stale +request is answered there and **never reaches the approval modal** — so what a +person is asked to look at is always a real, fresh request. (A stale one costs +the phone one extra biometric prompt and a retry, not a restart.) + +Then the laptop interrupts: + + + +This modal is the whole security model in one screen, which is why it is a +modal and not a settings row. + +- **Local approval is the only path into the ACL.** Not signing in, not owning + the account, not holding a synced passkey. Someone with your account and your + passkey still gets no further than this dialog on a machine they are not + standing at. +- **It has to not read as a formality.** Hence *"Approve only if you are the + one asking"* rather than a bare Are-you-sure, and hence Deny holding initial + focus. +- **The three rows are the identity being authorized.** Device is the label the + phone suggested, Account is who signed in, and Key is the first 8 characters + of that browser's device public key — the part that distinguishes two + Clients on the same physical phone. +- **"Adds it to this machine only"** is doing real work. The ACL is per-Host + and local; your other laptops are unaffected and will each ask separately. + +Approving writes one `HostAclRecord` binding *that passkey credential* to *that +device key* — a pair, not either one alone. The approval is bound to the +ceremony's immutable `pairingId`, so a request that changed underneath a +displayed modal is rejected rather than silently approving a device nobody was +shown. + +Two cases the copy has to survive: a phone that suggested no label, and an +account and label long enough to wrap. + + + + + +--- + +## 7. Connect + +Pairing is once. Connecting is every session, and it re-proves everything: + +```text +phone server host + |-- connect {hostId} -------->|-- connect {clientId} ------->| + |<-- challenge ---------------|<-- challenge (HostChallengeIssuer) + | ONE biometric prompt: | | + | WebAuthn get({challenge}) | | + | + device-key signature | | + |-- ConnectionRequest ------->| server verifies the | + | | assertion itself, then | + | |-- ConnectionRequest -------->| authorizeConnection() + |<-- decision ----------------|<-- decision -----------------| (final authority) + |============ opaque remote-api relay from here ============>| +``` + +A connection succeeds only if all five hold: the passkey proves fresh presence, +the Server recognizes the account, the Host recognizes the passkey credential, +the Host recognizes the device key, and the Client signs a fresh Host challenge +with that device key. The Host decides last and decides alone, regardless of +what the Server claims to have already checked. + +One host challenge feeds both signatures, so the whole thing costs the user a +single Face ID prompt. + +Then the payoff — the same mobile terminal UI, driven by the laptop's real +sessions: + + + +--- + +## 8. Living with it + +Back on the laptop, the section now counts what is paired: + + + +**Displaced** is the one connection state that needs a person. Another Dormouse +instance enrolled with the same `hostId` took the relay slot, and this one stood +down — terminally, on purpose, because two instances fighting over a slot is +worse than one stopping. Reconnect takes it back, and displaces the other in +turn. + + + +**Disconnect** asks first, because forgetting the enrollment drops every paired +phone until each pairs again. + + + +Two limits worth knowing before they surprise you: + +- **Clearing site data destroys the device key.** That is a re-pair, by design: + the identity the laptop authorized is gone — and the storage partition the + key lives in is per-browser, as §4 warns. +- **A dropped WebSocket returns the phone to the Hosts view.** Tap Connect + again; there is no resume protocol. + +--- + +## 9. What is not built yet + +So nothing above reads as a promise: + +- **Revocation is editing a JSON file by hand.** There is no management UI and + no relay frame carries a revocation; a removed record takes effect at the + Host's next authorization check. +- **Approval is Approve/Deny only.** Choosing a standing grant at pairing time + — observe-only versus interactive — is designed but not built. Every paired + session today gets full input. +- **The Host does not show you who is connected.** A viewer list with + per-viewer disconnect is staged. +- **Terminals only.** The shipped protocol carries terminal surfaces and + nothing else — no browser surfaces, no thumbnails, no scrollback. diff --git a/lib/.storybook/main.ts b/lib/.storybook/main.ts index 5f42e176..fb8f80b3 100644 --- a/lib/.storybook/main.ts +++ b/lib/.storybook/main.ts @@ -1,11 +1,18 @@ import type { StorybookConfig } from '@storybook/react-vite'; import path from 'path'; +import { createRequire } from 'module'; import { fileURLToPath } from 'url'; const here = path.dirname(fileURLToPath(import.meta.url)); +const requireFromHere = createRequire(import.meta.url); const config: StorybookConfig = { - stories: ['../src/**/*.stories.@(ts|tsx)'], + // The narrative walkthrough in `docs/stories/` lives outside this package on + // purpose: it is a doc about the product, not about `lib`, and it references + // stories from here rather than defining any (MDX has not been able to define + // a story since Storybook 7). + stories: ['../src/**/*.stories.@(ts|tsx)', '../../docs/stories/**/*.mdx'], + addons: ['@storybook/addon-docs'], framework: '@storybook/react-vite', viteFinal: (config) => { const stub = path.resolve(here, 'tauri-stub.ts'); @@ -35,6 +42,13 @@ const config: StorybookConfig = { // unbuilt `dist`. The directory alias covers the subpath and the bare // specifier both. 'dor-lib-common': path.resolve(here, '..', '..', 'dor-lib-common', 'src'), + // `docs/stories/*.mdx` lives outside this package, so Node resolution + // from that file never reaches `lib/node_modules` and the docs blocks + // fail to resolve. Resolve them here, where the package *is* installed, + // and alias the exact specifier the MDX imports. + '@storybook/addon-docs/blocks': requireFromHere.resolve( + '@storybook/addon-docs/blocks', + ), }; return config; }, diff --git a/lib/.storybook/preview.ts b/lib/.storybook/preview.ts index 26bab40f..2ad9ec7c 100644 --- a/lib/.storybook/preview.ts +++ b/lib/.storybook/preview.ts @@ -29,6 +29,10 @@ import { type AlertSpeechState, } from '../src/lib/alert-speech-state'; import { VSCODE_THEMES, VSCODE_THEME_TYPES } from './themes'; +import { + makeStubRemoteHostLink, + type PrimedRemoteHost, +} from '../src/host/remote/test-remote-host-link'; import { cfg } from '../src/cfg'; import type { DormouseTheme } from '../src/lib/themes'; import { clearPersistedShellSelection, seedShellStore } from '../src/lib/shell-store'; @@ -237,6 +241,20 @@ const preview: Preview = { // is how the Settings dialog decides to hide its Shell row. platform.hostOwnsShells = context.parameters?.hostOwnsShells === true || undefined; + // And the same seam again for the Settings dialog's Remote control + // section, which renders nothing without a Host service behind the + // webview (`docs/specs/server.md`). Absent is the honest default for a + // fake platform, so only the stories about that section prime a stub. + // Read during render like the two above: the store reads `remoteHost` + // when the section first subscribes, which is after this decorator's + // render body and before any effect. + const primedRemoteHost = context.parameters?.primedRemoteHost as + | PrimedRemoteHost + | undefined; + platform.remoteHost = primedRemoteHost + ? makeStubRemoteHostLink(primedRemoteHost) + : undefined; + // Installed themes normally arrive from OpenVSX and live in localStorage, // which every story shares — so a story that wants them names them, and // every other story clears them. diff --git a/lib/package.json b/lib/package.json index 97e3cda5..bade298a 100644 --- a/lib/package.json +++ b/lib/package.json @@ -34,6 +34,7 @@ "tailwind-variants": "^3.2.2" }, "devDependencies": { + "@storybook/addon-docs": "^10.4.0", "@storybook/react": "^10.4.0", "@storybook/react-vite": "^10.4.0", "@tailwindcss/vite": "^4.3.0", diff --git a/lib/src/components/RemoteControlSection.test.tsx b/lib/src/components/RemoteControlSection.test.tsx new file mode 100644 index 00000000..8dc4ed58 --- /dev/null +++ b/lib/src/components/RemoteControlSection.test.tsx @@ -0,0 +1,265 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * The store reads `getPlatform().remoteHost`, so the link is the only seam the + * whole section hangs off. Mutable so a test can present a build with no Host + * service behind it, which is a rendering decision rather than an error. + */ +let platform: { remoteHost?: unknown } = {}; + +vi.mock('../lib/platform', () => ({ + IS_MAC: false, + getPlatform: () => platform, +})); + +import { RemoteControlSection } from './RemoteControlSection'; +import type { RemoteHostConsoleStatus } from '../host/remote/service-protocol'; +import { + enrolledStatus, + UNENROLLED_STATUS as NOT_ENROLLED, +} from '../host/remote/test-remote-host-link'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +type Handler = (data: unknown) => void; + +function makeLink(command: (cmd: string, params?: unknown) => Promise) { + const listeners = new Map>(); + return { + command: vi.fn(command), + on: vi.fn((name: string, listener: Handler) => { + const set = listeners.get(name) ?? new Set(); + set.add(listener); + listeners.set(name, set); + return () => set.delete(listener); + }), + respond: vi.fn(), + notify: vi.fn(), + emit(name: string, data: unknown) { + for (const listener of listeners.get(name) ?? []) listener(data); + }, + }; +} + +/** The shared fixture, keeping this file's own server/host values. */ +const enrolled = (over: Partial = {}) => + enrolledStatus({ + serverUrl: 'https://laptop.tailnet.ts.net', + hostId: 'host-1', + pairedClients: 1, + ...over, + }); + +let container: HTMLDivElement; +let root: Root; + +async function render() { + await act(async () => { + root.render(); + }); +} + +function text(): string { + return container.textContent ?? ''; +} + +function buttonLabelled(label: string): HTMLButtonElement | undefined { + return [...container.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === label, + ) as HTMLButtonElement | undefined; +} + +async function type(selector: string, value: string) { + const input = container.querySelector(selector); + if (!input) throw new Error(`no input for ${selector}`); + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value', + )!.set!; + await act(async () => { + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); +} + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + // Unmounting drops the store's last subscriber, which resets it — otherwise + // one test's status would seed the next one's first paint. + await act(async () => root.unmount()); + container.remove(); + platform = {}; + vi.clearAllMocks(); +}); + +describe('RemoteControlSection', () => { + it('renders nothing on a build with no Host service', async () => { + platform = {}; + await render(); + expect(container.innerHTML).toBe(''); + }); + + it('offers the enroll form when the machine is not enrolled', async () => { + platform = { remoteHost: makeLink(async () => NOT_ENROLLED) }; + await render(); + expect(text()).toContain('Connect this machine to a Dormouse server'); + expect(buttonLabelled('Connect')).toBeTruthy(); + }); + + it('keeps Connect disabled until every field is filled', async () => { + platform = { remoteHost: makeLink(async () => NOT_ENROLLED) }; + await render(); + + expect(buttonLabelled('Connect')!.disabled).toBe(true); + await type('input[type="url"]', 'https://laptop.tailnet.ts.net'); + expect(buttonLabelled('Connect')!.disabled).toBe(true); + await type('input[type="password"]', 'hunter2'); + expect(buttonLabelled('Connect')!.disabled).toBe(true); + await type('input:not([type="url"]):not([type="password"])', 'Work laptop'); + expect(buttonLabelled('Connect')!.disabled).toBe(false); + }); + + it('enrolls with trimmed values and re-reads the status', async () => { + let status: unknown = NOT_ENROLLED; + const link = makeLink(async (cmd) => { + if (cmd === 'enroll') { + status = enrolled(); + return { hostId: 'host-1', serverUrl: 'https://laptop.tailnet.ts.net' }; + } + return status; + }); + platform = { remoteHost: link }; + await render(); + + await type('input[type="url"]', ' https://laptop.tailnet.ts.net '); + await type('input[type="password"]', 'hunter2'); + await type('input:not([type="url"]):not([type="password"])', ' Work laptop '); + await act(async () => buttonLabelled('Connect')!.click()); + + expect(link.command).toHaveBeenCalledWith('enroll', { + serverUrl: 'https://laptop.tailnet.ts.net', + password: 'hunter2', + label: 'Work laptop', + }); + // The status re-read after enrolling is what flips the view. + expect(text()).toContain('https://laptop.tailnet.ts.net'); + expect(text()).toContain('Connected'); + }); + + it('surfaces an enrollment refusal instead of silently failing', async () => { + const link = makeLink(async (cmd) => { + if (cmd === 'enroll') throw new Error('server origin is not allowed by this build'); + return NOT_ENROLLED; + }); + platform = { remoteHost: link }; + await render(); + + await type('input[type="url"]', 'https://evil.example.com'); + await type('input[type="password"]', 'hunter2'); + await type('input:not([type="url"]):not([type="password"])', 'Work laptop'); + await act(async () => buttonLabelled('Connect')!.click()); + + expect(text()).toContain('server origin is not allowed by this build'); + // Still on the form, so the user can correct the origin and retry. + expect(buttonLabelled('Connect')).toBeTruthy(); + }); + + it('shows the server and paired-device count when enrolled', async () => { + platform = { remoteHost: makeLink(async () => enrolled({ pairedClients: 2 })) }; + await render(); + expect(text()).toContain('https://laptop.tailnet.ts.net'); + expect(text()).toContain('2 paired devices'); + expect(buttonLabelled('Reconnect')).toBeUndefined(); + }); + + it('offers Reconnect only when the Host was displaced', async () => { + const link = makeLink(async () => enrolled({ connection: 'displaced' })); + platform = { remoteHost: link }; + await render(); + + expect(text()).toContain('took this server’s slot'); + await act(async () => buttonLabelled('Reconnect')!.click()); + expect(link.command).toHaveBeenCalledWith('reconnect'); + }); + + it('confirms before disconnecting, because paired phones must re-pair', async () => { + const link = makeLink(async () => enrolled()); + platform = { remoteHost: link }; + await render(); + + await act(async () => buttonLabelled('Disconnect')!.click()); + expect(link.command).not.toHaveBeenCalledWith('clearEnrollment'); + expect(text()).toContain('Paired phones will need to pair again'); + + await act(async () => buttonLabelled('Disconnect')!.click()); + expect(link.command).toHaveBeenCalledWith('clearEnrollment'); + }); + + it('follows the connection state, which fires no event', async () => { + vi.useFakeTimers(); + try { + let status: unknown = enrolled({ connection: 'connecting' }); + const link = makeLink(async () => status); + platform = { remoteHost: link }; + await render(); + expect(text()).toContain('Connecting…'); + + // `connecting -> connected` does not change `enrolled`, so the service + // sends nothing. Only the poll notices. + status = enrolled({ connection: 'connected' }); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(text()).toContain('Connected'); + expect(text()).not.toContain('Connecting…'); + } finally { + vi.useRealTimers(); + } + }); + + it('stops polling once nothing is watching', async () => { + vi.useFakeTimers(); + try { + const link = makeLink(async () => enrolled()); + platform = { remoteHost: link }; + await render(); + await act(async () => root.unmount()); + + const callsAtUnmount = link.command.mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(link.command.mock.calls.length).toBe(callsAtUnmount); + // Re-create the root so afterEach's unmount stays valid. + root = createRoot(container); + } finally { + vi.useRealTimers(); + } + }); + + it('re-reads the status when the service announces a change', async () => { + let status: unknown = NOT_ENROLLED; + const link = makeLink(async () => status); + platform = { remoteHost: link }; + await render(); + expect(text()).toContain('Connect this machine to a Dormouse server'); + + // Another window enrolled: the event carries only `{ enrolled }`, so the + // section must re-read rather than patch a field. + status = enrolled(); + await act(async () => { + link.emit('status', { name: 'status', enrolled: true }); + }); + expect(text()).toContain('https://laptop.tailnet.ts.net'); + }); +}); diff --git a/lib/src/components/RemoteControlSection.tsx b/lib/src/components/RemoteControlSection.tsx new file mode 100644 index 00000000..7b848682 --- /dev/null +++ b/lib/src/components/RemoteControlSection.tsx @@ -0,0 +1,265 @@ +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'; +import { TextInput, modalActionButton } from './design'; +import type { RemoteHostStatus } from '../remote/host/remote-host'; +import { + clearRemoteHostEnrollment, + enrollRemoteHost, + getRemoteHostStatusSnapshot, + reconnectRemoteHost, + refreshRemoteHostStatus, + subscribeToRemoteHostStatus, +} from '../remote/host/host-status-store'; + +/** + * How each relay-socket state reads to someone who is not holding the spec. + * `displaced` is the only one that needs the user to act, so it is the only one + * that gets a button (`docs/specs/server.md`, "Relay socket policy"). + */ +function describeConnection(connection: RemoteHostStatus): { text: string; tone: 'ok' | 'warn' | 'muted' } { + switch (connection) { + case 'connected': + return { text: 'Connected', tone: 'ok' }; + case 'connecting': + return { text: 'Connecting…', tone: 'muted' }; + case 'disconnected': + return { text: 'Reconnecting…', tone: 'muted' }; + case 'displaced': + return { + text: 'Another Dormouse instance took this server’s slot. This machine stood down and will not retry on its own.', + tone: 'warn', + }; + case 'stopped': + return { text: 'Stopped', tone: 'muted' }; + case 'idle': + return { text: 'Not connected', tone: 'muted' }; + } +} + +const TONE_CLASS = { + ok: 'text-foreground', + warn: 'text-error', + muted: 'text-muted', +} as const; + +const FIELD_LABEL = 'text-xs text-muted'; + +/** + * Connect this machine to a coordinating server, so a phone running Dormouse + * Pocket can pair with it. + * + * Renders nothing at all on a build with no Host service behind it (the + * website, the lib dev server): there is no Host to enroll, and offering the + * form would promise something the build cannot do. + * + * This is the same `enroll` / `status` / `reconnect` / `clearEnrollment` + * surface as the `window.dormouseRemoteHost` console hook, which stays as the + * scripting seam (`docs/specs/server.md`, "Host side"). Pairing approval is + * *not* here — it is a modal, because it must interrupt + * (`docs/specs/remote-security-model.md`, Pairing Ceremony). + */ +export function RemoteControlSection() { + const state = useSyncExternalStore(subscribeToRemoteHostStatus, getRemoteHostStatusSnapshot); + + // Another window may have enrolled since this dialog last opened, and the + // service pushes `status` only when it changes. + useEffect(() => void refreshRemoteHostStatus(), []); + + if (state.kind === 'unsupported') return null; + + return ( +
+
Remote control
+ {state.kind === 'loading' ? ( +
Checking…
+ ) : state.kind === 'error' ? ( +
+ Could not reach this machine’s Host service: {state.message} +
+ ) : state.status.enrolled ? ( + + ) : ( + + )} +
+ ); +} + +function EnrolledView({ + serverUrl, + connection, + pairedClients, +}: { + serverUrl: string | null; + connection: RemoteHostStatus; + pairedClients: number; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Disconnecting drops every paired phone until they pair again, so it asks + // once rather than acting on the first click. + const [confirmingDisconnect, setConfirmingDisconnect] = useState(false); + const described = describeConnection(connection); + + const run = useCallback(async (action: () => Promise) => { + setBusy(true); + setError(null); + try { + await action(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setBusy(false); + } + }, []); + + return ( +
+
{serverUrl ?? 'Unknown server'}
+
{described.text}
+
+ {pairedClients === 0 + ? 'No phone has paired with this machine yet.' + : `${pairedClients} paired ${pairedClients === 1 ? 'device' : 'devices'}.`} +
+ + {error ?
{error}
: null} + +
+ {connection === 'displaced' ? ( + + ) : null} + {confirmingDisconnect ? ( + <> + Paired phones will need to pair again. + + + + ) : ( + + )} +
+
+ ); +} + +function EnrollForm() { + const [serverUrl, setServerUrl] = useState(''); + const [password, setPassword] = useState(''); + const [label, setLabel] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const ready = serverUrl.trim() !== '' && password !== '' && label.trim() !== ''; + + const submit = useCallback(async () => { + setBusy(true); + setError(null); + try { + await enrollRemoteHost(serverUrl.trim(), password, label.trim()); + // Only on success: a failed enroll is usually a typo in one of the other + // fields, and clearing the password would make every retry a re-fetch + // from the password manager. + setPassword(''); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setBusy(false); + } + }, [serverUrl, password, label]); + + return ( +
{ + e.preventDefault(); + if (ready && !busy) void submit(); + }} + > +
+ Connect this machine to a Dormouse server to control it from your phone. +
+ + + + + + + + {error ?
{error}
: null} + +
+ +
+
+ ); +} diff --git a/lib/src/components/SettingsDialog.tsx b/lib/src/components/SettingsDialog.tsx index 08fc38cb..0a284339 100644 --- a/lib/src/components/SettingsDialog.tsx +++ b/lib/src/components/SettingsDialog.tsx @@ -12,6 +12,7 @@ import { import { ThemePicker } from './ThemePicker'; import { ShellPicker } from './ShellPicker'; import { WatchedCommandList } from './WatchedCommandList'; +import { RemoteControlSection } from './RemoteControlSection'; import { getPlatform } from '../lib/platform'; import { getShellsSnapshot, subscribeToShells } from '../lib/shell-store'; import { @@ -42,10 +43,20 @@ const SECTION = 'mt-4 border-t border-border pt-3'; * (`docs/specs/remote-security-model.md`), so there is no account-wide device * list to show and the copy must not imply one. */ -function describePushTargets(push: PushDevicesState): string { +function describePushTargets(push: PushDevicesState, hasHostService: boolean): string { if (push.status === 'loading') return 'Looking for devices…'; if (push.status === 'error') return 'Could not reach the server to list devices.'; - if (push.status === 'no-host') return 'Connect this machine to a Dormouse server to send push.'; + // `no-host` covers two builds: one whose Host service simply has not enrolled, + // and one with no Host service at all (`push-devices.ts` — the website leaves + // it here forever). Only the first has a Remote control section beneath this + // line, because the second is exactly where that section renders nothing, so + // "below" has to key on the same seam the section gates on rather than on + // `no-host`. + if (push.status === 'no-host') { + return hasHostService + ? 'Connect this machine to a Dormouse server below to send push.' + : 'Connect this machine to a Dormouse server to send push.'; + } if (push.devices.length === 0) { return 'No device paired with this machine has enabled alerts in Dormouse Pocket yet.'; } @@ -86,6 +97,9 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) { // to offer. That also covers every host whose adapter detects no shells and // every host that never seeds the store (fake = 1, remote = 0). const showShell = !getPlatform().hostOwnsShells && shellState.shells.length >= 2; + // The same seam `RemoteControlSection` gates on, read here so the push line + // above it cannot promise a section this build does not render. + const hasHostService = getPlatform().remoteHost !== undefined; // A phone can enable alerts long after this machine booted, so re-read the // list on open rather than showing whatever was true at Host start. @@ -182,8 +196,13 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) { onToggle={(pushEnabled) => updateAlertSettings({ pushEnabled })} onCommitDelay={(pushDelayMs) => updateAlertSettings({ pushDelayMs })} > - {describePushTargets(push)} + {describePushTargets(push, hasHostService)} + + {/* Last, and directly under the push section that points at it: push is + the feature that makes a reader care, and "no Host" is the reason it + has nowhere to go. Renders nothing on a build with no Host service. */} + ); } diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx index 7de4c78c..9b289438 100644 --- a/lib/src/components/design.tsx +++ b/lib/src/components/design.tsx @@ -328,6 +328,34 @@ export const NumericInput = forwardRef( }, ); +export type TextInputProps = Omit, 'onChange' | 'value'> & { + value: string; + onChange: (next: string) => void; +}; + +/** + * A full-width underlined text field — the string counterpart to + * {@link NumericInput}, sharing its underline so a dialog mixing the two reads + * as one form. Unlike NumericInput it filters nothing and sets no `type`, so a + * caller passes `type="password"` for a credential. + */ +export const TextInput = forwardRef( + function TextInput({ value, onChange, className, ...props }, ref) { + return ( + onChange(e.target.value)} + className={clsx( + 'w-full border-0 border-b border-border bg-transparent px-0.5 py-0.5 font-mono text-foreground outline-none placeholder:text-muted focus:border-focus-ring', + className, + )} + {...props} + /> + ); + }, +); + /** * Left margin that lines content up under an `OnOffSwitch`'s label rather than * its pill: the switch's `w-14` plus the usual `gap-3` between them. Lives here diff --git a/lib/src/host/remote/test-remote-host-link.ts b/lib/src/host/remote/test-remote-host-link.ts new file mode 100644 index 00000000..5c5f06c6 --- /dev/null +++ b/lib/src/host/remote/test-remote-host-link.ts @@ -0,0 +1,77 @@ +/** + * The fake `RemoteHostLink` and status fixtures the Settings dialog's Remote + * control section is exercised against. + * + * Test-only, and shared on purpose — the same reasoning as + * `lib/src/remote/test-fake-socket.ts`. `RemoteControlSection` hangs entirely + * off `getPlatform().remoteHost`, so its unit test and its stories need the + * same two things: a link that answers `status`, and a + * {@link RemoteHostConsoleStatus} to answer it with. Kept typed here, next to + * the interface it fixtures, so adding a field to that interface breaks this + * file rather than letting one caller quietly keep asserting the old shape. + * + * Imports no test framework: the Storybook preview and the story bundle load + * this, and neither may pull `vitest` in (the same rule `lib/tsconfig.app.json` + * records for `wall-test-utils.ts`). Callers that want spies wrap these. + */ + +import type { RemoteHostConsoleStatus } from './service-protocol'; +import type { RemoteHostLink } from '../../lib/platform/types'; + +/** A machine that has never enrolled: the section shows its three-field form. */ +export const UNENROLLED_STATUS: RemoteHostConsoleStatus = { + enrolled: false, + serverUrl: null, + hostId: null, + connection: 'idle', + pairedClients: 0, +}; + +/** An enrolled machine, with the fields a caller is likely to vary. */ +export function enrolledStatus( + over: Partial = {}, +): RemoteHostConsoleStatus { + return { + enrolled: true, + serverUrl: 'https://ned-mac.tail9c2f1.ts.net', + hostId: 'host-6f1c2a90', + connection: 'connected', + pairedClients: 0, + ...over, + }; +} + +/** What {@link makeStubRemoteHostLink} should answer. */ +export interface PrimedRemoteHost { + /** What `status` answers. */ + status?: RemoteHostConsoleStatus; + /** Make `status` reject — "could not reach this machine's Host service". */ + statusError?: string; + /** Make `enroll` reject — the refused-origin case the form renders inline. */ + enrollError?: string; +} + +/** + * A link that answers from a fixed status rather than a real Host service. + * + * Deliberately not a scenario engine: a story is one frame, so `enroll`, + * `reconnect` and `clearEnrollment` resolve without changing the answer. The + * exception is `enrollError`, because a refused origin is a state the form must + * render (`docs/specs/server.md`, "Remote control, in the Settings dialog") and + * a rejected `enroll` is the only way to reach it. + */ +export function makeStubRemoteHostLink(primed: PrimedRemoteHost): RemoteHostLink { + return { + command: async (cmd) => { + if (cmd === 'status') { + if (primed.statusError) throw new Error(primed.statusError); + return primed.status ?? UNENROLLED_STATUS; + } + if (cmd === 'enroll' && primed.enrollError) throw new Error(primed.enrollError); + return null; + }, + respond: () => {}, + notify: () => {}, + on: () => () => {}, + }; +} diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index abf35f41..c9413406 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -1,4 +1,4 @@ -import type { AlertStateDetail, OpenPort, PlatformAdapter, PtyInfo } from './types'; +import type { AlertStateDetail, OpenPort, PlatformAdapter, PtyInfo, RemoteHostLink } from './types'; import { AlertManager } from '../alert-manager'; import type { AlertSettings } from '../alert-settings'; import { normalizeExternalUri } from '../external-links'; @@ -55,6 +55,12 @@ export class FakePtyAdapter implements PlatformAdapter { hostOwnsTheme?: boolean; hostOwnsShells?: boolean; + // Same reason, one layer up: a fake platform has no Host service behind it, so + // this stays undefined and the Settings dialog's Remote control section renders + // nothing (`docs/specs/server.md`). The preview decorator installs a stub link + // for the stories that are *about* that section. + remoteHost?: RemoteHostLink; + constructor() { this.alertManager.onStateChange((id, state) => { for (const handler of this.alertStateHandlers) { diff --git a/lib/src/remote/host/host-status-store.test.ts b/lib/src/remote/host/host-status-store.test.ts new file mode 100644 index 00000000..aa62678e --- /dev/null +++ b/lib/src/remote/host/host-status-store.test.ts @@ -0,0 +1,224 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { RemoteHostLink } from '../../lib/platform/types'; + +let remoteHostLink: RemoteHostLink | undefined; + +vi.mock('../../lib/platform', () => ({ + getPlatform: () => ({ remoteHost: remoteHostLink }), +})); + +import { + clearRemoteHostEnrollment, + getRemoteHostStatusSnapshot, + subscribeToRemoteHostStatus, +} from './host-status-store'; + +afterEach(() => { + remoteHostLink = undefined; + vi.useRealTimers(); +}); + +describe('host status polling', () => { + it('lets a slow status timeout commit without overlapping polls superseding it', async () => { + vi.useFakeTimers(); + let activeReads = 0; + let maxActiveReads = 0; + const command = vi.fn( + () => + new Promise((_resolve, reject) => { + activeReads++; + maxActiveReads = Math.max(maxActiveReads, activeReads); + setTimeout(() => { + activeReads--; + reject(new Error('status timed out')); + }, 15_000); + }), + ); + remoteHostLink = { + command, + respond: () => {}, + notify: () => {}, + on: () => () => {}, + }; + + const unsubscribe = subscribeToRemoteHostStatus(() => {}); + try { + expect(command).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(14_000); + expect(command).toHaveBeenCalledTimes(1); + expect(maxActiveReads).toBe(1); + expect(getRemoteHostStatusSnapshot()).toEqual({ kind: 'loading' }); + + await vi.advanceTimersByTimeAsync(1_000); + expect(maxActiveReads).toBe(1); + expect(getRemoteHostStatusSnapshot()).toEqual({ + kind: 'error', + message: 'status timed out', + }); + } finally { + unsubscribe(); + } + }); +}); + +describe('re-reading after a mutation', () => { + /** + * The poll may coalesce, because any recent answer will do. A lifecycle + * command may not: a `status` issued before the disconnect answers the + * question as it stood beforehand, so joining it would report this machine + * still enrolled after its enrollment was successfully deleted — the exact + * claim the service's delete-first ordering exists to prevent. + */ + it('does not resolve on a status read that predates the command', async () => { + let enrolled = true; + /** Set while the first `status` is deliberately left hanging. */ + let releaseFirstRead: (() => void) | null = null; + + const statusAnswer = () => ({ + enrolled, + serverUrl: 'https://laptop.tailnet.ts.net', + hostId: 'host-1', + connection: 'connected', + pairedClients: 1, + }); + + let seenStatus = 0; + const command = vi.fn(async (cmd: string) => { + if (cmd === 'clearEnrollment') { + enrolled = false; + return null; + } + // Every `status` answers with enrollment as it stood when it was *called*. + const answer = statusAnswer(); + if (++seenStatus > 1) return answer; + return new Promise((resolve) => { + releaseFirstRead = () => resolve(answer); + }); + }); + remoteHostLink = { + command, + respond: () => {}, + notify: () => {}, + on: () => () => {}, + }; + + const unsubscribe = subscribeToRemoteHostStatus(() => {}); + try { + // Subscribing issued a read that is still in flight, and still says enrolled. + expect(releaseFirstRead).not.toBeNull(); + + // Disconnect, and let its own re-read run to completion... + await clearRemoteHostEnrollment(); + // ...then let the pre-disconnect read land. It must not win. + releaseFirstRead!(); + await Promise.resolve(); + + expect(getRemoteHostStatusSnapshot()).toMatchObject({ + kind: 'ready', + status: { enrolled: false }, + }); + } finally { + unsubscribe(); + } + }); +}); + +describe('re-subscribing', () => { + /** + * Closing the dialog while a read hangs and reopening it must issue a new + * read. Coalescing onto the old one would answer the reopened dialog with a + * status fetched for the closed one — and leave it on "Checking…" until that + * read finally settles, which for a wedged Host service is the link's whole + * command timeout. + */ + it('issues a fresh read rather than joining one left over from a closed dialog', async () => { + const releases: Array<() => void> = []; + const command = vi.fn( + () => + new Promise((resolve) => { + releases.push(() => + resolve({ + enrolled: true, + serverUrl: 'https://laptop.tailnet.ts.net', + hostId: 'host-1', + connection: 'connected', + pairedClients: 1, + }), + ); + }), + ); + remoteHostLink = { + command, + respond: () => {}, + notify: () => {}, + on: () => () => {}, + }; + + // Open, then close while that first read is still hanging. + subscribeToRemoteHostStatus(() => {})(); + expect(command).toHaveBeenCalledTimes(1); + + const unsubscribe = subscribeToRemoteHostStatus(() => {}); + try { + expect(command).toHaveBeenCalledTimes(2); + + // The abandoned read landing must not commit for the reopened dialog... + releases[0]!(); + await Promise.resolve(); + expect(getRemoteHostStatusSnapshot()).toEqual({ kind: 'loading' }); + + // ...and the reopened dialog's own read must. + releases[1]!(); + await Promise.resolve(); + expect(getRemoteHostStatusSnapshot()).toMatchObject({ kind: 'ready' }); + } finally { + unsubscribe(); + } + }); +}); + +describe('publishing', () => { + /** + * The service answers with a fresh object every poll, so an unguarded write + * would re-render the section twice a minute to paint identical text. The + * sibling store this same dialog reads guards the same way (`setPushDevices`). + */ + it('does not notify when a poll answers the same status again', async () => { + vi.useFakeTimers(); + const status = { + enrolled: true, + serverUrl: 'https://laptop.tailnet.ts.net', + hostId: 'host-1', + connection: 'connected', + pairedClients: 1, + }; + // A new object each time, exactly as a round trip through the service gives. + const command = vi.fn(async () => ({ ...status })); + remoteHostLink = { + command, + respond: () => {}, + notify: () => {}, + on: () => () => {}, + }; + + const listener = vi.fn(); + const unsubscribe = subscribeToRemoteHostStatus(listener); + try { + await vi.advanceTimersByTimeAsync(0); + expect(listener).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(3 * 2000); + expect(command.mock.calls.length).toBeGreaterThan(1); + expect(listener).toHaveBeenCalledTimes(1); + + // A real change still publishes. + status.pairedClients = 2; + await vi.advanceTimersByTimeAsync(2000); + expect(listener).toHaveBeenCalledTimes(2); + } finally { + unsubscribe(); + } + }); +}); + diff --git a/lib/src/remote/host/host-status-store.ts b/lib/src/remote/host/host-status-store.ts new file mode 100644 index 00000000..2696d64c --- /dev/null +++ b/lib/src/remote/host/host-status-store.ts @@ -0,0 +1,256 @@ +/** + * The remote-Host status the Settings dialog renders, as an external store. + * + * The Host is a service in the process that owns the PTYs, so everything here + * is one round trip away over the `remoteHost` link (`activation.ts`). This + * module holds no Host, no relay socket and no ACL — it asks and mirrors. + * + * Deliberately independent of `installRemoteHostConsoleHook`: that lives in the + * lazily-loaded pairing-modal chunk, while Settings is in the main one. Both + * subscribe to the same service events, and `link.on` supports either arriving + * first, so the dialog works whether or not the pairing chunk has loaded. + * + * The service's `status` event carries only `{ enrolled }` + * (`service-protocol.ts` -> `HostStatusEvent`), which is enough to know the + * answer changed but not what it changed to — so every event re-reads the full + * status rather than patching a field. + */ + +import type { RemoteHostConsoleStatus } from '../../host/remote/service-protocol'; +import { getPlatform } from '../../lib/platform'; +import type { RemoteHostLink } from '../../lib/platform/types'; + +/** + * `unsupported` is a build with no Host service behind it (the website, the + * lib dev server) — not a failure, and the section renders nothing at all. + * It is distinct from `error`, which means there is a service and it refused. + */ +export type RemoteHostStatusState = + | { kind: 'unsupported' } + | { kind: 'loading' } + | { kind: 'ready'; status: RemoteHostConsoleStatus } + | { kind: 'error'; message: string }; + +const UNSUPPORTED: RemoteHostStatusState = { kind: 'unsupported' }; +const LOADING: RemoteHostStatusState = { kind: 'loading' }; + +let state: RemoteHostStatusState = LOADING; +const listeners = new Set<() => void>(); +let unsubscribeFromLink: (() => void) | null = null; +let pollTimer: ReturnType | null = null; +let refreshInFlight: Promise | null = null; +let refreshAgain = false; + +/** + * The service's `status` event fires only when `enrolled` changes, because that + * is the edge its webview gate arms on. The *connection* moves underneath it + * with no event at all: `connecting -> connected` on a normal start, + * `connected -> disconnected` on a dropped relay, `-> displaced` when another + * instance takes the slot. Without a poll the dialog would show whichever state + * happened to be true the instant it opened — a machine that connected a second + * later reads as permanently "Connecting…". + * + * Polling only while something is subscribed keeps this to the seconds the + * dialog is actually open, rather than a standing timer on every window. A + * slow read is never overlapped: ticks coalesce behind it, so its timeout can + * commit instead of every later tick making the eventual failure stale. + */ +const POLL_MS = 2000; + +/** Invalidates an in-flight answer that can no longer be the one anybody wants. */ +let generation = 0; + +/** + * Publish a new state, skipping a write that says the same thing. + * + * The poll re-reads every 2 s and the service answers with a fresh object each + * time, so without this the section re-renders twice a minute to paint + * identical text. The sibling store this same dialog reads guards the same way + * (`setPushDevices` in `lib/src/lib/push-devices.ts`); comparing the five + * primitives is the whole of it, because `RemoteHostConsoleStatus` has no + * nested value. + */ +function setState(next: RemoteHostStatusState): void { + if (sameState(state, next)) return; + state = next; + for (const listener of listeners) listener(); +} + +function sameState(a: RemoteHostStatusState, b: RemoteHostStatusState): boolean { + if (a === b) return true; + if (a.kind !== b.kind) return false; + if (a.kind === 'error' && b.kind === 'error') return a.message === b.message; + if (a.kind === 'ready' && b.kind === 'ready') { + return ( + a.status.enrolled === b.status.enrolled && + a.status.serverUrl === b.status.serverUrl && + a.status.hostId === b.status.hostId && + a.status.connection === b.status.connection && + a.status.pairedClients === b.status.pairedClients + ); + } + // `unsupported` and `loading` are the two singletons, so matching kinds is all. + return true; +} + +/** + * `getPlatform` throws before `initPlatform`, and a host may simply have no + * service. Both mean the same thing here: nothing to ask. + */ +function link(): RemoteHostLink | undefined { + try { + return getPlatform().remoteHost; + } catch { + return undefined; + } +} + +export function getRemoteHostStatusSnapshot(): RemoteHostStatusState { + return state; +} + +export function subscribeToRemoteHostStatus(listener: () => void): () => void { + listeners.add(listener); + if (listeners.size === 1) { + const active = link(); + if (active) { + unsubscribeFromLink = active.on('status', () => void refreshRemoteHostStatus()); + pollTimer = setInterval(() => void refreshRemoteHostStatus(), POLL_MS); + void refreshRemoteHostStatus(); + } else { + setState(UNSUPPORTED); + } + } + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + unsubscribeFromLink?.(); + unsubscribeFromLink = null; + if (pollTimer) clearInterval(pollTimer); + pollTimer = null; + // Next mount re-reads rather than showing a snapshot from a previous open, + // which may predate an enrollment made in another window. That includes + // dropping a read still in flight: keeping it would have the next mount + // coalesce onto an answer fetched for a dialog that is already closed, + // and sit on "Checking…" until it finally settles. + state = LOADING; + dropInFlightRead(); + } + }; +} + +/** Re-read the service's status, coalescing calls while one read is in flight. */ +export function refreshRemoteHostStatus(): Promise { + if (refreshInFlight) { + refreshAgain = true; + return refreshInFlight; + } + + const refresh = readRemoteHostStatus(); + refreshInFlight = refresh; + void refresh.then(() => { + if (refreshInFlight !== refresh) return; + refreshInFlight = null; + if (refreshAgain && listeners.size > 0) { + refreshAgain = false; + void refreshRemoteHostStatus(); + } + }); + return refresh; +} + +/** + * Stop coalescing onto the read in flight, because its answer is no longer the + * one anybody is waiting for. + * + * Safe to call at any point: the abandoned read is neutralized twice over — + * `generation` moves, so it cannot commit, and its completion callback sees a + * different in-flight promise, so it cannot clear whatever replaced it. + */ +function dropInFlightRead(): void { + refreshInFlight = null; + refreshAgain = false; + generation++; +} + +/** + * Re-read *after* a mutation this module just made. + * + * Coalescing is right for the poll, where any recent answer will do, and wrong + * here: a read issued before the enroll/disconnect answers the question as it + * stood beforehand, so 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. + */ +function refreshAfterMutation(): Promise { + dropInFlightRead(); + return refreshRemoteHostStatus(); +} + +async function readRemoteHostStatus(): Promise { + const active = link(); + if (!active) { + setState(UNSUPPORTED); + return; + } + const mine = ++generation; + try { + const status = (await active.command('status')) as RemoteHostConsoleStatus | null; + if (mine !== generation) return; + setState(status ? { kind: 'ready', status } : UNSUPPORTED); + } catch (error) { + if (mine !== generation) return; + setState({ kind: 'error', message: describeError(error) }); + } +} + +/** + * Enroll this machine with a coordinating server. + * + * The password is a bearer credential and is passed straight through to the + * service, which is what talks to the server; it is never stored here. The + * service refuses an origin outside this build's baked relay allowlist *before* + * the password leaves the machine (`docs/specs/server.md`, "Where a Host may + * reach a relay server"), so a mistyped origin fails closed rather than leaking + * it. Rejections propagate verbatim — the caller renders them. + */ +export async function enrollRemoteHost( + serverUrl: string, + password: string, + label: string, +): Promise { + const active = link(); + if (!active) throw new Error('This build has no remote Host service.'); + await active.command('enroll', { serverUrl, password, label }); + await refreshAfterMutation(); +} + +/** + * Take the relay slot back after `displaced` — which is terminal by design, so + * nothing reconnects on its own. This displaces the other instance in turn + * (`docs/specs/server.md`, "Relay socket policy"). + */ +export async function reconnectRemoteHost(): Promise { + const active = link(); + if (!active) throw new Error('This build has no remote Host service.'); + await active.command('reconnect'); + await refreshAfterMutation(); +} + +/** + * Forget the enrollment. The service awaits the delete before reporting + * un-enrolled, so a failed delete leaves this machine enrolled rather than + * claiming otherwise while the credential is still on disk. + */ +export async function clearRemoteHostEnrollment(): Promise { + const active = link(); + if (!active) throw new Error('This build has no remote Host service.'); + await active.command('clearEnrollment'); + await refreshAfterMutation(); +} + +function describeError(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + if (typeof error === 'string' && error) return error; + return 'The Host service did not answer.'; +} diff --git a/lib/src/stories/RemoteControlSection.stories.tsx b/lib/src/stories/RemoteControlSection.stories.tsx new file mode 100644 index 00000000..857a5fae --- /dev/null +++ b/lib/src/stories/RemoteControlSection.stories.tsx @@ -0,0 +1,163 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fireEvent, userEvent, within } from 'storybook/test'; +import { ModalSurface } from '../components/design'; +import { RemoteControlSection } from '../components/RemoteControlSection'; +import { enrolledStatus, UNENROLLED_STATUS } from '../host/remote/test-remote-host-link'; + +/** + * The Settings dialog's Remote control section — the one step a self-hoster + * cannot skip (`docs/specs/server.md`, "Remote control, in the Settings + * dialog"). Rendered on its own rather than through `SettingsDialog` so these + * stories are about the enrollment states themselves; `SettingsDialog`'s + * `WithRemoteControl` covers it in place. + * + * Every state comes from the `primedRemoteHost` parameter, because the section + * reads its whole world from `getPlatform().remoteHost` and renders nothing + * without one. The leading rule is the section's own `border-t` — it normally + * separates it from the push settings above. + */ +function RemoteControlStory() { + return ( +
+ + + +
+ ); +} + +const meta: Meta = { + title: 'Modals/RemoteControlSection', + component: RemoteControlStory, + // Embedded in a docs page, each of these needs its own frame. The section + // reads a module-singleton store (`host-status-store.ts`: `state` is module + // scope, and the link is captured only when `listeners.size === 1`), so N + // sections sharing one JS realm share one status however many links exist — + // and the other stories on that page reset `platform.remoteHost` to + // `undefined` underneath them. Separate realms is the only fix short of + // rebuilding the store around a docs page. An iframe does not grow to its + // content, hence the explicit height; stories taller than this override it. + parameters: { docs: { story: { inline: false, height: '250px' } } }, +}; + +export default meta; +type Story = StoryObj; + +/** + * The status command is a round trip, so every story opens on "Checking…". + * Waiting for the settled text keeps Chromatic off that frame — and asserts the + * story actually reached the state it claims, rather than rendering an empty + * section because the stub never arrived. + */ +function settled(text: string | RegExp) { + return async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await within(canvasElement).findByText(text); + }; +} + +/** A machine that has never enrolled: server, setup password, name. */ +export const Unenrolled: Story = { + parameters: { + primedRemoteHost: { status: UNENROLLED_STATUS }, + docs: { story: { height: '390px' } }, + }, + play: settled('Connect'), +}; + +/** + * The refusal that matters most. A Host bundle only talks to the origins baked + * into it at build time, so a stock build pointed at a self-host server fails + * *before* the password leaves the machine — and the form has to say that, + * rather than let it read as a wrong password. + */ +export const EnrollRefused: Story = { + parameters: { + primedRemoteHost: { + status: UNENROLLED_STATUS, + enrollError: + 'This build will not connect to https://ned-mac.tail9c2f1.ts.net. Allowed: https://*.dormouse.sh wss://*.dormouse.sh', + }, + docs: { story: { height: '440px' } }, + }, + // `fireEvent.change` rather than `userEvent.type`: these are controlled + // inputs, so per-character typing costs a render each — ten seconds to fill + // three fields, long enough that a reader scrolling past sees a half-typed + // form — and typing them without awaiting a render between keystrokes + // (`delay: null`) loses every character but the last. One change event with + // the whole value is what a paste does anyway. + play: async (context) => { + const canvas = within(context.canvasElement); + const fill = (label: string, value: string) => + fireEvent.change(canvas.getByLabelText(label), { target: { value } }); + + await canvas.findByLabelText('Server'); + fill('Server', 'https://ned-mac.tail9c2f1.ts.net'); + fill('Setup password', 'correct horse battery staple'); + fill('Name for this machine', 'Work laptop'); + await userEvent.click(canvas.getByRole('button', { name: 'Connect' })); + await canvas.findByText(/This build will not connect/); + }, +}; + +/** Enrolled, relay socket still opening. No event fires for this → the 2 s poll. */ +export const Connecting: Story = { + parameters: { primedRemoteHost: { status: enrolledStatus({ connection: 'connecting' }) } }, + play: settled('Connecting…'), +}; + +/** Connected, but nothing has paired yet — the state right after enrolling. */ +export const ConnectedNoDevices: Story = { + parameters: { primedRemoteHost: { status: enrolledStatus() } }, + play: settled('No phone has paired with this machine yet.'), +}; + +/** After a successful pairing ceremony. */ +export const ConnectedOneDevice: Story = { + parameters: { primedRemoteHost: { status: enrolledStatus({ pairedClients: 1 }) } }, + play: settled('1 paired device.'), +}; + +/** Plural, and a long tailnet origin exercising the URL line's `break-all`. */ +export const ConnectedManyDevices: Story = { + parameters: { + primedRemoteHost: { + status: enrolledStatus({ + serverUrl: 'https://neds-16-inch-macbook-pro-2026.tail9c2f1.ts.net', + pairedClients: 4, + }), + }, + }, + play: settled('4 paired devices.'), +}; + +/** + * The only connection state with a button. `displaced` is terminal by design — + * another instance took the relay slot and this one stood down — so nothing + * brings it back on its own. + */ +export const Displaced: Story = { + parameters: { + primedRemoteHost: { status: enrolledStatus({ connection: 'displaced', pairedClients: 1 }) }, + docs: { story: { height: '280px' } }, + }, + play: settled(/Another Dormouse instance took/), +}; + +/** Disconnect asks first: it drops every paired phone until each pairs again. */ +export const ConfirmingDisconnect: Story = { + parameters: { primedRemoteHost: { status: enrolledStatus({ pairedClients: 2 }) } }, + play: async (context) => { + const canvas = within(context.canvasElement); + await userEvent.click(await canvas.findByRole('button', { name: 'Disconnect' })); + await canvas.findByText('Paired phones will need to pair again.'); + }, +}; + +/** + * There *is* a Host service and it refused — distinct from a build that has + * none, which renders nothing at all rather than an error. + */ +export const HostServiceError: Story = { + parameters: { primedRemoteHost: { statusError: 'The Host service did not answer.' } }, + play: settled(/Could not reach this machine’s Host service/), +}; diff --git a/lib/src/stories/SettingsDialog.stories.tsx b/lib/src/stories/SettingsDialog.stories.tsx index b680f019..5161517b 100644 --- a/lib/src/stories/SettingsDialog.stories.tsx +++ b/lib/src/stories/SettingsDialog.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { userEvent, within } from 'storybook/test'; import type { DormouseTheme } from '../lib/themes'; import { SettingsDialog } from '../components/SettingsDialog'; +import { enrolledStatus, UNENROLLED_STATUS } from '../host/remote/test-remote-host-link'; /** * The app-global Settings dialog, normally opened from the far right of the @@ -99,7 +100,8 @@ export const PushNoDevices: Story = { }, }; -/** No remote Host at all — the ordinary case for a machine that never enrolled. */ +/** No Host service in this build at all — the website. Nothing renders below, + * so the copy must not point there. Paired with `PushNotEnrolled`. */ export const PushNoHost: Story = { parameters: { primedWatchedCommands: ['claude'], @@ -108,6 +110,24 @@ export const PushNoHost: Story = { }, }; +/** + * The other `no-host`: a build that *does* have a Host service, which simply has + * not enrolled. Same push status as `PushNoHost`, but here the Remote control + * section renders beneath — so this is the one whose copy may say "below", and + * the pair is what keeps that word honest. + */ +export const PushNotEnrolled: Story = { + parameters: { + primedWatchedCommands: ['claude'], + primedAlertSettings: { pushEnabled: true }, + primedPushDevices: { status: 'no-host', devices: [] }, + primedRemoteHost: { status: UNENROLLED_STATUS }, + }, + play: async ({ canvasElement }) => { + await within(canvasElement).findByText(/server below to send push/); + }, +}; + /** * Non-default timings, proving every number field renders the stored value * rather than a hardcoded one. @@ -259,3 +279,22 @@ export const HostOwnsShells: Story = { primedAlertSettings: {}, }, }; + +/** + * The Remote control section in place — last, and directly under the push + * settings whose `no-host` copy points at it. Every other story here leaves + * `primedRemoteHost` unset, which is a build with no Host service behind the + * webview: the section renders nothing at all rather than offering a form the + * build cannot honor (`docs/specs/server.md`). `RemoteControlSection.stories` + * covers its own states. + */ +export const WithRemoteControl: Story = { + parameters: { + primedRemoteHost: { status: enrolledStatus({ pairedClients: 1 }) }, + primedWatchedCommands: ['claude'], + primedAlertSettings: { pushEnabled: true }, + }, + play: async ({ canvasElement }) => { + await within(canvasElement).findByText('1 paired device.'); + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc972100..17388cd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,6 +129,9 @@ importers: specifier: ^3.2.2 version: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.3) devDependencies: + '@storybook/addon-docs': + specifier: ^10.4.0 + version: 10.5.9(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) '@storybook/react': specifier: ^10.4.0 version: 10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3) @@ -1163,6 +1166,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mdx-js/react@3.1.1': + resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} + peerDependencies: + '@types/react': '>=16' + react: '>=16' + '@napi-rs/keyring-darwin-arm64@1.3.0': resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} engines: {node: '>= 10'} @@ -1921,6 +1930,15 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@storybook/addon-docs@10.5.9': + resolution: {integrity: sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.5.9 + peerDependenciesMeta: + '@types/react': + optional: true + '@storybook/builder-vite@10.5.8': resolution: {integrity: sha512-UeRnn7yT55WmBlHNOQzLrvN7vsHEvVgIukhKDO+4cMbGXN87wZkbxhx6NstpuXRH8OxGqwKS0SZNVp+SC1ftLQ==} peerDependencies: @@ -1945,6 +1963,24 @@ packages: webpack: optional: true + '@storybook/csf-plugin@10.5.9': + resolution: {integrity: sha512-4H5QIHQVtQYCuL43GCRLGjNQhZpQg9gL03ja0DV80kO2Dn9LEt6ol87bSnSjn4VDgcAXtgTzXFvRLknfVgAAqg==} + peerDependencies: + esbuild: '*' + rollup: '*' + storybook: ^10.5.9 + vite: '*' + webpack: '*' + peerDependenciesMeta: + esbuild: + optional: true + rollup: + optional: true + vite: + optional: true + webpack: + optional: true + '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} @@ -1967,6 +2003,20 @@ packages: '@types/react-dom': optional: true + '@storybook/react-dom-shim@10.5.9': + resolution: {integrity: sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.5.9 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@storybook/react-vite@10.5.8': resolution: {integrity: sha512-ioMJGi4YzueGsJBlYio+2+UhfCFB9QV5Bs1lOilkek+a4BZgKJl0D1mVSJl6k96stQBPZmLgI9/l0hLVcUL6Kg==} peerDependencies: @@ -2237,6 +2287,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/mdx@2.0.14': + resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} + '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} @@ -5247,6 +5300,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@types/mdx': 2.0.14 + '@types/react': 19.2.18 + react: 19.2.8 + '@napi-rs/keyring-darwin-arm64@1.3.0': optional: true @@ -5764,6 +5823,25 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@storybook/addon-docs@10.5.9(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + dependencies: + '@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.2.8) + '@storybook/csf-plugin': 10.5.9(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + '@storybook/icons': 2.1.0(react@19.2.8) + '@storybook/react-dom-shim': 10.5.9(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + storybook: 10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + ts-dedent: 2.3.0 + optionalDependencies: + '@types/react': 19.2.18 + transitivePeerDependencies: + - '@types/react-dom' + - esbuild + - rollup + - vite + - webpack + '@storybook/builder-vite@10.5.8(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': dependencies: '@storybook/csf-plugin': 10.5.8(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) @@ -5784,6 +5862,15 @@ snapshots: rollup: 4.62.2 vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + '@storybook/csf-plugin@10.5.9(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + dependencies: + storybook: 10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + unplugin: 2.3.11 + optionalDependencies: + esbuild: 0.28.2 + rollup: 4.62.2 + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + '@storybook/global@5.0.0': {} '@storybook/icons@2.1.0(react@19.2.8)': @@ -5799,6 +5886,15 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@storybook/react-dom-shim@10.5.9(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + storybook: 10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@storybook/react-vite@10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.8(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) @@ -6059,6 +6155,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/mdx@2.0.14': {} + '@types/node@24.13.3': dependencies: undici-types: 7.18.2