This document describes the runtime architecture of Voicebox as implemented in the repository. All generated sections below are verified against the live codebase and server by scripts/docs-check.mjs (npm run docs:check).
| Component | Key Files | Responsibility |
|---|---|---|
| Browser Interface | public/index.html, public/fused.js, public/style.css, public/live-voice.js, public/pip-mic.mjs, public/extensions-ui.mjs, public/settings-ui.mjs, public/components/environment-root-dialog.mjs, public/components/changelog-dialog.mjs, public/components/mic-hotkey-controller.mjs, public/components/sync-toast.mjs, public/components/audio-device-controller.mjs, public/components/activity-log-ui.mjs, public/components/mini-app-surface.mjs, public/components/harnesses-dialog.mjs, public/components/environment-list.mjs, public/components/vision-controls.mjs, public/components/deck-controller.mjs |
Renders the centered voice stage (#mic, #text-form), floating pop-over bubble tray (#sqeh-deck) for Files (#sqeh-files-bubble, #made-list), File Viewer (#sqeh-reader-bubble, #reader), Mini-Apps (#sqeh-actions, #mini-app-container — listing apps only: a saved, shelf or sandbox app, plus apps launched this session, never a bare workspace page, and without hardcoded default apps), and Recent Turns (#sqeh-toggle-history, #session), plus top-bar dialogs for Settings, Harnesses, Extensions, and Environments (integrating project workspace and machine/browser root configuration directly within the room UI, with a setup guide that says what setup does, why it is needed and in what order, and project creation by name). |
| HTTP & WebSocket Server | server.mjs, lib/routes/workspace.mjs, lib/routes/extensions.mjs, lib/routes/presence.mjs, lib/routes/activity.mjs, lib/routes/agents-read.mjs, lib/routes/changelog.mjs, lib/routes/tool-catalog.mjs, lib/routes/gemini-media.mjs, lib/ws-server.mjs |
Zero-dependency (node:http) loopback server binding 127.0.0.1, serving static assets and REST API routes (workspace file/list/grep, extension inventory/staging/decisions, GET/POST presence, GET/DELETE activity, GET agents, GET changelog, GET tools, and the Gemini media capabilities/image/video/embed handlers live in route modules), /live voice WebSockets, and /channel routed-action sockets. |
| Live Voice Gateway | lib/live-session.mjs, lib/commands.mjs, lib/project-instruction.mjs |
Manages full-duplex streaming audio and tool-call execution with Gemini Multimodal Live and OpenAI Realtime. |
| Turn Resolver | lib/resolver.mjs |
Translates text transcripts from POST /api/turn into structured actions (write, read, list, make-tool, tool, etc.). |
| Policy & Domain Core | core/root.ts, core/extensions.ts, core/tasks.ts, core/wire.ts, core/fleet.ts, core/harness-config.ts, core/mini-app.ts, core/paths.ts, core/tier-table.ts |
Runtime-agnostic pure TypeScript modules defining workspace roots, capability admission rules, task state machines, wire envelopes, and path normalization. Imports nothing outside core/. |
| Host Capabilities | lib/state-dirs.mjs, lib/extensions.mjs, lib/wasm-shelf.mjs, lib/tasks.mjs, lib/task-placement.mjs, lib/fleet.mjs, lib/harness-config.mjs |
Server-side implementations of extension admission, digest-pinned WebAssembly execution (tools/create-asset.wat), ACP task delegation, and multi-environment fleet routing. |
| Dev Proxy | vite.config.js, lib/browser-sources.mjs |
Development-only HMR front-end server on port 5173 that proxies /api, /live, /channel, and browser module graphs (core, browser, tools, tests, lib) to server.mjs. |
public/live-voice.js captures microphone audio through the pcm-worklet.js AudioWorklet and streams PCM frames over /live to lib/live-session.mjs.
Live Voice Providers (lib/live-session.mjs): claude ((registered, but this check has no capture for it)), gemini (models/gemini-3.8-live), openai (gpt-realtime). The default fallback provider is gemini (configurable via VOICEBOX_LIVE_PROVIDER or selected per session in the UI Settings dialog).
While the assistant is speaking, microphone capture remains active so you can interrupt naturally at any time:
public/live-voice.js monitors incoming microphone frame energy against both an absolute floor and an adaptive quiet baseline measured at the microphone. Sustained voice energy above the threshold immediately flushes local audio playback and sends { type: "interrupt" } over /live.{ type: "interrupt" }, /live invokes session.interrupt() (canceling active OpenAI Realtime generation; Gemini Live detects barge-in server-side). When the upstream provider emits an interrupt event, the server forwards state: "interrupt" to the browser to flush any remaining buffered audio frames.Browser UI ──POST /api/turn { transcript }──▶ server.mjs ──resolveTurn()──▶ lib/resolver.mjs
◀── { transcript, action, result } ── Executes action in active project root
Voicebox supports both machine-owned filesystem roots (kind: "machine") and browser-owned roots (kind: "opfs" for Origin Private File System or kind: "handle" for directories opened via the File System Access API, defined in core/root.ts):
x-voicebox-host-token on POST /api/root (declaredBy: "host").opfs and handle roots reside in the browser (ROOT_FACTS.opfs.reachableFrom = ["page"] in core/root.ts). Same-origin browser requests can declare page-owned roots directly (declaredBy: "page"), and routed file operations execute in the browser via browser/acts.ts (refusing mismatched roots with root-not-mine).scratchpad/ directory so users can create and inspect files immediately on first launch.AGENTS.md): lib/project-instruction.mjs locates the nearest AGENT.md or AGENTS.md file (bounded to 32 KiB) from the active folder up to the root and includes it in the model’s system instructions.Registered turn resolvers (lib/resolver.mjs): claude, gemini, openai, script
registerResolver(name, fn) registers a text turn resolver; resolveTurn(transcript, provider = "script") resolves a user transcript into a structured action.script): Maps common file and tool commands without requiring an external API key (for example, "create a file called hello.txt with hi" → {"verb":"write","name":"hello.txt","content":"hi"}).write, read, list, make-tool, tool. Prompts outside the deterministic grammar return an explicit unresolved response (for example, "book me a flight to Lisbon" → "the script resolver only knows create/read…").claude, gemini, openai) are registered separately via registerLiveProvider in lib/live-session.mjs and stream audio and tool calls over /live.
The table below traces a complete turn executed against a temporary workspace during documentation generation:
| Step | Stage | Mechanism | Verified Output |
|---|---|---|---|
| 1. Turn Request | Client submits transcript | POST /api/turn { transcript } |
"create a file called hello.txt with hi" |
| 2. Turn Resolution | Resolver parses transcript into an action | resolveTurn(transcript, "script") in lib/resolver.mjs |
{"verb":"write","name":"hello.txt","content":"hi"} |
| 3. Action Execution | Executor runs action inside the active root | execute(action) in server.mjs (POST /api/root) |
wrote hello.txt (2 bytes) (root.kind: "machine") |
| 4. Turn Response | Server returns structured result to client | { transcript, action, result } |
ok: true, logged: 2 |
| 5. Audit Trail | Append-only log records 2 entries (attempt/attempted → allow/writes-inside, linking outcome to attempt sequence) and 1 entry for pre-flight refusal |
<root>/.audit/<writer>.jsonl (core/shared-log.ts), GET /api/audit |
seq 1 attempt, seq 2 allow; refusal: seq 3 refuse/outside-root |
Boundary & Admission Guarantees (Verified Against Live Server):
refused: "root-not-declared" (logged: null). Once declared via POST /api/root (ok: true, reachableFromThisProcess: true), the turn succeeds."read ..") is refused with refused: "outside-root" and logged at audit sequence 3.make-tool → admit → tool):
"create a tool called peek that lists files" resolves to make-tool and writes a pending descriptor (proposed tool 'peek-tool', state pending) under proposals/ without loading code.GET /api/extensions/proposals/peek-tool/plan previews the admission verdict (admitted; enforced: read via host-primitive-scope).POST /api/extensions/admit with x-voicebox-host-token admits the descriptor (admitted); requests without the host token fail with HTTP 403 (host-token-required)."run the tool peek" dispatches tool → callTool("peek") in lib/extensions.mjs (ok: true, files ["hello.txt"]).GET /api/extensions lists peek-tool with declared capabilities [read], enforcement {"read":"host-primitive-scope"}, and tools [peek].Unified Workspace Root: Admitted file extensions operate on the active project root (["hello.txt"]).
When the server starts or reloads the extension registry, lib/extensions.mjs re-runs the admission gate (core/extensions.ts) on every admitted descriptor and cross-checks .ledger.jsonl:
unreadable), or was deleted from disk (descriptor-missing) is reported in failedLoads on GET /api/extensions and displayed in the Extensions UI as Approved, not running (tests/extension-init-errors.test.mjs, tests/extensions-ui.test.mjs).User input reaches the shared action executor through three paths:
| Input Path | Active | Transport | Execution Pipeline |
|---|---|---|---|
| Text Composer | Yes | public/fused.js → POST /api/turn |
resolveTurn() (lib/resolver.mjs, default script) → execute() (server.mjs) → callTool() (lib/extensions.mjs) |
Browser Dictation (SpeechRecognition) |
Yes | public/fused.js → POST /api/turn |
Same pipeline as Text Composer |
| Live Voice Audio | Audio: Yes; Tools: Yes | public/live-voice.js → /live → lib/live-session.mjs |
Provider tool call → commandToAction() → execute() → correlated tool response + {type:"tool"} UI notification |
Live Session Tool Declarations: claude declares: (not captured); gemini declares: list_extensions, call_extension, propose_extension, write_file, read_file, list_files, delete_file, edit_file, diff_file, grep_files, list_agents, delegate_task, contact_agent, launch_mini_app, git_status, git_diff, git_log, inspect_environment, undo_last_action, list_tools, search_tools, run_command, open_workspace; openai declares: list_extensions, call_extension, propose_extension, write_file, read_file, list_files, delete_file, edit_file, diff_file, grep_files, list_agents, delegate_task, contact_agent, launch_mini_app, git_status, git_diff, git_log, inspect_environment, undo_last_action, list_tools, search_tools, run_command, open_workspace.
Script Resolver Sample Utterances: "create a file called hello.txt with hi" → write, "read hello.txt" → read, "list files" → list, "create a tool called clock that tells the time" → make-tool, "run the tool clock" → tool.
Built-In Extension Primitives (6 closed primitives in core/extensions.ts; extensions parameterize primitives as pure JSON descriptors rather than executing arbitrary model-authored code):
| Primitive | Capability Required | Host-Mediated Interface |
|---|---|---|
now |
— | Returns current host timestamp |
read-file |
read | a root-scoped read function: paths resolve inside the project root or refuse |
write-file |
write | a root-scoped write function: paths resolve inside the project root, writes are reported and revertible |
list-files |
read | a root-scoped read function: paths resolve inside the project root or refuse |
http-get |
network | a mediated fetch: hosts outside bounds.hosts are refused by name — INCLUDING across redirects, every hop charged to bounds.maxRequests — and the audit records the URL that actually served the bytes |
wasm |
— | Isolated WebAssembly module verified by SHA-256 digest at admission and invocation under strict host memory and timeout ceilings |
Capabilities Prohibited on machine Placement:
Extension Catalogue (catalogue/*.json, 4 descriptors):
| Extension ID | Tools | Declared Capabilities | Bounds | Admission Verdict |
|---|---|---|---|---|
brave-search |
brave_search → http-get |
network | hosts: api.search.brave.com; maxRequests: 20 | admitted — network via mediated-fetch |
mcp-server-local |
mcp_list_tools → process |
exec | command: npx -y @modelcontextprotocol/server-filesystem /tmp | refused exec-absent |
mcp-server-remote |
mcp_remote_list_tools → http-get |
network | hosts: mcp.example.com; maxRequests: 20 | admitted — network via mediated-fetch |
web-search |
web_search → http-get |
network | hosts: api.duckduckgo.com; maxRequests: 5 | admitted — network via mediated-fetch |
Named Refusal Codes by Subsystem:
core/extensions.ts): absent-capability, bad-tool-name, capability-unmediated, duplicate-tool, eval-not-a-tool-path, exec-absent, network-unbounded, no-tools, under-declared, unknown-capability, unknown-primitive, unsupported-abilib/mini-app-store.mjs): mini-app-name-taken, mini-app-not-found, missing-html, outside-rootserver.mjs, lib/routes/workspace.mjs, lib/routes/extensions.mjs, lib/routes/presence.mjs, core/root.ts, browser/acts.ts): adapter-not-configured, approval-invalid-id, approval-json-required, audit-unreadable, bad-answer, bad-json, bad-request, bearer-refused, body-too-large, bounds-invalid, cannot-delete-directory, cannot-delete-local, cross-environment-unauthorized, dotfile-refused, environment-not-paired, environment-unknown, environment-unreachable, exec-threw, executor-unavailable, extension-not-admitted, git-failed, host-token-refused, host-token-required, invalid-task, loopback-auth-disabled, loopback-unauthenticated, mini-app-timeout, mini-app-unreachable, missing-argument, missing-content, no-project, not-a-directory, not-a-git-repo, not-found, not-supported-in-browser, nothing-to-undo, outside-root, pairing-revoked, path-missing, pattern-not-found, pattern-not-unique, probe-failed, provider-not-configured, root-not-mine, root-not-reachable-from-here, root-unreachable, server-error, task-root-unavailable, unauthenticated-call, undo-failed, unknown-command, unknown-environment, unknown-mini-app-tool, unknown-root-kind, unknown-verb, unreadable, unsupported-content-type, write-errorlib/extensions.mjs): approval-audit-unwritable, approval-no-proposal, approval-plan-changed, approval-unavailable, bad-descriptor, bad-redirect, bad-tool-name, bounds-invalid, descriptor-missing, extension-not-admitted, fetch-failed, gate-refused-at-load, invalid-id, missing-description, missing-name, network-unbounded, no-tools, over-budget, params-invalid, params-unknown-tool, redirect-host-not-allowed, redirect-without-location, too-many-redirects, unknown-primitive, unreadablecore/tasks.ts, lib/tasks.mjs): agent-environment-mismatch, agent-not-configured, agent-required, executor-unavailable, invalid-task, invalid-task-address, invalid-task-context, task-audit-unavailable, task-authority-field, task-call-id-conflict, task-call-id-required, task-cancelled, task-capacity-exhausted, task-context-unavailable, task-deadline, task-environment-changed, task-environment-unverified, task-input-over-budget, task-invalid-result, task-not-found, task-not-running, task-output-over-budget, task-owner-mismatch, task-owner-unconfirmed, task-owner-unverified, task-persistence-failed, task-root-replaced, task-root-unavailable, unbounded-executor, unknown-tool, unsupported-runtime-capabilityRuntime Inspection & Admission Endpoints:
GET /api/extensions: Returns { placement, extensions, proposals, present, failedLoads, catalogueCount } (placement: machine, catalogueCount: 4).GET /api/extensions/catalogue: Previews admission verdicts for all catalogue descriptors.GET /api/extensions/{proposals|catalogue}/<id>/plan: Returns capability and enforcement disclosure prior to admission.GET /api/probe: Runs tools/sandbox-probe.mjs and returns an observed environment report (HTTP 200; sections: identity, sandboxHints, filesystem, limits, tools, network).POST /api/extensions/admit: Requires x-voicebox-host-token (unauthenticated requests fail with HTTP 403 host-token-required).
Environment variables read by the server and runtime libraries:
| Variable | Read In | Description |
|—|—|—|
| ANTHROPIC_API_KEY | lib/live-providers/claude.mjs, lib/pi-acp.mjs, lib/resolver.mjs, server.mjs | Anthropic API key used by the claude resolver/provider and forwarded to the pi-acp adapter as a fallback when no store credential exists (can also be configured in the UI Settings dialog). |
| BRAVE_API_KEY | lib/extensions.mjs | Brave Search API subscription token used by http-get extensions targeting api.search.brave.com. |
| FORCE_COLOR | lib/logger.mjs | Terminal color override (0 disables ANSI colors in lib/logger.mjs; non-zero enables them when stdout is not a TTY). |
| GEMINI_API_KEY | lib/gemini-models.mjs, lib/live-providers/gemini.mjs, lib/resolver.mjs, server.mjs | Google Gemini API key for Gemini Live voice sessions and the gemini text turn resolver (can also be configured in the UI Settings dialog). |
| LIVE_PROVIDER | lib/live-session.mjs, server.mjs | Deprecated alias for VOICEBOX_LIVE_PROVIDER, retained for backward compatibility. |
| NODE_DISABLE_COLORS | lib/logger.mjs | Node.js built-in flag that disables ANSI terminal colors alongside NO_COLOR. |
| NO_COLOR | lib/logger.mjs | Disables ANSI color sequences in lib/logger.mjs when set to a non-empty value. |
| OPENAI_API_KEY | lib/live-providers/openai.mjs, lib/resolver.mjs, server.mjs | OpenAI API key for OpenAI Realtime voice sessions and the openai text turn resolver (can also be configured in the UI Settings dialog). |
| PATH | lib/claude-acp.mjs, lib/tool-index.mjs | System executable search path, also inherited by task-adapter child processes. |
| PORT | server.mjs | HTTP server port bound on 127.0.0.1 (default 8787). |
| VOICEBOX_ACP_ADAPTER | lib/pi-acp.mjs | Path or command override for the pi-acp stdio adapter binary in lib/pi-acp.mjs. |
| VOICEBOX_ACP_PI | lib/pi-acp.mjs | Path or command override for the pi coding agent CLI used by lib/pi-acp.mjs. |
| VOICEBOX_BIND_DEADLINE_MS | server.mjs | Maximum duration in milliseconds to retry binding the server port before failing. |
| VOICEBOX_BIND_RETRY_MS | server.mjs | Interval in milliseconds between port bind retries at startup. |
| VOICEBOX_CLAUDE_CLI | lib/claude-acp.mjs | Path override for the Claude Code CLI executable (CLAUDE_CODE_EXECUTABLE) used by lib/claude-acp.mjs. |
| VOICEBOX_CLAUDE_KEEP_API_KEY | lib/claude-acp.mjs | Set to 1 to retain ANTHROPIC_API_KEY in the Claude Code adapter child environment (omitted by default so CLI login takes precedence). |
| VOICEBOX_ENABLE_STUB_PROVIDER | server.mjs | Set to 1 to register the key-free stub live voice provider for local audio testing. |
| VOICEBOX_EXTENSIONS_DIR | lib/state-dirs.mjs | Host state directory storing admitted extensions, .host-token, .ledger.jsonl, .pairings.json, .api-keys.json, and .harness-settings.json (mode 0600). |
| VOICEBOX_HARNESS | server.mjs | Default host coding agent harness (pi or claude; can also be switched at runtime in the Harnesses UI dialog). |
| VOICEBOX_HELLO_BOUND_MS | server.mjs | Timeout in milliseconds to receive an authentication hello frame on /channel or /live (default 5000). |
| VOICEBOX_INSTANCE | server.mjs | Writer identifier recorded in the active workspace’s .audit/<writer>.jsonl log (default machine). |
| VOICEBOX_LIVE_PROVIDER | lib/live-session.mjs, server.mjs | Fallback live voice provider (gemini, openai, or claude) when the client session does not specify one. |
| VOICEBOX_LOOPBACK_AUTH | server.mjs | Set to 1 to require a single-use bootstrap ticket (?bootstrap=<ticket>) and HttpOnly session cookie for local browser access. One ticket is minted at startup, and its startup text names the host it must be opened on: a cookie is scoped to the host that redeemed it, so a 127.0.0.1:8787 ticket opened from a localhost:5173 page leaves every /api call on that page answering 401 (open the same ticket on localhost:5173 when that is the dev front you browse). Redemption answers 303 to the plain route with the ticket removed, so the first refresh is authenticated rather than a re-used-ticket 401. Default off (accepted testing posture): with the gate off, unauthenticated loopback clients — including browser-originated requests — can reach state-mutating routes such as active-workspace writes, so the server prints a startup warning naming the exposure and this remedy (see docs/18-loopback-session-auth.md). |
| VOICEBOX_OPENAI_INPUT_TRANSCRIPTION | lib/live-providers/openai.mjs | Set to 1 to enable gpt-4o-mini-transcribe input audio transcription in the OpenAI Realtime session handshake. |
| VOICEBOX_PROVIDER | server.mjs | Deprecated alias for VOICEBOX_RESOLVER, retained for backward compatibility. |
| VOICEBOX_RESOLVER | server.mjs | Default text turn resolver used by POST /api/turn (script, gemini, openai, or claude; default script). |
| VOICEBOX_SANDBOX_HOMES | lib/state-dirs.mjs | Base directory for fenced sandbox home directories (default ~/sandbox-homes/<key>, located outside /tmp for PrivateTmp compatibility). |
| VOICEBOX_WASM_SHELF_DIR | lib/state-dirs.mjs | Directory containing the digest-pinned WebAssembly tool shelf (manifest.json and .wasm binaries; default ~/.isocan/modules/wasm-tools). |
| VOICEBOX_WORKSPACE | lib/state-dirs.mjs | Declares an active machine project root at startup and stores extension proposals (proposals/) and extension audit logs (audit.jsonl). |
The HTTP server (server.mjs, built on node:http) binds 127.0.0.1 and serves the core routes below:
| Method | Route | Probed Status |
|---|---|---|
GET |
/ |
200 |
GET |
/api/health |
200 |
GET |
/api/files |
200 |
POST |
/api/turn |
200 |
Static frontend assets are served from public/. GET /api/health reports the active turn resolver (provider: "script"), whether a workspace root is declared (declared: false), and the active root: { kind, path } configured via POST /api/root. Before a root is declared, root-scoped file operations return root-not-declared.
A WEBSOCKET UPGRADE ON /live IS ACCEPTED (101) — the zero-dependency server owns it.
The room presence note in public/fused.js treats /api/health as the first machine check, not as a
promise that later requests will reach it. If a later /api/root or /api/environments fetch loses the
connection, the note and server dot immediately say the machine is not answering; an HTTP response with
a route-specific refusal still names that route instead. tests/presence-downstream-drop.test.mjs stops
the server between the successful heartbeat and the environment fetch to guard this distinction.
lib/routes/presence.mjs owns only exact GET /api/presence and POST /api/presence table handlers. server.mjs still constructs the single room-presence coordinator, applies the global loopback auth wall before dispatch, and supplies its bounded JSON reader, response writer and channel broadcaster; the route does not copy those security and lifecycle owners (voicebox-beads-gdq2.1).
lib/routes/activity.mjs owns only exact GET /api/activity and DELETE /api/activity table handlers. server.mjs retains the one mutable activity buffer, its producers, bounded trimming and workspace-reset behavior; the route receives that same array and clears it in place, never replacing the producer’s reference. The global loopback wall remains host-owned (voicebox-beads-gdq2.2).
lib/routes/agents-read.mjs owns only exact GET /api/agents: it filters the host registry and applies a public, secret-free projection plus the admission verdict from a fresh host context on each GET. server.mjs continues to own mutable harness/adapter configuration, the global loopback auth wall and the separate privileged POST /api/agents and PATCH /api/agents/:id handlers. The registration alias environmentKey: "local" is projected as this host’s minted environment key when querying the registry (voicebox-beads-gdq2.3).
lib/routes/fleet.mjs owns only exact GET /api/fleet, filtering and projecting the host’s current fleet list. POST /api/fleet/contact stays in server.mjs: it forwards a caller bearer and mints an audit token across an environment boundary. The GET spread keeps its position between agents-read and that host-owned POST, before harness routes (voicebox-beads-r9hv.3).
lib/routes/changelog.mjs owns only exact GET /api/changelog and its single private cache. It preserves the host’s raw .git/HEAD plus ref-mtime :v2 key, cache miss when HEAD is unavailable (as in a worktree .git file), bounded git log execution and original 200/500 JSON envelopes. Global loopback auth, the separate git-log tool verb, and the following HEAD/GET health handlers remain in server.mjs (voicebox-beads-gdq2.4).
lib/routes/tool-catalog.mjs owns only exact GET /api/tools — the room’s tool catalogue, listing or searching (?q=, ?kind=, ?limit=) across native, extension, system, script, mini-app and harness capabilities. server.mjs still owns everything it hands over: the two lib/tool-index.mjs functions, the mini-app tool registry, the harness inventory plus its configured-agent combination, ROOT, and the global loopback auth wall. The active root moves as a LIVE accessor, never as a value: active is a module-level let that is reassigned when a machine root is declared or deleted, and this route lists from whichever tree is active at request time, so the host passes getActiveRoot: () => active and a snapshot would answer from the boot-time root forever — the voicebox-beads-w1f9 shape, which tests/tool-catalog-route-seam.test.mjs turns red by calling the route twice with the accessor changing in between. The route’s original harnesses = [] fallback for an unavailable harness inventory is preserved, as is the unchanged Number(...) handling of limit. Measured, and stated so nobody claims otherwise: this extraction does NOT make the fast gate faster. scripts/test-changed.mjs maps a change to this module to ~137 tests, because server.mjs imports it and 137 test files name server.mjs; the win is that server.mjs is the repo’s collision hotspot (214 commits in 30 days against 1-2 for each lib/routes module). The seam test also pins the table position (after the tasks block, before GET /api/keys) with self-proving missing-spread, shadowing and snapshot mutants, and tests/tool-index.test.mjs keeps driving the route end to end over HTTP (voicebox-beads-r9hv.2).
lib/routes/gemini-media.mjs owns the four CONTIGUOUS Gemini media entries — exact GET /api/gemini/capabilities, POST /api/gemini/image, POST /api/gemini/video, POST /api/gemini/embed — spread back into the table at the position they occupied, between ...activityRoutes and DELETE /api/root, because in this table order is dispatch. Two seams define the slice. The active root moves as a LIVE accessor: POST /api/gemini/image honours saveAs only when the active root is a machine root, and active is reassigned when a root is declared or deleted, so the host passes getActiveRoot: () => active and each handler reads it per request — a snapshot would freeze the boot-time root (voicebox-beads-w1f9), which tests/gemini-media-route-seam.test.mjs turns red by changing the accessor’s answer between two calls. The provider calls arrive as dependencies (generateGeminiImage, startGeminiVideoGeneration, pollGeminiVideoOperation, embedGeminiTexts, semanticSearchDocuments), which is what makes the slice testable at all: the focused test drives every branch, including all three refusal paths, with injected fakes, and a fetch spy that throws on contact proves nothing in it reaches an external media API — a mutant that adds a real fetch call to the module dies on that spy, so the check has teeth rather than being decoration. server.mjs keeps the providers, CONTENT_JSON_BODY_MAX_BYTES (the image route’s larger cap, while the other two POSTs take the reader default), resolveActive, the global loopback auth wall, and the refusal shapes and their order: a non-JSON body is refused with 400 bad-request BEFORE any provider call, which is why a refusing request can never make an outbound call. The FENCED static/CSP entries are untouched, and no fast-gate speed win is claimed — a change to this module still maps to ~137 tests, because server.mjs imports it and 137 test files name server.mjs (voicebox-beads-r9hv.4).
A staged Content-Security-Policy — reported, not enforced (voicebox-beads-ul1m.1; parent voicebox-beads-ul1m, ADR voicebox-beads-28sc). Responses to the room, help.html, changelog.html, environment.html and harnesses.html carry Content-Security-Policy-Report-Only holding, character for character, the policy public/index.html already enforces through its meta tag — six request spellings covering five documents, since / and /index.html are the same file. Nothing is blocked: every violation carries disposition=report, and there is deliberately no report endpoint, so violations appear in a devtools console and in the page’s own securitypolicyviolation event — which is how tests/csp-report-only-browser.test.mjs asserts them. The header is keyed on the RESOLVED FILE, never on the requested URL and never on a content type: cspReportOnlyHeaders takes the path that resolvePublicFile has already decoded, containment-checked and realpath’d, and reduces it to its public path. That is a fix for a hole a reviewer found rather than the original shape: keyed on the raw request path, /%68elp.html, /help%2Ehtml and /help.html/ all reached the same Help document and were served with no header at all, the trailing-slash spelling needing no encoding to type. Patching the comparison with a decode would have closed those three and left the next alias open; keying on the file collapses every spelling — encodings, dot segments, trailing slashes — to one string by construction, and tests/csp-report-only.test.mjs asserts that each alias carries the policy AND serves the same bytes as the canonical spelling, with traversal and case-mismatch spellings asserted as still unresolvable so “covered everywhere” cannot be satisfied by covering everything. It remains a list of documents rather than a rule phrased as “any text/html response”, which would silently cover the mini-app pages under public/apps/, the mini-app-bridge.html host — excluded because it is the frame that renders user HTML, and because srcdoc children inheriting that policy is spec behaviour this change did NOT measure and so did not rely on — and every document added later by someone who never read the server code. Those are excluded, along with every asset and /api route. What the reports currently say is nothing at all. The worklist emptied with voicebox-beads-xggy.3, which moved environment.html’s inline stylesheet to public/environment.css and its one inline style attribute to a rule in that file: both were what style-src 'self' and style-src-attr would have blocked, and both are gone. That page also declares its scheme in the head as a <meta name="color-scheme">. Its effect is narrower than it first appears: measured with the real external stylesheet, on both OS schemes, the page’s own first painted frame is identical with and without the meta, because the render-blocking stylesheet already supplies the scheme and the ground. What the meta reaches was measured on one surface only: the canvas the browser draws for the page. With /environment.css held so that the stylesheet could not supply a scheme at all, the UA’s Canvas resolves dark with the meta and light without it on an otherwise identical page, and CanvasText follows. Scrollbars and the caret run on the same mechanism and the same hint covers them, but those surfaces were NOT driven here and this page does not claim to have measured them. A preventive hint, not a fix for a flash in the page’s own content. Its root-declaration script was the third until it was extracted to public/environment-root.js (voicebox-beads-xggy.2), and help.html’s search filter went the same way to public/help-search.js (voicebox-beads-xggy.1); an inline script is the one thing script-src 'self' would block, and the Modern Web Guidance security guide names refactoring as the alternative to nonces and hashes. Both of that page’s scripts are loaded WITHOUT defer, deliberately, and that is a behaviour requirement rather than a style choice — environment-root.js for the declaration and environment-dialogs.js for the create-project opener (voicebox-beads-5tv3). Inline at the end of <body> it ran while the document was still parsing, so its click listener always existed before a person could click “Declare this folder”. A deferred file attaches once the document is already interactive, and a click landing in that window is lost permanently — unlike a search box, a click leaves no state to re-apply, so the fix used on the Help page (apply the value already present) cannot recover it. tests/csp-report-only-browser.test.mjs holds the file at the request stage and requires the document NOT to have become interactive, measuring the same technique against a file that IS deferred as its control, and tests/csp-report-only.test.mjs fails if the tag ever gains defer, async or type="module". environment.html is now the only allowlisted page that still reports anything, which is what keeps the alias-coverage signal non-vacuous. index.html reports nothing, which is the check that this is a mirror of the meta and not a second, stricter policy in disguise.
The shared static-file resolver refuses decoded NUL bytes as not-found before filesystem calls and still rejects traversal/symlink escapes, while unexpected filesystem failures remain server errors (voicebox-beads-mgbv).
Every route that reads a JSON body reads it through one bounded door in server.mjs (readJsonBody, voicebox-beads-d808): a body past the cap (1 MiB by default; routes whose body carries content — file text, mini-app HTML, extension source, tool args, base64 reference images — pass 8 MiB) is refused 413 body-too-large with the rest of the upload discarded unread, and a declared non-JSON Content-Type is refused 415 unsupported-content-type before a byte is read. A refused body never reaches the route. The one exception is POST /api/mini-app/tools, which predates the door with its own stricter bound (64 KiB, refused 400 body-too-large — see docs/17).
public/index.html loads theme-boot.js, fused.js, pip-mic.mjs, live-voice.js from public/.
AudioWorklet modules loaded by the frontend audio engine: pcm-worklet.js.
verify.mjs resides in public/ as a standalone verification utility and is not loaded by index.html.
server.mjs: Owns HTTP routing and the loopback auth wall, root declaration, WebSocket upgrade gates, and the central action executor (execute()). Always binds 127.0.0.1.lib/routes/workspace.mjs: Handles file listing, reading, editing, diffing, deletion and grep after the host auth wall. It calls the host’s bounded JSON door, path-authorization seam, page relay and executor; it does not define another root or permission check.lib/routes/extensions.mjs: Handles extension inventory, staging, approval, admission, revocation and reconfiguration after the host auth wall. Host-only admission still checks the host token; in-room mutation uses the host-owned hasExtensionAuthority() check, and the bounded JSON door stays in server.mjs.lib/state-dirs.mjs: Single owner for host state directory resolution (VOICEBOX_WORKSPACE, VOICEBOX_EXTENSIONS_DIR, VOICEBOX_WASM_SHELF_DIR, VOICEBOX_SANDBOX_HOMES), enforced statically by scripts/single-owner.mjs and tests/single-owner.test.mjs. Expands leading tildes (~) to os.homedir() across state directory values and operator declarations (workspaceDeclared(), sandboxHomesDeclared()), enabling boot root declarations (e.g. VOICEBOX_SANDBOX_HOMES=~/my-dir or VOICEBOX_WORKSPACE=~/my-dir) to resolve and auto-create fresh directories without requiring manual UI folder selection. Used across server.mjs, lib/resolver.mjs, lib/extensions.mjs, tools/approval-code.mjs, lib/fence-provider.mjs, and lib/unit-fence-provider.mjs.lib/path-auth.mjs (voicebox-beads-q0a3): Single owner for machine-side path authorization — root-kind handling (the reachability seam), lexical containment (core/paths.ts), the walk-up realpath containment, the audit guard, and the segment-wise dotfile denial, in that order. Every file verb (server.mjs), admitted extension tool (lib/extensions.mjs), mini-app store writer (lib/mini-app-store.mjs), cwd containment (lib/tool-index.mjs, tools/env-serve.mjs) and transport guard (lib/env-transport.mjs) asks it; scripts/single-owner.mjs refuses a second copy of any of the three shapes (declared as SITES), and drives the owner on a scratch tree so a declaration that stops refusing fails the gate.lib/acp-client.mjs, lib/pi-acp.mjs, lib/claude-acp.mjs: Own the Agent Client Protocol (ACP) client and stdio adapters. Each adapter enforces bounded execution timeouts (60s default ceiling for Pi; 120s CLAUDE_ACP_TIMEOUT_CEILING_MS for Claude Code, meta-capped at 600s).core/harness-config.ts & lib/harness-config.mjs: Own the configured-agent schema and atomic registry persistence (GET / POST /api/agents, PATCH /api/agents/:id), supporting both server and zero-server browser placements (lib/task-placement.mjs, docs/16-zero-server-delegation.md).public/fused.js: Owns the browser workspace UI, including:
#sqeh-deck): Toggles the Files popover (#sqeh-files-bubble, #made-list), floating File Viewer (#sqeh-reader-bubble, #reader), Mini-App launcher bubbles (#sqeh-actions — which render kind: "app" entries from /api/mini-apps and the apps launched this session; entries the store classifies kind: "page" are not listed, and are launched from the viewer’s #file-run-app or by name), and Recent Turns popover (#sqeh-toggle-history, #session).normaliseRelativeDir in core/paths.ts for breadcrumb folder navigation (tests/room-explorer-ui.test.mjs, tests/room-file-list-polish.test.mjs) and confirmation-gated file deletion via DELETE /api/file.window.__voiceboxRoomFoldersReady, window.__voiceboxClearAllRoomFolders, tests/room-folders.test.mjs).public/extensions-ui.mjs (voicebox-beads-v25a, slice of voicebox-beads-uei2): Owns the extension surface — the four-state list (running / waiting / found-here / refused), the approval disclosure and its plan cache, the manage dialog (reconfigure and remove), and the create-local form, together with the listeners that wire them. The Extensions UI now lives in public/extensions-ui.mjs; public/fused.js retains the host wiring, the shared element refs and the live WebSocket tool-status ownership. This narrows the edit surface of the central file — measured line counts are pinned to the extraction commit 73105d7 on voicebox-beads-v25a. The four things the surface used to read as fused.js locals now arrive through a single initExtensionsUI({ els, request, on, installLightDismissFallback, getToolStatus }) call: els is a pass-through of the element map, and the other three are the same helpers the rest of the page uses, so nothing is duplicated for voicebox-beads-ymu6 to collide with. lastToolStatus deliberately did NOT move — the live websocket tool-call hook owns it — so the module reads it through getToolStatus(), which must stay LIVE: it feeds the WASM shelf’s latency readout. A snapshot captured at init would freeze that readout, and before voicebox-beads-w1f9 that was invisible — the readout was wrong while the suite stayed green. It is not invisible now: tests/extensions-ui-extraction.test.mjs drives the real page’s own wiring and the room loop’s own window.__voiceboxOnToolCalls hook, so a captured accessor turns that test RED instead of shipping. tests/extensions-ui-extraction.test.mjs holds both failure modes: the dialog must not open when the surface’s listeners are absent, and a status change made behind the module must change the rendered latency on the next render.public/settings-ui.mjs (voicebox-beads-ymu6): Owns the native Settings dialog’s open/close/gear focus and aria-expanded listeners, agent settings pickers/persistence/reconnect messaging and masked provider-key status/save flow. public/fused.js injects its existing els, request, health, refreshDevices and on helper at one initialization site and retains exactly the original two boot-time loaders; the module itself makes no request at import/initialization. The host still owns device selection/capture/output routing, root/About facts, live-vision controls, theme and shared light-dismiss geometry; the microphone hotkey moved out of the host in voicebox-beads-uei2.4 into public/components/mic-hotkey-controller.mjs, and the host keeps only the global keydown dispatcher and the guards that decide whether a key is considered at all. The browser sends only pasted nonempty key values via PUT, clears key inputs on success and never persists or displays plaintext credentials; the host retains the credential store and API authorization. The header gear, open_panel=settings system command and SQEH dock all invoke that same gear opener (voicebox-beads-psfw); no direct showModal() bypass remains in the host, so the three routes share refresh, modal state and close focus. tests/settings-ui-extraction.test.mjs pins wiring, boot-request counts, dialog state, credential masking and live-session restart; tests/settings-open-paths-seam.test.mjs pins the three-route wiring and tests/settings-open-paths-browser.test.mjs drives each route plus repeat-open/close behavior. Focused browser suites tests/settings-dialog.test.mjs and tests/agent-settings-ui.test.mjs drive observable behavior.public/components/environment-root-dialog.mjs (voicebox-beads-mvxl, slice of the public/fused.js decomposition the architect scoped in voicebox-beads-uei2): Owns the Environments dialog’s ROOT surface — “Use browser workspace” (OPFS), “Pick a local folder…”, “Set machine root” (by path) and creating a project by name in browser storage — including the success line and the refusal each of them writes into #env-root-status / #env-create-project-status inside the open dialog, and Enter in the name box meaning the button rather than submitting the enclosing method=”dialog” form. Extracted verbatim from public/fused.js, which had grown it inline; fused.js hands it what it used to reach for as locals at one initEnvironmentRootDialog() site: its own element map, the on/request helpers (so there is one place where a missing element is not an error and one fetch refusal vocabulary), the room-root functions it calls (openOpfsScratchFolder, openRoomFolder, closeRoomFolder, renderRoot, loadRoot, load), SCRATCHPAD_NAME, and a live getRoomFolder() accessor. renderRoot deliberately did NOT move: it also paints the header chip, the About drawer and the project-change flash, so it is a state display this surface calls rather than part of it. The accessor must stay live — the picked-folder line is worded from the folder that is active at CLICK time, so a snapshot taken at init would call a folder the person can write “read-only”, which is the voicebox-beads-w1f9 shape (a captured accessor that stays green while the surface is wrong). tests/environment-root-dialog-extraction.test.mjs drives the real page with a real OPFS directory standing in for the folder picker: one click must reach the picker exactly once (a leftover copy of the old handler in fused.js makes it two), the status line must be the writable sentence on a page where nothing was active when the module was initialised (a snapshot makes it the read-only one), and a listener-free clone of the button must do nothing. Both mutants were driven RED against this branch rather than assumed. The ORDERING inside this module is load-bearing and was integrated deliberately (voicebox-beads-3auh): gate-recover’s ax2v repair (8470393) moved the repaint ahead of the announcement in fused.js’s declare-root handler, because the dialog could otherwise write “Machine root set to
public/components/changelog-dialog.mjs (voicebox-beads-mvxl, a slice of the same public/fused.js decomposition the architect scoped in voicebox-beads-uei2): Owns the room’s Change log dialog — the lazy /api/changelog fetch, the commit-card renderer (sha link, badge, subject, description, file pills, meta line), the conventional-commit classification behind the badge, and the open/close/refresh triggers together with the focus-return call to the trigger that opened it. Extracted VERBATIM from public/fused.js: the 123-line moved region is byte-identical in the module (checked mechanically, and locked by the extraction test), and it is wired at one initChangelogDialog({ els, on, sanitizeText }) site handing over the page’s element map, its own on helper and its own sanitizePlainActivityText redaction — so the Activity log and this dialog still redact with one vocabulary. It returns { openChangelogDialog } because the build stamp’s “change log” link calls it from OUTSIDE the surface (the initSettingsUi() shape); a module that forgot to return it leaves that link dead while every behaviour test still passes, which is why the extraction test opens the dialog from that link specifically. lastChangelogTrigger — the focus-return trigger — is module-local state, and the test asserts it as a COUNTED focus() call rather than by reading document.activeElement, because the platform’s own focus restore for a closed modal races this handler (the identical sequence left focus on the anchor once and on <body> once on this page). What deliberately did NOT move: the /api/changelog route (lib/routes/changelog.mjs), the standalone changelog.html + public/changelog.js page, and installLightDismissFallback(els.changelogDialog), which is the shared closedby="any" fallback every dialog on the page uses. Why the slice exists, measured: through scripts/test-changed.mjs a one-line edit of public/fused.js selects 148 test files (19 unit / 58 server / 71 browser), while an edit of this module selects its own focused test — a closure the fast gate can finish. The 148 is not a mapper artifact to be argued away: server.mjs really serves /fused.js (server.mjs:4165), so the 134 tests that name server.mjs come with the file. This extraction does not narrow the mapper, does not remove that dependency, and does not make the fused.js bootstrap cheap; it moves the blast radius of future work on this dialog. tests/changelog-dialog-extraction.test.mjs drives the real page for all four ways this extraction can rot: one refresh click must be exactly ONE fetch (a leftover copy of the old inline handler in fused.js makes it two — and the OPEN handler cannot show this, because openChangelogDialog() returns early when the dialog is already open, so refresh is the only count that exposes a double wiring), and that count is PROVEN LIVE in the same run before it is trusted — a second listener attached to the same button must make one click count TWO, so a counter that had quietly stopped counting cannot satisfy the assertion below — and it no longer stands on a guessed delay: the arm waits for the request to be ISSUED and then to SETTLE, because the moment used to be a 400ms sleep (the voicebox-beads-zjpm.5 class, never sample transient state on a delay), while the 250ms windows that remain are the CLICK arms’ own absence windows — the click under test’s, which proves one click stays one fetch once the request has settled (the leftover-second-listener mutant it guards fires in the same dispatch or on a short timer, and the doubled-listener control above it proves the counter would see it when it does), and that control’s own, so a removed listener does not fire late either — and the listener-free clone keeps a 400ms window of its own, an ABSENCE with nothing to wait for; a listener-free clone of the refresh button must fetch nothing (which is what makes that count evidence about wiring rather than markup); close must count one focus() call on the build-stamp link and none on the header button; and statically public/fused.js must no longer carry the moved markers while the module does — a DEAD leftover copy is invisible to every behaviour assertion and this repo has no lint gate to catch it. All three mutants were driven RED against this branch against the intended assertion, with their messages recorded on the bead. tests/changelog-links.test.mjs, tests/modal-panels.test.mjs and tests/ux-bubble-stage.test.mjs keep driving the dialog’s behaviour end to end, unchanged; the one assertion that moved with the code is tests/ui-modern-polish.test.mjs’s priority: "low" regex, re-pointed from fused.js to this module with the assertion itself unchanged.public/components/mic-hotkey-controller.mjs (voicebox-beads-uei2.4, a slice of the same public/fused.js decomposition the architect scoped in voicebox-beads-uei2): Owns the configurable microphone hotkey (default ‘M’; the feature itself is voicebox-beads-ebu) — the shortcut value and its persistence, the #mic-hotkey field and its two listeners, the #mic-hotkey-state / #mic-hotkey-badge / #mic aria-keyshortcuts paint, and the hotkey’s OWN semantics: whether a key matches (case-insensitively, against the live value) and what a match does (flash .hotkey-active for 300ms, then click the mic). It is wired at one initMicHotkeyController({ els, on, storage }) site, and storage is injected so the module never reaches for the localStorage global itself — those two reads and writes are the move’s only non-verbatim change. What deliberately did NOT move: the page’s global keydown dispatcher. It keeps the guards that decide whether a key is even considered — modifier keys, an input/textarea/select/contenteditable focused, an open <dialog> — because those are behaviour the owner test pins by name (typing ‘m’ in the composer must not toggle; a press while a modal is open must not toggle), so moving them would move the thing that makes them true. The dispatcher now asks the controller for the live key and calls its toggle. window.__voiceboxHotkey keeps its exact shape at the same point in the boot sequence, because three browser tests use it as their “the page finished wiring” signal and public/pip-mic.mjs reads .get() to label the Document-PiP control — which is why getHotkey is a live accessor rather than a snapshot. tests/mic-hotkey-extraction.test.mjs drives the real page (one press must reach the mic exactly ONCE, with the flash observed through a MutationObserver), drives the module STANDALONE with a stub store (which proves the injection: the write lands in the stub and not in the page’s real storage), and holds a static arm that public/fused.js no longer carries the moved markers while the module does — with its four negative mutants driven RED against this branch. tests/mic-hotkey.test.mjs keeps driving the feature unchanged (4/4). Why the slice exists, measured: a one-line edit of public/fused.js selects 152 test files (unit 20 / server 59 / browser 73); an edit of this module selects 1 (tests/mic-hotkey-extraction.test.mjs, browser). As with the earlier slices this does NOT narrow the mapper — server.mjs really serves /fused.js — so it moves the blast radius of future work on the hotkey surface rather than making the entrypoint cheap.public/components/audio-device-controller.mjs (voicebox-beads-uei2.6, a slice of the same public/fused.js decomposition scoped in voicebox-beads-uei2): Owns the three device facts and the routing policy between them — what microphone/speaker was CHOSEN, what is AVAILABLE and what is HAPPENING: the #mic-select / #out-select pickers, the #mic-device-state / #out-device-state readouts, the "voicebox.devices" preference, and the decision that a chosen output which disappears mid-reply STOPS playback rather than silently falling back. Wired at one createAudioDeviceController({ els, on, getLiveClient, storage }) site; storage is injected by the same seam the hotkey controller uses, so the module never reaches for the localStorage global, and the moved text is byte-identical to the region it was cut from apart from exactly two named seams — window.__voiceboxLiveClient becoming getLiveClient() (10 sites) and localStorage becoming storage (2 sites) — which the extraction test proves by reading the region back out of the immutable git blob it was cut from, rather than comparing it to a second copy that can drift. The extraction has since taken exactly TWO cited behaviour fixes, each written down as a named span rather than the comparison being loosened: (1) voicebox-beads-cqrd — the output-change handler’s refusal branch now repaints BEFORE it writes Could not use <device>. <reason>. and puts the picker back, because the repaint’s generic “No reply playing” line was overwriting the refusal the person needed to read and the picker kept naming the device that was NOT in use (measured on main as row “No reply playing” against a stored preference of out-a); and (2) voicebox-beads-t1k9 — an UNANSWERED client is not a false answer, so the controller starts with canChooseOutput: null, caches only a real boolean, and reads the client’s answer live at paint time instead of turning silence into “This browser uses system output. Change the output in your device’s sound settings.” while the client is still loading. const CITED_SPANS names those five places, both sides of the comparison are normalised through it, and the count is asserted per side — so the arm normalises ONLY what it cites, and a rewritten statement anywhere else still breaks it. Each cited change is then asserted POSITIVELY by its own function, which is why both are kept rather than merged into one: assertRefusalOrder pins the ORDER of the refusal branch (repaint, then put the picker back, then name device and reason) and assertT1k9Rules pins the four device rules (the null default, the live read, the sentence only for an answered false, the cache that takes only a real boolean). tests/settings-dialog.test.mjs drives the refusal end to end — a client that refuses a switch must leave the preference, the picker and the reason all naming the device actually in use — and tests/audio-device-controller-boot-paint.test.mjs drives the t1k9 rules; reintroducing either defect on the tree drives its own control RED. getLiveClient must be a LIVE accessor: the host passes () => window.__voiceboxLiveClient, because the client is created after this controller and replaced between rooms, and the test proves the liveness by REPLACING the client after init and driving an output choice through the new one. window.__voiceboxDevices keeps its exact four-member shape and boot position, because tests read it and public/settings-ui.mjs is handed refreshDevices; the record itself now stays private to the module. The move is covered by tests/audio-device-controller-extraction.test.mjs, which counts rather than assumes: one refresh costs exactly ONE enumerateDevices() call (a leftover inline copy makes it two, and the counter is shown to be sensitive by having a second controller enumerate again), a listener-free CLONE of a picker writes nothing, and choosing a device writes the preference while calling getUserMedia ZERO times — the “choice is a preference, never a grant” rule, asserted as a call count. The row-readout arm drives the refresh it depends on rather than reading whatever the boot refresh happened to paint (voicebox-beads-jeye): the output row’s first branch reads a fact only the LIVE client knows (canChooseOutput), and that client is published by a different module script (public/live-voice.js) than the surface’s own boot (public/fused.js) — two module scripts whose completion order is not fixed, because fused.js’s body awaits — so on a box where live-voice.js lost that race the boot paint said “This browser uses system output. Change the output in your device’s sound settings.” and the assertion failed on a correct module. The arm now waits for the client and then refreshes, which is what the real user path does when the settings dialog opens, and it proves the dependence by driving the client’s OWN ANSWER rather than by taking the client away: an answered false must paint the system-output sentence verbatim, an answered true must let the saved device be named, and an absent client must leave the row saying what the last REAL answer said — the case that used to be asserted the other way round, before voicebox-beads-t1k9, when the row was allowed to claim the machine could not choose an output merely because no client had spoken yet. tests/settings-dialog.test.mjs keeps driving the rows end to end, unchanged. Why the slice exists, measured: through scripts/test-changed.mjs an edit of this module selects 1 test file (its own extraction test), while an edit of public/fused.js selects 158 (unit 20 / server 62 / browser 76). As with every earlier slice this does NOT narrow the mapper — server.mjs really serves /fused.js — so it moves the blast radius of future work on the device surface rather than making the entrypoint cheap.public/components/sync-toast.mjs (voicebox-beads-uei2.5, a slice of the same public/fused.js decomposition scoped in voicebox-beads-uei2): Owns the revision-mismatch toast — its message, the reload and dismiss controls, and the dismissed-key memory that suppresses only the revision it was dismissed for — together with the build stamp: reading the page’s own stamp, turning the page and server revisions into #build, and the “change log” link. Wired at one createSyncToastController({ els, on, openChangelogDialog }) site; the changelog dialog’s opener is injected because that link calls it, and the module has no other route to the dialog — its extraction test asserts the page’s own dialog stays shut when the injected opener is a spy. Two host callers remain by design: the health poll calls stampBuild(...) on both its success and failure paths, and the About box calls pageBuild(). That second caller is why pageBuild is a plain export rather than a controller method — the About box runs while the host is still evaluating its own top-level body, and a function reached through the controller object would be a temporal-dead-zone crash there; an ESM import binding is initialised before the importing module’s body runs, so the export is safe at any point, and the extraction test refuses the other shape with a mutant. window.__voiceboxSyncToast keeps its exact shape and boot position, because tests/ux-bubble-stage.test.mjs drives the toast through .update(...). The move is verbatim: every one of the 125 moved statements appears unchanged in the module except function pageBuild() becoming export function pageBuild(), and the other additions are the wrapper, the return and comments. Measured: an edit of public/fused.js selects 152 test files (unit 20 / server 59 / browser 73); an edit of this module selects 1 (tests/sync-toast-extraction.test.mjs, browser). As with the earlier slices this does not narrow the mapper — server.mjs serves /fused.js — it moves the blast radius of future work on this surface.public/components/activity-log-ui.mjs (voicebox-beads-uei2.2, a slice of the same public/fused.js decomposition scoped in voicebox-beads-uei2): Owns the work activity log and the project-change flash — the list and its badge, the append path with its 60-item cap and its bounded duplicate-id set, the sequence counter behind generated ids, the /api/activity refresh, and the flash: the body and .where markers, the 650ms timer that clears them, and the entry the flash records. It is wired at one initActivityLog({ sanitizePlainActivityText, isDebugEnabled, createBoundedSeenSet }) site. The redaction vocabulary deliberately did NOT move. sanitizePlainActivityText stays in the host because the changelog dialog, the tool-failure path and the system-error path all redact through the very same function, so the module is handed it; a copy inside the module would be a second place to keep in step, which is what this extraction must not create, and the extraction test refuses it with a mutant. isDebugEnabled is injected for the same reason, so the stack branch keeps its gate rather than re-implementing it. The host keeps lastPaintedRootKey (root painting, not this surface) and the panel’s open/close wiring (deck state); but the log’s clear operation moved, because forgetting the duplicate-id set has to travel with the set. The host’s clear handler now calls activityLog.clearActivityLog() instead of reaching into the module — which is precisely the dangling reference this slice hit while it was being built: the handler still said workActivitySeenIds.clear(), a ReferenceError waiting for the first click and invisible to any load-time check, and the extraction test now pins that class of bug as a mutant. The three page globals keep their shape and their boot position: window.__voiceboxAppendActivity, window.__voiceboxOnActivity and window.__voiceboxFlashProjectChange, the last of which tests/ux-bubble-stage.test.mjs drives. The move is verbatim (all 109 moved statements appear unchanged; the additions are the wrapper, the return and the consolidated clear), and the host’s twelve call sites needed no edit because the factory’s returns are destructured the same way the changelog dialog’s opener already was. Measured: an edit of public/fused.js selects 152 test files (unit 20 / server 59 / browser 73); an edit of this module selects 1 (tests/activity-log-extraction.test.mjs). As with the earlier slices this does not narrow the mapper — server.mjs serves /fused.js — it moves the blast radius of future work on this surface.public/components/mini-app-surface.mjs (voicebox-beads-uei2.7, the bridge-bearing slice of the same public/fused.js decomposition scoped in voicebox-beads-uei2): Owns the interactive mini-app surface — the card (mount/close/toggle/expand/reload/edit/delete), the tool catalogue it exposes (sync/register/call with its pending-call correlation), and the bridge to the sandboxed app: the #mini-app-outer-frame iframe pointing at /mini-app-bridge.html, the MessageChannel whose port2 is transferred to that frame, the handshake that validates e.origin and e.source before it removes itself, and the descriptor html it hands over. It is wired at one initMiniAppHost({ … }) site. What the host kept, and why no call site changed: let miniAppController = null stays in the host as the live handle, because fourteen call sites across the host read it and six of them are null-guarded, and window.__voiceboxMiniApp is still assigned inside the host’s own if (els.miniAppContainer) guard — so a page without the container still leaves the global undefined and the controller null, exactly as before. launchedMiniApps also stays (it has readers outside this surface) and is passed in as the same Map, because it is only ever mutated in place. catalogMiniApps is read and reassigned by the delete path, so it arrives as an accessor pair (getCatalog/setCatalog) rather than a snapshot, which would have silently stopped a deletion from updating the catalogue; and sqehSyncMiniApps is declared after this surface in the host while the shell calls back into the controller, so it is injected as a callback. 380 of the moved statements are verbatim; the only two differences are the return in place of the old assignment into the host handle, and the accessor pair above. tests/mini-app-surface-extraction.test.mjs drives the real bridge (the app html crossing into the sandboxed frame proves the handshake, the single-use port transfer and load_app/mini_app_init together), pins that the handshake listener removes itself and that a remount closes the previous port while opening a fresh channel, and drives the installed handler directly to prove a forged handshake is refused on either a foreign origin or a wrong source window — with the seven mutants that break those invariants driven RED. Measured: an edit of this module selects 1 test file. Naming is part of that number: the module was first called mini-app-host.mjs, and that basename collided with the server-side lib/mini-app-host.mjs, which the mapper resolves by basename — the closure came out at 146 test files, dragging server.mjs’s whole set into a change that never touches the server. The rename to mini-app-surface.mjs is why the number is 1: a basename that already exists in lib/ costs a slice its focus.public/components/harnesses-dialog.mjs (voicebox-beads-uei2.9, ranked #1 by the voicebox-beads-uei2.8 plan, the settings-class slice of the same public/fused.js decomposition): Owns the Harnesses modal dialog — the host-inventory check (/api/harnesses, with the /api/agents fallback and the /api/harnesses/configure save), the harness rows and their per-state actions, the config form, the task launcher (/api/delegate), the output save, the project-context line, the modal tool-catalogue renderer, and all eight listeners on its controls. The moved region is the banner through the tracker’s own on(...) line — 358 lines, 336 statements, of which 334 moved verbatim: the 12 differences are 10 rewritten statements and the 2 statements that stay in the host, enumerated by the extraction test. fused.js 5531 → 5203 lines. The seam is the interesting part, and every piece of it is measured. Eight names are injected by value (els, on, request, health, load, showFile, loadRoomFolder, writeRoomFile); three are LIVE accessors because the host REASSIGNS them while the page lives (roomFolder, listingDir, miniAppController — a snapshot would pin a stale root, a stale listing, or a dead mini-app handle); and lastHarnessOutput is an accessor pair, because the page’s own __voiceboxOnTask handler WRITES it from outside the dialog — which is why the host keeps the binding and that call site needed no edit at all. Two functions the rest of the page calls — renderHarnessChangedFiles (that same task handler) and openAgentProgressTracker (the system-command path) — are returned by the factory, safe because both callers run after boot; the page global window.__voiceboxOpenAgentTracker is still assigned by the host at the same point in the boot sequence. One sharp edge worth knowing: miniAppController is declared AFTER this seam in the host, so handing over a snapshot is not merely stale — it is a temporal-dead-zone crash at boot (driven as a mutant and RED against the static arm), which is also why the accessor is only read on demand, never during init. tests/harnesses-dialog-extraction.test.mjs pins all of it: statically that the surface left the host and the host kept what it must keep; on the real page that one click on the check button is exactly ONE /api/harnesses fetch while a listener-free clone of the same button fetches nothing (the double-wiring detector — and the count is only taken after the check that OPENING the dialog starts has finished, because the button is rightly disabled while one is in flight: that probe takes ~7s on this host), that the host’s own __voiceboxOnTask path renders changed files through the returned callback and then reaches the pair (a sentinel in the status line proves the save handler got past its if (!getLastHarnessOutput()) return;), and that the tracker mounts the mini-app through the LIVE controller; plus a standalone arm with spies where the injected pair is the only path the module uses. Five mutants RED, each reverted with the tree verified pristine: a snapshot for the pair, a snapshot for miniAppController (the TDZ boot crash above), the host’s task handler writing elsewhere, the module never writing the pair, and a twice-installed check listener (which makes the page fetch twice). Measured: an edit of this module selects 1 test file (its own extraction test, browser lane) against 160 for public/fused.js (unit 20 / server 59 / browser 81) — and because that test is browser-lane, the unit fast gate is unchanged by this slice even though the file itself takes ~10s. Owner suites stay green and unchanged: tests/modal-panels.test.mjs, tests/ux-bubble-stage.test.mjs, tests/configured-harness.test.mjs.public/components/environment-list.mjs (voicebox-beads-uei2.10, a slice of the same public/fused.js decomposition scoped in voicebox-beads-uei2): Owns the environment list and the active-root readout that shares its source — the /api/environments read, the rows with their reachable/unreachable state and their plain-language notes, the per-row Re-probe and Remove actions, the auto-probe of a reachable environment that has never been probed, and the three named states of #env-active-root-val (“browser storage · …”, “picked folder · …”, “no folder chosen yet”). It is wired at one initEnvironmentList({ els, request, SCRATCHPAD_NAME, getRoomFolder, getActiveRoot }) site. This slice needs no element-wiring helper from the host: the renderer builds its own rows with document.createElement and attaches its handlers to those created buttons, which is why the host hands over only five names. els, request and SCRATCHPAD_NAME are plain values. roomFolder and activeRoot are live accessors, because the host assigns both somewhere other than their declaration and the renderer reads them at paint time: roomFolder is reassigned after the seam, and activeRoot — whose initial value is undefined and whose own comment reads “undefined = not asked yet, null = none declared” — is assigned inside functions that run later, so a snapshot taken at the seam would freeze “not asked yet” for the life of the page. Both are read only and never written by the module, verified line by line. renderEnvironments is returned because four host sites call it: the health poll, the “+” declare path, and the two dialog openers (#envs-open and the empty-state link). One thing this slice found and did NOT change: a successful probe writes its confirmation note (Re-probed this machine: voicebox.) and the refresh that follows clears it 4 ms later via the module’s own envNote.textContent = "", so the message is effectively invisible — measured with a MutationObserver, filed as voicebox-beads-2qxi rather than silently altered here. The move is verbatim: all 174 moved statements appear unchanged except the 8 lines carrying 10 rewritten occurrences (roomFolder ×3, activeRoot ×7, both to accessor calls), and the additions are the wrapper, the return and comments. Its extraction test drives the real page for the outward seam and the row actions — a listener-free clone of the Re-probe button issues no probe while one click issues exactly one — proves the accessors are live by changing the host state between four renders, and ends with a control that requires the same bounded wait to fail when the list genuinely never resolves. Measured: an edit of public/fused.js selects 162 test files (unit 20 / server 63 / browser 79); an edit of this module selects 2 (tests/environment-list-extraction.test.mjs plus the surface’s own tests/environment-list-ui.test.mjs, which the mapper brings along because it stem-matches the name). As with the earlier slices this does not narrow the mapper — server.mjs serves /fused.js — it moves the blast radius of future work on this surface.public/components/vision-controls.mjs (voicebox-beads-uei2.12, the vision/PTT-class slice of the same public/fused.js decomposition scoped in voicebox-beads-uei2): Owns the Live Vision and Push to talk surface — the STREAM (camera or screen capture, its tracks, its frame timer, the encode-in-flight guard and the metadata line), the PREFERENCES behind it (the voicebox:vision-prefs key: resolution, fps, thinking level, PTT on/off) and PUSH TO TALK (the button’s visibility, its pressed state and the activity control it sends). It owns all eleven listeners the host used to attach inline, and the initial paint from saved preferences. Wired at one createVisionControls({ els, encodeVisionFrame, visionGroundingController, setState, isLiveSessionActive, sendLiveVideo, restartLiveSession, sendActivityControl, setPipVisionStream, storage, media, timers }) site. Two discontiguous regions became one factory, which is behaviour-preserving by inspection rather than by hope: the surface’s first block ends where the debug / offline-speech / doctor handlers begin, and the PTT button’s own click listener sat after them — so the slice takes both regions and nothing between them. Those handlers are settings, not vision, they stay in the host, and nothing between the two regions read or wrote this module’s state; the extraction test asserts they are still in fused.js by name. What deliberately did NOT move: encodeVisionFrame, visionGroundingController and setState are handed in, because the encoder and the grounding controller already own their behaviour and each has its own test — a second copy here would be a second thing to keep in step, which is what this slice exists to avoid. The seam is the interesting part. storage, media and timers default to the real globals so the unit arms can drive the rules with no browser, no camera and no clock; and every host fact arrives as a LIVE closure over the global (isLiveSessionActive, sendLiveVideo, restartLiveSession, sendActivityControl, setPipVisionStream), never as a captured value — a snapshot would freeze the session answer the module asks about, the voicebox-beads-w1f9 shape this repo has paid for twice. The extraction test refuses the snapshot wiring statically AND drives the liveness: flipping the session answer between two starts changes whether the microphone is auto-clicked, and flipping it between two thinking-level changes changes whether a session is restarted. The move is verbatim and that is MEASURED, not claimed: 191 moved lines (183 of them code) appear in the module in order, apart from exactly 16 sites across 11 named seams, compared against the region read back out of the immutable base blob (0083140) rather than against a second copy that can drift; fused.js 4543 → 4370 lines. tests/vision-controls-extraction.test.mjs is unit-first — the initial paint from stored preferences, camera and screen start, the track-ended teardown, the preview/SEND split with its one-encode-in-flight guard, stop and the same-mode toggle, the preference writes with the timer restart, PTT’s enable/disable and click, a refused capture through setState, and a doubled initialisation that must make one press send TWO activity controls (the counter’s own sensitivity proof) — and then drives the REAL page on Chrome’s own fake capture device: camera on with a live track, a live snapshot reaching __voiceboxSendLiveVideo as JPEG while a preview snapshot reaches nobody, PTT sending start then end, and a stop that leaves the browser’s own track ended rather than merely a hidden card, closed by a listener-free clone of the camera button that must start nothing. Two source pins moved with the fact: tests/ptt-and-thinking-settings.test.mjs’s restart-on-thinking-change marker had become true by ACCIDENT after the move (the host’s two closures happen to sit inside its 160-character window), so it now asserts the rule where the rule is — the change handler, guarded by the session question — and tests/caption-fade-and-thinking.test.mjs’s els.mic.click() guard keeps every clause with the injected accessor; the extraction test drives both rules instead of only grepping them. Why the slice exists, measured: through scripts/test-changed.mjs an edit of this module selects 2 test files (its own extraction test and caption-fade-and-thinking.test.mjs) with no unmapped source, while an edit of public/fused.js selects 163 (unit 17 / server 62 / browser 84). As with every earlier slice this does NOT narrow the mapper — server.mjs really serves /fused.js — so it moves the blast radius of future work on this surface rather than making the entrypoint cheap.public/components/deck-controller.mjs (voicebox-beads-uei2.13, a slice of the same public/fused.js decomposition scoped in voicebox-beads-uei2): Owns the SQEH 3-state system interface controller (deck, files, history, activity), the Quick Files tiles and Mini-App tray bubble synchronization, popover sheet open/close state, navigation click listeners, outside-click and Escape key dismissal with modal dialog guards, window manager initialization, and the #files list MutationObserver. It is wired at one createDeckController({ els, document, window, getCatalogMiniApps, getMiniAppController, getLaunchedMiniApps, syncReaderBubble, refreshActivityLog, clearActivityLog, normalizeMiniAppKey, launchWorkspaceHtmlMiniApp, installWindowManager }) site. What the host kept, and why no call site changed: Element.prototype.scrollIntoView, HTMLElement.prototype.focus, and Element.prototype.checkVisibility prototype patches remain in ONE guarded adapter in public/fused.js, delegating made-list, reader, and text-form branches while preserving argument/return semantics for unrelated nodes; the component does not touch prototypes. Unrelated settings (doctor, debug, offline speech), status mirror, Help Center, vision controls, and window.__voiceboxOnToolCalls forwarder remain in the host. Host call sites (systemCommands at line 264, mini-app sync) delegate through the returned { setState, openSheet, syncMiniApps, syncQuickFiles, collapseOpenReader, getState } interface. tests/deck-controller-extraction.test.mjs pins unit state transitions, dynamic catalog updates, quick file tile clicks, reader collapse, modal dismissal guards, prototype delegation, and seam integrity, plus a real-browser CDP arm exercising navigation and dismissal. Measured: an edit of this module selects 1 test file (tests/deck-controller-extraction.test.mjs, browser lane) with no unmapped source, targeting <=3 tests, while public/fused.js mapping remains honest (164 tests).public/theme.mjs (voicebox-beads-ri9x, first slice of voicebox-beads-mvxl): Owner of the theme preference — the tri-state (light / dark / system) held in localStorage under voicebox:theme, the data-theme (effective) and data-theme-mode (preference) attributes, the prefers-color-scheme listener that re-applies while system is chosen, and the select/toggle/label/icon sync. Its storage listener follows valid choices from another tab without writing back, falls back to light for removed/invalid values, and initTheme re-entry removes only its own UI/media/storage listeners. Extracted from public/fused.js, which had grown it inline; fused.js hands it its three controls at boot (initTheme) and calls applyTheme from the system-command branch, so the module depends on nothing else in the page. Driven in real Chromium by tests/theme-browser.test.mjs and the two-tab/Help case in tests/theme-cross-tab-browser.test.mjs; tests/theme-storage.test.mjs pins listener lifecycle.public/style.css (voicebox-beads-7xse): The palette itself. All ten colour tokens are single light-dark(light, dark) pairs on :root, which declares color-scheme: light dark, and :root[data-theme="light"] / :root[data-theme="dark"] set ONLY color-scheme — so the light and dark values cannot drift apart, an explicit choice is a one-property override, and a browser that runs no script still follows the OS. The card shadow deliberately keeps box-shadow syntax with only its COLOUR in a light-dark() pair (--shadow-ink): light-dark() resolves to a <color>, so wrapping the shadow itself makes the declaration invalid and the computed value none, with no error anywhere — tests/theme-tokens.test.mjs refuses that shape and holds every token to the value it had before the refactor, while tests/theme-os-fallback.test.mjs asserts the computed shadow is never none in either scheme.
The stylesheet also establishes a legible type scale on :root (--text-xs 0.75rem through --text-2xl 1.25rem,
voicebox-beads-3674) with an enforced 0.75rem (12px) readability floor for all secondary text, badges, hotkey tags,
and timestamps across desktop and responsive mobile viewports. Every sub-12px declaration in the four surfaces at
a26fb12 — 43 of them: 28 font-size literals and four font: shorthands in public/style.css (including
.mic-hotkey-badge, .sqeh-dock-label, .sqeh-action, and .harness-badge), and 11 across
public/apps/agent-monitor.html, public/apps/landing-inspector.html and designs/prototype.html — is lifted to
the 0.75rem floor (var(--text-xs)), and the Document-PiP window’s .pip-task-chip moves off its own 11px
(public/pip-mic.mjs, the one module that injects a stylesheet of its own). The floor is pinned in two tiers:
tests/type-scale-tokens.test.mjs reads the declarations with no browser — comments stripped, the font: shorthand
read past any style or weight (font: 600 11px/1.4 … counts, which is how four sizes hid from the first
font-size:-only scan), read as declaration text rather than one line at a time and case-folded, so a value that
starts on the next line and an upper-cased property name are read too (voicebox-beads-jlfq) — across every
public/*.css, every page under public/, designs/prototype.html and public/pip-mic.mjs, so the fast gate
refuses a reintroduced sub-12px size. It also resolves a size carried by a
custom property, in every scope: a scoped override of a pinned token (--text-xs: 0.6rem inside one rule) and a
brand-new token are both refused, and the refusal names the declaration that set the size rather than every use of
it. A size the check cannot read at all — an unknown var() with no fallback, or a unit it cannot convert to px —
is refused rather than passed, because a floor check that answers “fine” to a size it could not see is the defect
tests/type-scale-tokens.test.mjs exists to prevent (voicebox-beads-94of); tests/type-scale.test.mjs
browser-verifies computed font sizes (>= 12px) across desktop, mobile, and secondary apps, and proves the lifted
rules are rem by doubling the root font size and watching them grow with it. The one row that did not survive that
doubling, the waiting-branches count badge, whose fixed height: 18px box could not hold the 24px the token resolves
to, now carries min-height so it grows with the user’s text scale instead of overflowing its own circle.
The .sr-only utility in public/style.css uses overflow: clip rather than overflow: hidden (voicebox-beads-dche,
the live room copy of voicebox-beads-y5dr). Under overflow: hidden the 1px box was a scroll container with a 65x20
scroll area on 6 of the 18 labels that existed when it was measured, which script could scroll to (scrollLeft = 999
moving to 64). overflow: clip clips identically without creating a scroll container, and every label now measures 0
while its accessible name stays in the accessibility tree. The test does not pin the label count — a toolbar button added
on main took it from 18 to 19 — and instead asserts the invariant over whatever .sr-only nodes the page renders,
backed by a control arm that proves the probe still detects an overflow: hidden 1px box. Pinned by
tests/type-scale.test.mjs.
The same file carries the room and Help placeholder fix (voicebox-beads-0q5c): those pages drew placeholder
text from the user agent’s grey, measuring 4.45:1 light and 4.16:1 dark against the fields’ own backgrounds
(and 4.19:1 / 3.17:1 on the two transcript fields’ darker ground), under the 4.5:1 floor this project applies
to placeholder text. :where(.room, .settings-form, .help-page) :is(input, textarea)::placeholder sets it to
var(--muted) — the colour the surrounding secondary text already uses — with an explicit opacity: 1, the
same statement .list-tools input::placeholder already
carries, and inert in this browser (opacity measured at 1 before the rule existed) — which measures 5.91:1 light and
7.47:1 dark on those fields. The :where() wrapper is load-bearing: .room input::placeholder and
.composer input::placeholder are at EQUAL specificity (one class plus one type, since ::placeholder counts
as a type), so on a tie source order decides and a plain container rule overrides the composer’s deliberate
alpha composite. :where() contributes zero specificity, so the rule fills only the gap the user agent
leaves and cannot outrank an authored rule. tests/placeholder-contrast-browser.test.mjs drives the
application’s own theme preference into stored light and stored dark and holds every opaque placeholder field
in both themes to the floor, and holds the composer to its own separate rule rather than to this one.
The room composer’s field (#utterance, voicebox-beads-8wm8) is deliberately quieter than typed text, so it is
a translucent mix of --muted rather than the token itself. At 62% it composited to 2.68:1 light and 3.46:1 dark
against the field’s own ground — under the floor. It is now 90%, chosen for HEADROOM rather than because it is
the bare minimum: 88% is the bare clearing candidate, at 4.54:1 light and 5.61:1 dark, only 0.04 above the floor
in light and so inside rendering variance. At 90% the placeholder keeps alpha 0.902 and measures 4.81:1 light and
5.87:1 dark against typed text at 14.91:1 and 17.11:1, so it stays roughly a third of the entered text’s
contrast and is not flattened to the opaque token. The
figures come from injecting the exact declaration into a disposable browser and reading composited pixels, with
alpha taken from a black/white pixel pair instead of parsing an oklch()/color() string; the same probe
re-reads the 0q5c opaque fields and .list-tools to confirm that changing a value inside .composer’s own
declaration leaves them untouched. Focus leaves the composer ground unchanged up to one unit, and the composer
input is never disabled — only the send button is.
tests/placeholder-contrast-browser.test.mjs drives the application’s own theme preference into stored light and
stored dark and asserts the composer’s mix percentage, its composited contrast against the resolved ground, its
alpha staying below 1, and that it remains a clear margin below the typed text in both themes. It enumerates the
composer placeholder with a non-empty guard, so a selector matching nothing fails rather than passes, and a
reduction mutant (90% back to 62%) must turn it red.
public/environment.css (voicebox-beads-xggy.3): The Environment page’s own stylesheet — its 11 colour tokens, each a single light-dark(light, dark) pair on :root, plus the page’s layout, table, dialog and :focus-visible rules. It is a separate palette from style.css’s ten: same discipline, different values (this page’s dark ground is #101014, not #0b0f19). Moved out of the page’s inline block for CSP readiness: the 80 original rules moved verbatim and in the same order, and the file adds a provenance header comment plus two rules: the one that replaced the single inline style attribute, and
::placeholder { color: var(--muted); } (voicebox-beads-sv2n; widened from a single input to the bare pseudo-element
by voicebox-beads-yd08), which replaces the user agent’s placeholder grey — 4.34:1 light and 3.87:1 dark, under the
4.5:1 floor this project applies to placeholder text — with the colour the page’s secondary text already uses,
measuring 5.76:1 and 6.45:1 on every one of the page’s six placeholder fields (five inputs and the asset-body textarea), which all draw from --input-bg. The browser test pins every pair twice — as the exact declared light-dark() text, and as the colour it resolves to in each scheme — so a future palette change is a deliberate act rather than something a relocation or a tidy-up carries in (voicebox-beads-h1oo).public/theme-boot.js (voicebox-beads-xpwt): The pre-paint copy of that decision, and the reason theme.mjs is described as the owner rather than the only site. It is a CLASSIC script — not a module, not deferred — loaded from <head> so it runs before the first paint, and since voicebox-beads-uj16 it is the SAME file that public/help.html loads: that page kept its own copy inline as the LAST thing in its <body>, and the copy is deleted rather than kept in step. That copy did NOT flash, which is worth stating because assuming it did is easy and wrong: an inline classic script runs while the document is parsing, and measured on the pre-uj16 markup it set the attribute at 1ms with the first frame at 13ms already dark. The xpwt flash came from a DEFERRED MODULE, a different timing. So the reason to delete this copy is divergence, not timing — it ignored system, never set data-theme-mode, and had no listener, and no test related it to the owner. The listener is why public/help-theme.mjs exists: the guard resolves the system scheme ONCE, so with system stored it would pin the page to whatever the OS was at load, and the module’s initTheme() installs the listener that keeps following an operating-system switch while the page is open (voicebox-beads-ftqe, found in review). theme.mjs is imported by fused.js and module scripts are always deferred, so without this the theme was applied after the paint (measured: dark stored, FCP 76ms, dark applied 78ms — a person who chose dark saw light first on every load). It restates theme.mjs’s four decisions — the storage key, the light/dark/system whitelist with anything else falling back to light, the matchMedia resolution, and the two attribute names — because it cannot import the module it shadows; tests/theme-boot-parity.test.mjs drives both implementations through the same fake DOM over the same matrix of stored values, OS schemes and blocked storage, and refuses any disagreement, so the restatement cannot drift silently; a further assertion there refuses any page that loads this file as a module or deferred, assigns data-theme itself, or reads the storage key itself, which is exactly the shape help.html had. It is external rather than inline because the page’s own policy is script-src 'self' (public/index.html), with no 'unsafe-inline', nonce or hash, which blocks the inline form the Modern Web Guidance FOUC recipe prescribes. That claim is measured rather than assumed, and the tag’s placement is load-bearing: a <meta> CSP governs only what is fetched after it is parsed, so the script sits BELOW the policy — an inline script added after the meta does not run, while this same-origin file loads and the theme is applied before the first frame, so the guard is demonstrably inside the policy rather than escaping it. tests/theme-first-paint.test.mjs asserts the theme at the first requestAnimationFrame, and its dark case fails when this script is removed from index.html; tests/help-theme-first-paint.test.mjs does the same for public/help.html, where removing the script leaves the first frame reading null at 55ms with the light ground — that page’s old body-end behaviour, reproduced exactly. It also asserts the computed color-scheme in the first frame, so the browser-drawn surfaces (scrollbars, caret, text selection, form controls) are not left on the previous scheme. Degradation, stated plainly rather than implied: if the storage read throws — site data blocked, private modes — the guard falls back to light, which is exactly what theme.mjs’s initTheme does in the same situation, and the page still paints and works. If JavaScript is disabled, or this script fails to load, the guard does nothing — and since voicebox-beads-7xse that is no longer a broken case: public/style.css sets color-scheme: light dark and writes every colour token as one light-dark(light, dark) pair, and neither page forces a scheme on <html> any more, so a browser with scripting disabled follows the operating system. A media query is NOT what provides this, and deliberately so: :root[data-theme="light"] outranks one, which is why a @media (prefers-color-scheme: dark) block would have been silently defeated until the forced attribute was removed and color-scheme was left to carry the decision. Both schemes are measured in a real browser by tests/theme-os-fallback.test.mjs, whose no-JS cases read computed styles through the CDP CSS domain because page.evaluate throws when scripting is disabled; the same file also pins the surviving asymmetry — with scripting, “no stored choice” still means LIGHT, because that is what theme.mjs does, so disabling JavaScript is what produces OS-following behaviour, not the other way round. A theme change made in another browser tab is not followed either (voicebox-beads-vtf3). An OFFLINE or lie-fi load is a separate case that this fix does not repair and deliberately does not claim to: public/sw.js is network-first but falls back to caches.match("./index.html") when the network fails, so a cached index.html whose markup predates this guard will still flash, because that document’s markup is what runs. That is a cache-freshness property of serving any cached document rather than something the guard can reach, so it is excluded from this change rather than fixed by it.