webui: serve the Console first screen from the control board (#1507)

This commit is contained in:
2026-09-12 19:37:35 -05:00
parent 7c8e530add
commit ea00ec66d9
29 changed files with 1639 additions and 0 deletions
@@ -0,0 +1,71 @@
# WEBUI-1507-R1
Author Dewey, independent reviewer Filbert, coordinator Darkwing.
Workspace `/mnt/storage/src/mosaic-stack`, branch `refactor`, baseline
355b4308c31c4bd500a844787eb1461f98dd2be3. Jason authorized issue-first,
coordination, commit and push for plan piece 4. Issue #1507 is open; Gate E
remains Jason-owned Monday 2026-09-14. No user acceptance is claimed.
## Candidate
All and only files under `packages/webui/`, pinned in
`webui-1507/manifest.sha256`. Manifest SHA-256:
`509f20e53292d5731c866d84c73cfaf539a89104b38b12a43260075ee7c3cb4d`.
No board files, seat files, comms, scanner, root package or fleet changes.
Darkwing confirmed ownership and named you reviewer, transport rc 0 followed
by his acknowledgement in this session. He released CURRENT and SESSIONS at
fab40f25. Concurrent planning commits f80404f5/355b4308 preserved the intake
status, with the shared-checkout hazard explicitly recorded in DEFERRED.
Original uncommitted WUI drafts, skills and other owners' files are excluded.
Review for exact brief conformance, proxy boundary/refusals, draft and focus
behavior, same-origin/loopback safety, rendering and maintainability. Reproduce
checks and return APPROVED or concrete findings in
`docs/plans/reviews/2026-09-13_webui-verdict.md`, then notify Dewey via
agent-send. Do not edit source or publish. No real board scans or real-seat
messages are needed for this review.
## Evidence
- `node --test packages/webui/tests/`: 6/6, including two Chromium browser tests.
- Clean baseline-plus-candidate copy at `/tmp/webui-1507-clean-8KGiHN`:
`node --test packages/webui/tests/ packages/control-board/tests/
packages/seat/tests/ packages/ledger/tests/ packages/mosaic/tests/`: 208/208.
Output: `webui-1507/clean-tests.txt`.
- Browser checks use the real board scanner/server with temporary fixtures,
including registered, working, waiting, error and offline rows. Only the
board transport is stubbed. Exact counts and Seen/Reply round trips pass.
- Browser keyboard, draft/caret, failed receipt, concurrent send exclusion,
empty/loading/malformed/unreachable, stale registration, hostile/long text
and denied localStorage cases pass. No real messages sent.
- Rendered contrast across ten palettes and three modes: 330 samples pass,
lowest 4.504658476260286:1. Five layout widths 320 through 2560px pass.
- Screenshots in `webui-1507/`: Harbor light page at 320/1440 and Grove dark
inspector at 320/1440. Dewey inspected all but the Harbor 320 screenshot
before this request; that screenshot remains available to inspect.
- CLI argument, upstream redirect, exact POST bytes/status, bad JSON/content
type/size, hostile Host/Origin and asset allowlist cases pass.
- No root CI workflow exists. No task/release/live fleet tests, deployment or
owner acceptance is claimed. Direct refactor publication is authorized.
## Corrections during author verification
The first browser test used a wrongly quoted CSS selector; corrected the test.
The Host test initially used fetch, which did not transmit the overridden Host;
replaced that probe with node:http and verified rejection. The first clean-tree
run exposed a loading-fixture race, ending the held response before the request
arrived; an explicit request promise fixed it, then 208/208 passed. These were
test defects, not waived failures.
## Scope choices and remaining gate
The left project filter uses actual board project names and counts, not mockup
workspace registrations. Dense table fields and inspector detail come only from
the board. Unsupported controller/generation/config/turns/activity and non-board
controls are omitted and listed in DEFERRED. CSS/brand/fonts are copied unchanged;
`live.css` supplies page adaptations. Appearance selectors remain on the one page.
Gate E requires Jason's Monday workday and end-of-day ruling. README gives the
startup commands, test steps and rollback to the unchanged board. Keep #1507
open until acceptance; no artificial issue closure from test results.
@@ -0,0 +1,108 @@
# WEBUI-1507-R1 review verdict
## APPROVED
Filbert, independent reviewer. Author Dewey. Reviewed 2026-09-13 against
Piece 4 in `docs/plans/2026-09-12_control-board-mvp.md` and the request at
`docs/plans/reviews/2026-09-13_webui-review-request.md`.
No blocking findings. Approval applies only to the exact package below. It
passes independent implementation review, not publication, deployment or user
acceptance. Gate E remains Jason's Monday 2026-09-14 ruling. Keep #1507 open
pending that acceptance.
## Exact candidate
HEAD was the requested baseline
`355b4308c31c4bd500a844787eb1461f98dd2be3` during provenance verification.
The candidate was the untracked `packages/webui/` overlay, not a committed tree.
Manifest: `docs/plans/reviews/webui-1507/manifest.sha256`.
Recomputed manifest SHA-256:
`509f20e53292d5731c866d84c73cfaf539a89104b38b12a43260075ee7c3cb4d`.
Its 21 entries cover all and only the 21 package files. All 21 content hashes
passed verification before review, in the independent test copy, and after
checks. This approval incorporates that exact manifest, not later package edits.
Behavior-bearing source hashes:
```text
20f8091e5fb3fcaba583b601ce258999d44afbe569e93c979938ace759b4eef4 packages/webui/src/cli.mjs
c0c1a360ce44a7439e67eea36b9237c59621aa3fd78571a823fd23dc15a950f6 packages/webui/src/serve.mjs
7f30847ba25eb7e2b176df614d7bcae45ab7d86615c84ad44ffc29f0f261759c packages/webui/src/public/app.js
db055d5b8a3710f4164193550ac25b7c5d223dd890ae3e9d73daaada38849557 packages/webui/src/public/index.html
7add449911677c662a05ad5d9c2462f919e759574fe4b85bd13a705cc54f6856 packages/webui/src/public/live.css
```
## Independently reproduced evidence
Created `/tmp/filbert-webui-1507-N4Iiyg` from a read-only `git archive` of
baseline `packages`, `scripts`, and `tools`, then copied only the pinned WebUI
package over it. No unrelated working-tree source entered this test copy.
No dependency installation was needed. Node v26.8.1 and installed Chromium.
Commands run from that copy:
```sh
sha256sum -c manifest.sha256
WEBUI_EVIDENCE=/tmp/filbert-webui-1507-N4Iiyg/screenshots node --test packages/webui/tests/
node --test packages/webui/tests/ packages/control-board/tests/ packages/seat/tests/ packages/ledger/tests/ packages/mosaic/tests/
```
- WebUI: 6 passed, 0 failed, no skips, including both Chromium tests.
- Combined suite: 208 passed, 0 failed, no skips.
- Rendered contrast: 330 samples, no failures, minimum 4.504658476260286:1.
- Layout checks passed at 320, 390, 768, 1440 and 2560px, with and without
the inspector. Table-local horizontal scrolling is intentional.
- Inspected all four independently generated screenshots: Harbor light page
at 320/1440 and Grove dark inspector at 320/1440. Controls and text remain
readable; the narrow inspector scrolls vertically to its reply controls.
- `node --check` passed for both server modules, both public scripts and all
five test modules.
- `cmp` confirmed copied shared CSS, Console CSS, brand.js and all six font,
source and license files are unchanged from Dewey's WUI assets.
Raw local results are `webui-tests.txt`, `combined-tests.txt` and `hashes.txt`
in the independent copy. Screenshots are in its `screenshots/` directory.
These temporary files supplement this durable result summary.
## Review conclusions
The page implements the approved Console layout using board data only. Project
counts and filters do not hide another project's waiting requests. The dense
table and inspector expose the specified fields; unsupported mockup controls
are absent. Palette and appearance remain on this one page. Refresh uses the
board page's ten-second cadence, with Pause and manual refresh, not a new scanner.
The server binds only to loopback, restricts the upstream to an HTTP loopback
origin, normalizes localhost without DNS resolution, rejects hostile Host and
Origin requests, and serves only allowlisted assets. POST routes require a JSON
object and enforce the 4096-byte limit. The proxy preserves accepted POST bytes,
upstream status and JSON text, refuses redirects, and does not retry actions.
Seen and Reply reach only fixture transports during verification.
Rendering escapes dynamic text and attributes. Hostile identifiers do not enter
CSS selectors. Drafts, receipts and caret positions survive refresh; stale
registration removes reply access without discarding the draft. A per-row
in-flight set prevents duplicate sends, and text changed during a send survives
its earlier success. Keyboard row navigation, inspector focus and Escape return
passed. Unreachable or malformed board data produces a warning without erasing
the last valid snapshot. Denied localStorage falls back safely.
The implementation is small and separated into CLI, proxy and page behavior.
Copied design assets remain separate from live-page adaptations. Source review
and tests support the declared boundaries; no board, seat or comms change is
part of this candidate.
## Limits and handoff
The checks used synthetic session and registration files and stubbed transport.
No real board scan, real-seat message, private-session read, credential access,
source edit or publication was performed. Git use was read-only provenance and
archive extraction. Browser evidence is Chromium, not a screen-reader audit or
Jason's workday acceptance. Contrast samples are not an exhaustive accessibility
certification. The README documents in-memory draft loss on reload and uncertain
delivery after a lost response.
Dewey should reverify the manifest before any separately authorized publication.
CURRENT.md remains with its existing owner for the verdict/publication transition.
@@ -0,0 +1,217 @@
✔ loadConfig: missing file throws ConfigError (2.069893ms)
✔ loadConfig: invalid JSON throws ConfigError (0.438417ms)
✔ loadConfig: missing dataRoot throws ConfigError (0.327993ms)
✔ loadConfig: relative dataRoot throws ConfigError (0.325869ms)
✔ loadConfig: valid config returns dataRoot (1.031496ms)
✔ findNewestSession: picks the newest by mtime among two files (0.731378ms)
✔ findNewestSession: finds files in nested subdirectories (0.449258ms)
✔ findNewestSession: returns null for a missing dir (0.190751ms)
✔ readSession: extracts fields, collapses/truncates text, counts a truncated final line (1.108679ms)
✔ readSession: model and provider follow the latest model_change entry or assistant turn; null when the log names neither; scanAgent carries them (2.038489ms)
✔ readSession: lastError carries the assistant errorMessage only when the last assistant turn errored (0.855495ms)
✔ findNewestSession/scan: never read sibling auth or secrets next to a sessions dir (2.03661ms)
✔ deriveState: full state table (0.313836ms)
✔ rule: newest entry is an assistant message with a tool call, after a question-looking text, is working (0.601595ms)
✔ rule: newest entry is a tool result with no assistant text after it is working (0.531506ms)
✔ rule: a finished turn (text-only assistant message, stopReason stop) is waiting (0.405072ms)
✔ task: the first user message of the session, from text blocks (0.491929ms)
✔ task: a plain-string user content is accepted, whitespace collapsed and long text capped (0.381223ms)
✔ task: no user message in the log means null (shown as unknown), never a guess (0.472373ms)
✔ workspace: the live tmux pane path wins; the session cwd is the fallback; neither means null (0.567966ms)
✔ activeProject: basename of the nearest .git directory or .git file above the workspace; none means null (0.643836ms)
✔ scan: the written record carries task, workspace and activeProject (0.745408ms)
✔ registration: overrides task, project and workspace; every source says registration; registered carries the launch fields; the grouping column is untouched (0.812363ms)
✔ registration: empty task and null project/workspace leave the derived values in place; registered is still non-null (0.549512ms)
✔ registration: a record whose pid is gone is stale; derived values win, sources say derived, registered stays with alive false; a pid the probe cannot decide is not stale; pidAlive itself (1.429642ms)
✔ registration: no registration leaves the Gate A fields exactly as before, and registered is null (0.623553ms)
✔ loadRegistrations: a missing seatsDir gives empty lists (0.275783ms)
✔ loadRegistrations: one good record, one malformed JSON, one with an unknown field; a stray file under seatsDir is ignored (1.147462ms)
✔ matchRegistration: matches by sessionsDir, and by realpath through a symlink; sessionsDir null never matches; same seat name with a different sessionsDir does not match (fleet vs repo darkwing) (1.76852ms)
✔ scan: writes the registration override to disk; index.json carries registered and registrationErrors (1.131616ms)
✔ scan: a relative seatsDir throws ConfigError; an omitted seatsDir behaves as before (0.313115ms)
✔ scanAgent: waitingOnYou is true for waiting/error and false otherwise (1.127154ms)
✔ scanAgent: ageSeconds is computed from the injected now (0.302815ms)
✔ scanAgent: sessionFile null and state idle when sessions dir is empty but alive (0.199626ms)
✔ discoverRepoAgents: finds agents with a sessions dir, skips those without, sorted by name (0.381178ms)
✔ discoverFleetAgents: finds agents with a sessions dir, sorted by name, fleet tmux fields (0.405132ms)
✔ scan: writes per-agent files and index.json, rerun overwrites, no leftover tmp files (1.289574ms)
✔ scan: relative boardDir throws ConfigError (0.150536ms)
✔ CLI: scan with assume-alive liveness exits 0, prints board summary, writes board files (89.928423ms)
✔ CLI: missing config exits 2 with a refused: message (80.253972ms)
✔ CLI: unknown command exits 2 (79.841572ms)
✔ CLI: unknown --liveness value exits 2 (90.403332ms)
✔ panesRunPi: true when any trimmed line equals 'pi' (0.356649ms)
✔ panesRunPi: false for bash-only, claude, empty, or node-pi-style lines (0.162436ms)
✔ tmuxIsAlive: a pane running pi is alive (0.297307ms)
✔ tmuxIsAlive: session exists but pi has exited is not alive (0.124004ms)
✔ tmuxIsAlive: no such tmux session is not alive (1.002756ms)
✔ tmuxIsAlive: tmux could not be run at all is unknown (null), never assumed alive (0.141834ms)
✔ tmuxIsAlive: passes -L <socket> only when a socket is given (0.188087ms)
✔ parsePanes: one pane per line, command and optional tab-separated path (0.141459ms)
✔ tmuxInspect: reports the path of the pane running pi, not of a shell pane (0.129298ms)
✔ tmuxInspect: no pi pane, no session, or no tmux gives no workspace and the matching liveness (0.172234ms)
✔ loadSeen: missing file returns {} (1.594824ms)
✔ loadSeen: invalid JSON throws ConfigError (2.10826ms)
✔ loadSeen: a JSON array throws ConfigError (0.343731ms)
✔ loadSeen: a non-string value throws ConfigError (4.345296ms)
✔ markSeen: seen true adds the key and writes seen.json mode 0600, no leftover tmp files (3.563497ms)
✔ markSeen: seen false deletes the key (1.941261ms)
✔ markSeen: missing, empty, or non-string fields throw ConfigError (0.477522ms)
✔ markSeen: project containing '/' throws ConfigError (0.231097ms)
✔ markSeen: non-boolean seen throws ConfigError (0.202643ms)
✔ scanAgent: a seen mark matching the waiting session's lastTimestamp clears waitingOnYou (2.88064ms)
✔ scanAgent: a stale mark (agent wrote something newer) is not seen and waitingOnYou is true (0.617591ms)
✔ scanAgent: a working session with a matching mark is not seen (marks only apply to waiting/error) (1.806863ms)
✔ scanAgent: an error-state session with a matching mark is seen (2.149805ms)
✔ scan: index.seen and waitingOnYou reflect seen.json, which scan never rewrites or deletes (1.045558ms)
✔ scan: a corrupt seen.json makes scan throw ConfigError (fail closed) (0.490992ms)
✔ isLoopbackHost: recognizes loopback hosts (2.380812ms)
✔ isLoopbackHost: rejects non-loopback hosts (6.201413ms)
✔ startServer: refuses a non-loopback host with ConfigError, never opens a socket (4.882151ms)
✔ startServer: serves page, healthz, and a rescanning /api/board (48.899289ms)
✔ startServer: a seatsDir registration overrides the row and index.registered reflects it (11.47682ms)
✔ startServer: /api/board returns 500 JSON with an error field when scan throws (4.702469ms)
✔ CLI: serve refuses a non-loopback host with exit 2 and a refused: message (78.932321ms)
✔ CLI: serve rejects a non-numeric --port with exit 2 (82.056496ms)
✔ CLI: scan still works after the async cli refactor (93.42089ms)
✔ CLI: live serve prints its URL and answers /healthz (134.584373ms)
✔ page.html: esc() escapes every HTML-significant character (1.293554ms)
✔ POST /api/seen marks a row; GET /api/board still shows it seen; seen:false clears it (19.569156ms)
✔ POST /api/seen without a JSON content-type returns 400 and does not write a mark (5.81507ms)
✔ POST /api/seen with invalid JSON returns 400 (8.413609ms)
✔ POST /api/seen with a body over 4096 bytes returns 400 (or resets the connection) and writes no mark (6.922079ms)
✔ POST /api/seen with a missing agent returns 400 (6.492325ms)
✔ POST /api/board returns 405; PUT /api/seen returns 405 (6.785092ms)
✔ CLI: scan --print marks a seen row with 's' and the summary line ends with 'N seen)' (93.27849ms)
✔ page.html: seenControl() escapes rec.project/agent/lastActivity, and the POST uses a JSON content-type (0.666541ms)
✔ page.html: has a collapsed Seen section that lists seen rows with the shared row builder (1.860189ms)
✔ page.html: each project has a Hide seen checkbox (default on) beside Hide offline, with a hidden-count note (0.458153ms)
✔ page.html: a project header reads "N of N" only while a checkbox hides rows (0.301181ms)
✔ page.html: every row shows Task and Active project, derived or the word unknown, with the workspace in the detail (0.522645ms)
✔ page.html: task and active project cells show their source via sourceTag(); the detail has a Registered row via registeredText(); SOURCE_LABEL maps registration to registered; every dynamic value in sourceTag/fromSource/registeredText is escaped (1.596776ms)
✔ POST /api/reply: runs agent-send.sh with -s from the registration, -S <host>:control-board, -m text plus the fixed trailer, no -L on the default socket, MOSAIC_TMUX_SOCKET stripped; answers delivered with the exit code and both streams (66.023811ms)
✔ POST /api/reply: a registration with a tmux socket adds -L <socket> (47.085295ms)
✔ POST /api/reply: a non-zero tool exit is a 200 with delivered false, the exit code and the stderr verbatim (44.906851ms)
✔ POST /api/reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, no registration 409, stale registration 409, no tmux session 409, bad JSON 400; the tool is never called (30.336458ms)
✔ POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash (6.232881ms)
✔ replyToRow: DEFAULT_AGENT_SEND is the repository's tools/tmux/agent-send.sh and it is executable (0.196143ms)
✔ page.html: the reply box appears only where canReply() holds (live registration with a tmux session), the detail has a Reply row, the submit posts JSON to /api/reply, receipts and drafts survive a refresh, and every receipt value is escaped (0.713094ms)
✔ real helper GET HTTP 200 preserves exit 0 without credentials (16.87011ms)
✔ real helper POST HTTP 201 preserves exit 0 without credentials (19.904021ms)
✔ real helper GET HTTP 403 preserves exit 1 without credentials (14.960278ms)
✔ fixture git subjects only, follow-ups and three session kinds (150.605163ms)
✔ text and JSON carry same numbers, open and truncated title (323.809392ms)
✔ missing credentials exit 2, no-issues never calls API and shows unknown (256.457428ms)
✔ empty range gives no rows and zero totals (141.564883ms)
✔ inclusive UTC dates, first-line preamble only, role and seat boundaries (116.133329ms)
✔ close-only issue included, even median, missing metadata stays unknown (88.374838ms)
✔ unique commits but per-issue links count multiple tags once each (87.716176ms)
✔ page cap refuses rather than silently undercounting (101.467302ms)
✔ bad API payload not JSON refuses (91.969036ms)
✔ bad API payload {} refuses (86.830577ms)
✔ bad API payload [{"number":1}] refuses (77.291166ms)
✔ partial or malformed session log refuses with location, not content (91.720579ms)
✔ no sessions is an empty table; symlink source refuses (138.05013ms)
✔ reads only refactor even when another branch is checked out (85.265578ms)
✔ invalid dates, reverse dates and duplicate options refuse (46.116248ms)
✔ preamble parsing and issue number boundaries (0.357602ms)
✔ no closed issues with human messages means undefined ratio, not invented zero (0.191973ms)
✔ pure resolution selects current default or explicit enrolled account (3.452427ms)
✔ scope is explicit, bounded and never inferred (3.224534ms)
✔ fork pin is preserved against default change, override, missing account and revocation (2.07011ms)
✔ unenrolled account/provider, missing harness, model expansion and native model ceiling refuse (1.785801ms)
✔ only explicit synthetic credential forms and internal fixture stores admitted (12.853623ms)
✔ two concurrent workspaces of the same agent publish distinct complete private generations (89.218237ms)
✔ same execution ID is exclusively claimed and cannot overwrite a published generation (51.919745ms)
✔ failed generation after-auth preserves prior files, records failure and refuses blind same-ID retry (69.110981ms)
✔ failed generation before-publish preserves prior files, records failure and refuses blind same-ID retry (165.301085ms)
✔ credential lock contention refuses without duplicate side effects (34.503814ms)
✔ symlinked pre-existing final target is refused and never followed (76.344676ms)
✔ invalid registry cannot resolve; no fallback to supplied partial entries (0.585699ms)
✔ post-publication failure records uncertainty, preserves complete generation and prevents replay (90.149481ms)
✔ expired credentials refresh under transaction and subsequent generation reuses rotation (229.155792ms)
✔ refresh failure retains prior generation and store state (80.103824ms)
✔ refresh timeout retains prior generation and store state (166.055716ms)
✔ refresh malformed retains prior generation and store state (68.191722ms)
✔ concurrent refresh on same account refuses contention while unrelated account proceeds (218.21407ms)
✔ invalid refresh options refuse before burning claim (39.334705ms)
✔ fixed fake process rotates both OAuth fields without mutating caller input (51.399762ms)
✔ concurrent isolated processes preserve separate provider credentials (55.271858ms)
✔ fake failure is refused with fixed diagnostics (33.787807ms)
✔ fake malformed is refused with fixed diagnostics (47.268272ms)
✔ fake timeout is refused with fixed diagnostics (108.39385ms)
✔ fake unchanged is refused with fixed diagnostics (64.498115ms)
✔ caller executable/environment injection is rejected before spawning (0.483922ms)
✔ valid fixture tree validates and lists without secrets (112.29331ms)
✔ unknown-field refuses (0.679922ms)
✔ invalid-id refuses uppercase and traversal shapes (0.462347ms)
✔ plain-http baseUrl requires allowInsecureTransport (0.517778ms)
✔ native provider rejects allowInsecureTransport (0.232811ms)
✔ unsupported credential type and kind refuse (0.267028ms)
✔ account provider-path mismatch refuses (1.053032ms)
✔ profile account refs must be provider/account shaped (0.410258ms)
✔ seat selection accepts fork pin field, validates account refs (0.385189ms)
✔ harness manifest id must equal executable (gate 1) (0.471681ms)
✔ CLI validate: duplicate provider id across files refuses (49.292022ms)
✔ CLI validate: missing referenced provider/account refuse (56.231526ms)
✔ CLI validate: broken JSON refuses without secret echo (44.514594ms)
✔ CLI usage errors exit 2 (111.897184ms)
✔ credential.json sibling presence does not break validation and is never read (168.781797ms)
✔ D1 missing, empty and structurally empty roots refuse, no list projection (275.749732ms)
✔ D1 required directory auth cannot be absent (124.619472ms)
✔ D1 required directory auth/providers cannot be absent (136.518861ms)
✔ D1 required directory auth/accounts cannot be absent (117.238735ms)
✔ D1 required directory auth/settings cannot be absent (119.706456ms)
✔ D1 required directory harnesses cannot be absent (97.054141ms)
✔ D1 root file and unreadable metadata refuse (137.663685ms)
✔ D2 no symlink traversal at auth/providers/openai-codex.json (57.104514ms)
✔ D2 no symlink traversal at auth/accounts/openai-codex/homelab-openai (58.8982ms)
✔ D2 no symlink traversal at auth/providers (69.919373ms)
✔ D2 no symlink traversal at auth (66.585499ms)
✔ D2 root and ancestor symlinks and lexical traversal refuse (182.602198ms)
✔ private filesystem modes enforced for root (56.988873ms)
✔ private filesystem modes enforced for auth (54.402276ms)
✔ private filesystem modes enforced for auth/providers/openai-codex.json (55.196762ms)
✔ private filesystem modes enforced for auth/accounts/openai-codex/homelab-openai/account.json (62.434238ms)
✔ D3 numeric version 1 only across all record kinds (1.213381ms)
✔ D4 nested unknown keys and missing per-kind required fields refuse (51.243059ms)
✔ D5 unenrolled default refuses even when account exists (61.905203ms)
✔ D6 provider/account credential type must match (61.029479ms)
✔ D7 every harness endpoint enforces HTTP opt-in and shape (0.495449ms)
✔ D8 URLs reject embedded credentials and unsupported protocols without echo (164.103475ms)
✔ D9 malformed JSON diagnostics contain no content excerpt (61.131821ms)
✔ D10 missing metadata is missing-path, not invalid-json (61.521407ms)
✔ D10 library returns no partial entries on any invalid record (70.423693ms)
✔ null/scalar/array metadata refuses without stack or echo (216.403521ms)
✔ credential sibling is never opened, even when an unreadable symlink (34.349719ms)
✔ oversized metadata refuses before parsing (58.959915ms)
✔ resolveSeat: by name under --repo resolves the repo layout (2.244578ms)
✔ resolveSeat: by path resolves the fleet layout (0.655672ms)
✔ resolveSeat: refusals for missing dir, missing launch.sh, non-executable launch.sh, invalid name, and unknown layout (1.497292ms)
✔ tmuxContext: outside tmux, default socket, custom socket, and exec failure (1.278671ms)
✔ makeRegistration produces a record that validates; each shape violation throws SeatError (1.478842ms)
✔ writeRegistration/readRegistration: round trip, permissions, absence, and malformed records (1.781997ms)
✔ updateTask: changes task and updatedAt only, and refuses appropriately (2.864265ms)
✔ CLI launch: registers, execs the fake launch script, and passes args through (40.186244ms)
✔ CLI launch: --harness lands in the record (62.351088ms)
✔ CLI launch: the launch script's own exit code passes through (52.770779ms)
✔ CLI launch: relaunching a seat rewrites the one registration record (103.782698ms)
✔ CLI launch: omitting --task records an empty string, not null (68.271979ms)
✔ CLI seat task: updates only the task after a launch, and refuses on an unlaunched seat (201.947039ms)
✔ CLI refusals: no args, unknown flag, missing config, already-registered env, and exec failure (249.955376ms)
✔ samePath: equal paths, symlinked dirs, distinct dirs, and non-strings (0.580992ms)
✔ browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback (1340.984357ms)
Rendered contrast: {"failures":[],"count":330,"lowest":4.504658476260286}
✔ served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures (1624.334009ms)
✔ loopback host and board origin fail closed (7.093521ms)
✔ real board fixture passes through WebUI; assets and isolated seen/reply work (97.215606ms)
✔ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect (36.079167ms)
✔ unreachable board reports URL; CLI rejects unsupported options (389.105161ms)
tests 208
suites 0
pass 208
fail 0
cancelled 0
skipped 0
todo 0
duration_ms 2604.12737
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

@@ -0,0 +1,21 @@
ee81d2b902a0b250f32d997d27085b9c76a7c98acdd0a0b55c65cf9342047b4e packages/webui/package.json
6eca43194632017ffed73d62ba76c74435c4b661f4ec3fc76d8b0bae8bcf9089 packages/webui/README.md
20f8091e5fb3fcaba583b601ce258999d44afbe569e93c979938ace759b4eef4 packages/webui/src/cli.mjs
7f30847ba25eb7e2b176df614d7bcae45ab7d86615c84ad44ffc29f0f261759c packages/webui/src/public/app.js
a30ddcd349703aff7464c34bef3fffdff405ee50c113440d7c8693c02d210972 packages/webui/src/public/assets/fonts/manrope-400.woff2
a30ddcd349703aff7464c34bef3fffdff405ee50c113440d7c8693c02d210972 packages/webui/src/public/assets/fonts/manrope-500.woff2
a30ddcd349703aff7464c34bef3fffdff405ee50c113440d7c8693c02d210972 packages/webui/src/public/assets/fonts/manrope-600.woff2
a30ddcd349703aff7464c34bef3fffdff405ee50c113440d7c8693c02d210972 packages/webui/src/public/assets/fonts/manrope-700.woff2
e01b637272e0cbdfb240184dd98ea5cc671556d9894dae2668d92ab2c906787c packages/webui/src/public/assets/fonts/manrope-OFL.txt
0d9aaaaa963b675d94d8fbee0cf47a8ecf03ea412f8b14aba96416375e5a2bb5 packages/webui/src/public/assets/fonts/manrope-sources.txt
d568df6dca14ae336db3b627639c8181c088649a21ffe992f9bfacc20b5b6238 packages/webui/src/public/brand.js
1ce47447339a274dd51c0168838a5e58e0addaf2200e04625bc1da5bc9279097 packages/webui/src/public/console.css
db055d5b8a3710f4164193550ac25b7c5d223dd890ae3e9d73daaada38849557 packages/webui/src/public/index.html
7add449911677c662a05ad5d9c2462f919e759574fe4b85bd13a705cc54f6856 packages/webui/src/public/live.css
b3e6e240fa93555e30321f897405839856b3438ef44b1cea73d2f5fbd899077e packages/webui/src/public/shared/app.css
c0c1a360ce44a7439e67eea36b9237c59621aa3fd78571a823fd23dc15a950f6 packages/webui/src/serve.mjs
2bf76719da97dfbad4edeeac98d608a4a8b09fccc2c1e42fba7cad61af205b0d packages/webui/tests/browser-edge.test.mjs
3ff14de07e87cc624433db6425b9d05e40d98f61e40f2bbf1fbee7431f9d73c1 packages/webui/tests/browser.mjs
f9cc0cf8603481a11f5eeb90ebc35fc5f1a7fff22a076e8a89eeec12cfd1700c packages/webui/tests/browser.test.mjs
3887958f6c3343ac8d6db800725b75482cc66e559e6eb925b842eb9a5ae13bd2 packages/webui/tests/fixture.mjs
15e77422890bc50cdb700484ca7e16ac32476aa8ec61d5575a6e0a920694bc4c packages/webui/tests/serve.test.mjs
+99
View File
@@ -0,0 +1,99 @@
# Mosaic Console
Piece 4, #1507. The control board is the first and only WebUI screen.
## Run
Start the existing board in one terminal, then the WebUI in another:
```sh
node packages/control-board/src/cli.mjs serve
node packages/webui/src/cli.mjs serve
```
Open http://127.0.0.1:7330/. Optional `--port N` and
`--board http://127.0.0.1:7331` select another port or loopback board origin.
Port 0 picks a free port. Ctrl-C stops each foreground server. No daemon,
installation, account, authentication or deployment is added.
Only HTTP loopback board origins are accepted. The WebUI binds to IPv4 loopback
by default, rejects nonlocal Host and cross-origin requests, sends no CORS
headers, refuses redirects and accepts only JSON object POST bodies up to 4096
bytes. Do not expose either unauthenticated server through a public proxy.
## Use
- Waiting on you uses the board's `waitingOnYou` flag across all projects.
- Select a project on the left to filter the session table. Counts show visible
rows out of the total when Hide offline or Hide seen hides anything.
- Select an agent to open its inspector. Arrow keys move between table agent
buttons, Enter opens, and Close or Escape in the inspector returns focus.
- Seen clears the row from Waiting on you until the board detects new activity.
The collapsed Seen section keeps those rows available; Unsee returns them.
- Reply appears only where the board's registration permits it. A failure shows
the board's stderr and keeps the draft. Transport success is the board's
`delivered` receipt, not proof that the seat processed the message.
- Refresh runs the existing board scan. Automatic refresh uses the board page's
ten-second interval; Pause stops it. Failed refreshes keep the last snapshot
with a warning. There are no automatic action retries.
- Palette and appearance use the existing brand tokens and persist in this
browser when local storage is available. No settings screen is added.
Drafts and receipts stay in page memory, including across refresh, inspector
changes and a stale registration. Reloading or closing the page loses them.
A pending send stays disabled across refresh; new text typed during a send is
not cleared by the earlier send's success. If the proxy loses the response,
delivery may be unknown: inspect the seat before sending again.
## Data and boundaries
GET `/api/board` and POST `/api/seen` and `/api/reply` proxy only the existing
board paths. POST bytes and upstream status/JSON are preserved. GET `/api/config`
returns the configured board URL for the page's error message. No scanner,
registration, session reader or transport is implemented here. No control-board,
seat, fleet, comms or root package files are changed.
Console's shared CSS, Console CSS, brand.js and local Manrope fonts were copied
unchanged from `agents/dewey/work/wui/`. Font license and source URLs accompany
the files under `src/public/assets/fonts/`. `live.css` contains the live-page
adaptations; original mockups and unrelated pending design work stay untouched.
Unsupported mockup fields and controls are listed in `docs/plans/DEFERRED.md`.
The source tree is a project filter; no invented workspace registration tree
or mockup session hierarchy is presented as live data.
## Verify
```sh
node --test packages/webui/tests/
node --test packages/control-board/tests/ packages/seat/tests/ packages/ledger/tests/ packages/mosaic/tests/
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/browser.test.mjs
```
Node's test runner and installed `/usr/bin/chromium` are required. Set `CHROMIUM`
to another installed Chromium path. No npm download is needed. The copied CDP
helper starts a separate temporary browser profile and removes it on exit.
Tests use temporary board session/registration files and stub the board's
transport. They never send to real seats or read real session logs.
Browser tests exercise the rendered real board fixture, exact counts, project
filtering, keyboard return focus, Seen/Unsee, success/failure receipts, draft and
caret preservation, pending-send exclusion, storage denial, stale registration,
loading/empty/malformed/unreachable states and hostile text. Contrast is measured
on 330 rendered samples across ten palettes and three modes. Layouts are checked
at 320, 390, 768, 1440 and 2560px. Horizontal scrolling is intentional within the
dense table; the page itself must not overflow.
No root CI workflow is configured for this package. These local tests are not a
claim of CI, deployment, live-seat delivery or user acceptance.
## User test and rollback
Gate E belongs to Jason on Monday 2026-09-14. Use Console to find who is waiting,
open a registered repo seat, reply and see its next answer after a scan. Mark a
completion Seen and find it again in Seen. Record any reason to open the board's
own page or a repo seat terminal in DEFERRED.md. Pass is Jason's end-of-day
say-so, not a test result. Keep #1507 open pending that ruling.
Rollback requires only stopping the WebUI and using the unchanged board at 7331.
Revert the scoped WebUI commit to remove it; there is no data migration to undo.
Seen and reply actions already taken belong to the board and are not rolled back.
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@mosaic/webui",
"version": "0.1.0",
"private": true,
"description": "Console WebUI for the existing local control board.",
"license": "UNLICENSED",
"type": "module",
"engines": { "node": ">=24" },
"scripts": {
"test": "node --test tests/",
"start": "node src/cli.mjs serve"
}
}
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env node
import { startServer, DEFAULT_BOARD } from './serve.mjs';
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
const options = { board: DEFAULT_BOARD, port: 7330 };
while (args.length) {
const flag = args.shift(), value = args.shift();
if (!value) throw new Error(`missing value for ${flag}`);
if (flag === '--port') {
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
options.port = Number(value);
} else if (flag === '--board') options.board = value;
else throw new Error(`unknown option: ${flag}`);
}
const server = await startServer(options);
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => server.close());
} catch (err) {
console.error(`refused: ${err.message}`);
process.exitCode = 2;
}
+147
View File
@@ -0,0 +1,147 @@
(() => {
'use strict';
const $ = id => document.getElementById(id);
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const key = r => `${r.project}/${r.agent}`;
const drafts = new Map(), receipts = new Map(), sending = new Set();
let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, timer;
let board = 'the configured board', palette = 'harbor', mode = 'light';
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
const source = value => value ? `<span class="source">${esc(labels[value] || value)}</span>` : '';
const badge = state => `<span class="badge ${ { working: 'badge-ok', waiting: 'badge-attn', error: 'badge-danger' }[state] || 'badge-muted'}">${esc(state || 'unknown')}</span>`;
const canReply = r => r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const row = id => data?.sessions.find(r => key(r) === id);
const announce = text => { $('announce').textContent = text; };
function theme() {
const p = Brand.palettes.find(p => p.id === palette) || Brand.palettes[0];
palette = p.id;
if (!['light', 'dim', 'dark'].includes(mode)) mode = 'light';
for (const [name, value] of Object.entries(Brand.tokens(p, mode))) document.documentElement.style.setProperty('--' + name, value);
document.documentElement.dataset.palette = palette; document.documentElement.dataset.mode = mode;
$('palette').value = palette; $('mode').value = mode;
try { localStorage.setItem('mosaic-console-appearance', JSON.stringify({ palette, mode })); } catch {}
}
$('palette').innerHTML = Brand.palettes.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
try { const saved = JSON.parse(localStorage.getItem('mosaic-console-appearance')); if (saved) { palette = saved.palette; mode = saved.mode; } } catch {}
theme();
$('palette').onchange = e => { palette = e.target.value; theme(); };
$('mode').onchange = e => { mode = e.target.value; theme(); };
function seenButton(r) {
if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return '';
return `<button type="button" class="btn" data-seen="${esc(key(r))}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
}
const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`;
function cards(rows) {
if (!rows.length) return '<p class="muted">Nothing here.</p>';
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}</p><p class="preview">${esc(r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(r.lastActivity || 'No activity')}</span>${seenButton(r)}</div></li>`).join('')}</ul>`;
}
function table(rows) {
if (!rows.length) return '<p class="state">No sessions match these filters.</p>';
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span></td><td>${badge(r.state)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${esc(r.lastActivity || 'unknown')}</td></tr>`).join('')}</tbody></table></div>`;
}
function registered(r) {
const reg = r.registered;
if (!reg) return 'No registration';
return `${reg.alive === false ? 'Stale; derived values shown. ' : ''}Started ${reg.startedAt || 'unknown'}; task updated ${reg.updatedAt || 'unknown'}; harness ${reg.harness || 'unknown'}; pid ${reg.pid || 'unknown'}; tmux ${reg.tmux?.session || 'unknown'}${reg.tmux?.socket ? ' on ' + reg.tmux.socket : ''}; layout ${reg.layout || 'unknown'}`;
}
function inspect() {
const r = row(selected);
$('inspector').hidden = !selected;
document.body.classList.toggle('has-inspector', !!selected);
if (!selected) return;
if (!r) { $('inspection').innerHTML = '<p>This session is no longer in the board scan. Its draft is kept until this page closes.</p>'; return; }
$('inspector-title').textContent = r.agent;
const fields = [ ['State', r.state], ['Project', r.project], ['Task', r.task || 'unknown'], ['Task source', labels[r.taskSource] || r.taskSource || 'unknown'], ['Active project', r.activeProject || 'unknown'], ['Workspace', r.workspace || 'unknown'], ['Model', [r.provider, r.model].filter(Boolean).join('/') || 'unknown'], ['Registered', registered(r)], ['Last activity', r.lastActivity || 'unknown'] ];
const receipt = receipts.get(selected);
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}<dt>Last assistant text</dt><dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>Last error</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : '<p class="muted">reply needs a registered seat</p>'}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
}
// Restore by data attribute equality, never interpolate API ids into selectors.
function focusSnapshot() {
const a = document.activeElement;
return { id: a?.id, open: a?.dataset.open, seen: a?.dataset.seen, project: a?.dataset.project, container: a?.closest('[id]')?.id, start: a?.selectionStart, end: a?.selectionEnd };
}
function restore(f) {
let el = f.id ? $(f.id) : null;
for (const attr of ['open', 'seen', 'project']) if (f[attr] !== undefined) {
el = [...document.querySelectorAll(`[data-${attr}]`)].find(e => e.dataset[attr] === f[attr] && e.closest('[id]')?.id === f.container);
}
if (el && !el.closest('[hidden]')) { el.focus({ preventScroll: true }); if (f.start != null) el.setSelectionRange(f.start, f.end); }
}
function render() {
if (!data) return;
const focus = focusSnapshot();
const projects = [...new Set(data.sessions.map(r => r.project))].sort();
if (project && !projects.includes(project)) project = null;
$('projects').innerHTML = `<ul class="tree-list"><li><button type="button" data-project="" aria-pressed="${project === null}">All projects <span class="count">${data.sessions.length}</span></button></li>${projects.map(p => `<li><button type="button" data-project="${esc(p)}" aria-pressed="${project === p}">${esc(p)} <span class="count">${data.sessions.filter(r => r.project === p).length}</span></button></li>`).join('')}</ul>`;
const waiting = data.sessions.filter(r => r.waitingOnYou);
const seen = data.sessions.filter(r => r.seen);
$('waiting-count').textContent = waiting.length; $('waiting').innerHTML = waiting.length ? cards(waiting) : '<p class="muted">Nothing is waiting on you.</p>';
$('seen-count').textContent = seen.length; $('seen').innerHTML = cards(seen);
const group = data.sessions.filter(r => !project || r.project === project);
const visible = group.filter(r => !($('hide-offline').checked && r.state === 'offline') && !($('hide-seen').checked && r.seen));
$('session-count').textContent = visible.length === group.length ? String(group.length) : `${visible.length} of ${group.length}`;
$('sessions').innerHTML = table(visible);
$('footer').textContent = Object.entries(data.counts || {}).map(([s, n]) => `${s} ${n}`).join(' · ') || 'No sessions';
$('status').textContent = `Scanned ${data.generatedAt || 'unknown'} · ${paused ? 'auto-refresh paused' : 'refresh every 10s'}`;
inspect(); restore(focus);
}
async function api(path, value) {
const res = await fetch(path, value === undefined ? { cache: 'no-store' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) });
const result = await res.json();
if (!res.ok) throw new Error(result.error || `HTTP ${res.status}`);
return result;
}
function accept(value) {
if (!value || !Array.isArray(value.sessions) || value.sessions.some(r => !r || typeof r.project !== 'string' || typeof r.agent !== 'string')) throw new Error('Invalid board session data');
data = value; $('error').hidden = true; render();
}
function error(err) { $('error').hidden = false; $('error').textContent = `${err.message} Board: ${board}. ${data ? 'Showing last known data.' : 'No board data loaded.'} Use Refresh to try again.`; }
function schedule() { clearTimeout(timer); if (!paused) timer = setTimeout(refresh, 10000); }
async function refresh() {
if (busy) return;
busy = true; $('refresh').disabled = true;
try { accept(await api('/api/board')); } catch (err) { error(err); }
finally { busy = false; $('refresh').disabled = false; schedule(); }
}
$('refresh').onclick = refresh;
$('pause').onclick = () => { paused = !paused; $('pause').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-pressed', String(paused)); schedule(); if (data) render(); };
$('hide-offline').onchange = $('hide-seen').onchange = render;
function close() { selected = null; render(); if (returnFocus) restore(returnFocus); }
$('close').onclick = close;
document.addEventListener('click', async e => {
const open = e.target.closest('[data-open]');
if (open) { returnFocus = focusSnapshot(); selected = open.dataset.open; render(); $('inspector-title').focus(); announce(`Inspecting ${row(selected)?.agent}`); return; }
const projectButton = e.target.closest('[data-project]');
if (projectButton) { project = projectButton.dataset.project || null; render(); return; }
const seen = e.target.closest('[data-seen]');
if (!seen || busy) return;
const r = row(seen.dataset.seen); if (!r) return;
busy = true; seen.disabled = true;
try { accept(await api('/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: !r.seen })); announce(`${r.agent} ${r.seen ? 'returned to waiting' : 'marked seen'}`); }
catch (err) { error(err); seen.disabled = false; }
finally { busy = false; schedule(); }
});
document.addEventListener('input', e => { if (e.target.id === 'reply') drafts.set(selected, e.target.value); });
document.addEventListener('submit', async e => {
if (!e.target.matches('.reply-form')) return;
e.preventDefault();
const id = selected, text = $('reply').value;
if (!text.trim() || sending.has(id)) return;
drafts.set(id, text); sending.add(id); render();
try {
const result = await api('/api/reply', { agent: id, text }); receipts.set(id, result);
// Do not discard text typed while the request was in flight.
if (result.delivered && drafts.get(id) === text) drafts.delete(id);
if (result.delivered && !paused) await refresh();
} catch (err) { receipts.set(id, { error: err.message + ' Delivery may be unknown; check the seat before sending again.' }); }
finally { sending.delete(id); render(); announce(receipts.get(id)?.delivered ? 'Reply delivered' : 'Reply failed; draft kept'); }
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && selected && e.target.closest('#inspector')) { e.preventDefault(); close(); return; }
if (!e.target.matches('table.sessions [data-open]')) return;
const buttons = [...document.querySelectorAll('table.sessions [data-open]')], i = buttons.indexOf(e.target);
const next = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: buttons.length - 1 }[e.key];
if (next !== undefined) { e.preventDefault(); buttons[next]?.focus(); }
});
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; }).catch(error).finally(refresh);
})();
@@ -0,0 +1,93 @@
Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,7 @@
Manrope
CSS: https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap
License: https://raw.githubusercontent.com/google/fonts/main/ofl/manrope/OFL.txt
400: https://fonts.gstatic.com/s/manrope/v20/xn7gYHE41ni1AdIRggexSg.woff2
500: https://fonts.gstatic.com/s/manrope/v20/xn7gYHE41ni1AdIRggexSg.woff2
600: https://fonts.gstatic.com/s/manrope/v20/xn7gYHE41ni1AdIRggexSg.woff2
700: https://fonts.gstatic.com/s/manrope/v20/xn7gYHE41ni1AdIRggexSg.woff2
+77
View File
@@ -0,0 +1,77 @@
/* Original brand studies. This file contains prototype design data, not an API. */
(function (root) {
const palettes = [
{id:'harbor', name:'Harbor', hue:214, sat:70, accent:34, accentSat:63, theory:'Complementary', note:'Clear blue with a restrained copper counterpoint. The warm accent balances cool navigation and gives selected details a second voice.', use:'Proposed default. Blue without the blue-purple gradient.'},
{id:'carmine', name:'Carmine', hue:350, sat:65, accent:176, accentSat:42, theory:'Near-complementary', note:'A red-led identity with a cool turquoise counterpoint. Rose-tinted neutrals keep the red from covering every surface.', use:'For a red preference. Errors still use an icon and explicit wording.'},
{id:'atlantic', name:'Atlantic', hue:199, sat:72, accent:170, accentSat:52, theory:'Analogous', note:'Ocean blue and teal sit close on the hue wheel. Their shared cool bias makes a quieter combination than an opposing accent.', use:'A cohesive blue-green alternative with low visual rivalry.'},
{id:'terracotta', name:'Terracotta', hue:18, sat:60, accent:198, accentSat:44, theory:'Complementary', note:'Fired-clay orange meets a desaturated steel blue. Warm neutral surfaces connect the palette without needing a beige wash everywhere.', use:'An earthier option for writing and personal work.'},
{id:'aubergine', name:'Aubergine', hue:287, sat:38, accent:47, accentSat:46, theory:'Triadic pair', note:'Muted plum and ochre sit 120 degrees apart on the hue wheel. The third triadic hue, teal, is left out so the interface has two brand accents rather than three.', use:'A deliberate purple option for users who want it, without the AI gradient.'},
{id:'mineral', name:'Mineral', hue:171, sat:47, accent:351, accentSat:42, theory:'Complementary', note:'Deep teal and dusty rose oppose each other while keeping saturation restrained. Blue-green tinted layers carry the identity in dark mode.', use:'Cool surfaces with a small warm punctuation.'},
{id:'cobalt', name:'Cobalt', hue:229, sat:75, accent:49, accentSat:63, theory:'Complementary', note:'A stronger royal blue meets a small amber accent. The high hue separation is controlled by using amber only in secondary details.', use:'The most assertive blue option. No large yellow surfaces.'},
{id:'rosewood', name:'Rosewood', hue:329, sat:47, accent:209, accentSat:39, theory:'Triadic pair', note:'Dusty pink and steel blue occupy two points of a triadic relationship. The third, yellow-green, is omitted to avoid an overly colorful interface.', use:'A softer warm identity that does not become pastel text.'},
{id:'graphite', name:'Graphite', hue:216, sat:9, accent:216, accentSat:12, theory:'Monochromatic', note:'One blue-gray hue uses value and saturation changes instead of a second brand hue. Semantic status colors remain independent.', use:'Minimal chroma for content-heavy work. Hierarchy comes from contrast.'},
{id:'grove', name:'Grove', hue:146, sat:37, accent:356, accentSat:39, theory:'Split-complementary pair', note:'Muted forest green meets dusty rose, 30 degrees to one side of its magenta complement. The second split accent is omitted. Low-saturation surfaces keep green from dominating.', use:'An optional green palette for other preferences, not the proposed default.'},
];
const fonts = [
{id:'dm', name:'DM Sans', family:'"DM Sans", system-ui, sans-serif', note:'Open, rounded forms without becoming playful. My first choice for a product that spans everyday work and professional tools.', detail:'Recommended balance'},
{id:'plex', name:'IBM Plex Sans', family:'"IBM Plex Sans", system-ui, sans-serif', note:'More engineered letterforms and a distinctive rhythm. Strong for dense labels and data; its technical character is more noticeable.', detail:'Precise and structured'},
{id:'manrope', name:'Manrope', family:'Manrope, system-ui, sans-serif', note:'Broad geometric forms give the wordmark more presence. Compare long control labels carefully because the wider forms use more space.', detail:'Geometric and expressive'},
];
const logos = [
{id:'mosaic', name:'Assembly', family:'An assembled M', note:'Four solid pieces form an M with an open center. The gaps suggest independent tools working together without using a puzzle-piece cliché.', tradeoff:'Most direct link to Mosaic. At 16 px, the two-unit gaps become fine seams.', paths:'<path d="M4 4h10v24H4zM18 4l12 12-7 7-5-5zM34 4v14l-5 5-7-7zM38 4h10v24H38z" transform="translate(2 10)"/>', recommended:true},
{id:'weave', name:'Relay', family:'An interlocking ribbon', note:'Two angular links cross to make a compact woven loop. The open counters suggest handoffs between people, agents, and tools.', tradeoff:'Strong standalone symbol, but less obviously an M. Check for resemblance to existing link marks before final adoption.', paths:'<path fill-rule="evenodd" d="M4 18 18 4h12l10 10-8 8-8-8h-2L14 22v2l8 8-8 8L4 30zm48 8L38 40H26L16 30l8-8 8 8h2l8-8v-2l-8-8 8-8 10 10z" transform="translate(0 6)"/>'},
{id:'fold', name:'Aperture', family:'A cut-paper tile', note:'An asymmetric square folds around an open center. A single diagonal cut gives the mark direction without an arrow, robot, or sparkle.', tradeoff:'The most abstract and artistic candidate. Its relationship to Mosaic will rely on repeated use with the wordmark.', paths:'<path fill-rule="evenodd" d="M4 4h30v10H14v28H4zm34 0 14 14v34H18V18h16v10h-6v14h14V22l-4-4z"/>'},
];
const icons = {
home:'<path d="m3 10 9-7 9 7v10H3zM9 20v-7h6v7"/>',
conversation:'<path d="M4 4h16v12H9l-5 4zM8 8h8M8 12h5"/>',
projects:'<path d="M3 6h7l2 3h9v11H3zM3 6V4h7l2 2h7v3"/>',
agents:'<rect x="5" y="7" width="14" height="13" rx="3"/><path d="M12 3v4M9 12h.01M15 12h.01M9 16h6M2 11v5M22 11v5"/>',
people:'<circle cx="9" cy="7" r="3"/><path d="M3 21v-3a6 6 0 0 1 12 0v3M16 4a3 3 0 0 1 0 6M18 14a5 5 0 0 1 3 4v3"/>',
board:'<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18M15 3v18M6 7v4M12 7v7M18 7v2"/>',
extensions:'<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 17.5h7M17.5 14v7"/>',
search:'<circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/>',
check:'<path d="m5 12 4 4L19 6"/>',
warning:'<path d="m12 3 10 18H2zM12 9v5M12 17h.01"/>',
error:'<circle cx="12" cy="12" r="9"/><path d="m9 9 6 6m0-6-6 6"/>',
plus:'<path d="M12 4v16M4 12h16"/>',
arrow:'<path d="M4 12h16m-6-6 6 6-6 6"/>',
sun:'<circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1"/>',
dim:'<circle cx="12" cy="12" r="9"/><path d="M12 3v18M12 7h6M12 12h9M12 17h6"/>',
moon:'<path d="M20 14A9 9 0 0 1 10 3a9 9 0 1 0 10 11z"/>',
};
function hsl(h,s,l) {
s/=100; l/=100;
const c=(1-Math.abs(2*l-1))*s, x=c*(1-Math.abs(h/60%2-1)), m=l-c/2;
const rgb=h<60?[c,x,0]:h<120?[x,c,0]:h<180?[0,c,x]:h<240?[0,x,c]:h<300?[x,0,c]:[c,0,x];
return '#'+rgb.map(v=>Math.round((v+m)*255).toString(16).padStart(2,'0')).join('');
}
function luminance(hex) {
const a=hex.slice(1).match(/../g).map(x=>parseInt(x,16)/255).map(v=>v<=0.04045?v/12.92:((v+0.055)/1.055)**2.4);
return a[0]*.2126+a[1]*.7152+a[2]*.0722;
}
function contrast(a,b) { const x=luminance(a), y=luminance(b);return (Math.max(x,y)+.05)/(Math.min(x,y)+.05); }
function foreground(h,s,start, backgrounds, minimum, light) {
for(let l=start; l>=0 && l<=100; l+=light?-1:1) {
const c=hsl(h,s,l);
if(backgrounds.every(bg=>contrast(c,bg)>=minimum)) return c;
}
throw new Error('No contrast-compliant color');
}
function tokens(p, mode) {
const light=mode==='light', dim=mode==='dim', s=Math.min(p.sat*.3,22);
const canvas=hsl(p.hue,s,light?96:dim?18:9);
const surface=light?'#ffffff':hsl(p.hue,s,dim?23:14);
const raised=hsl(p.hue,s,light?92:dim?28:20);
const backgrounds=[canvas,surface,raised];
const color=(h,s,min=4.5)=>foreground(h,s,light?44:68,backgrounds,min,light);
const action=color(p.hue,p.sat);
const onAction=contrast('#ffffff',action)>=4.5?'#ffffff':'#10141a';
return {canvas,surface,raised,text:color(p.hue,15,7),muted:color(p.hue,12),
line:hsl(p.hue,s,light?84:dim?35:28),border:color(p.hue,12,3),action,onAction,
accent:color(p.accent,p.accentSat),focus:action,success:color(157,49),warning:color(34,70),danger:color(3,66)};
}
function logo(id, size=56) {const l=logos.find(l=>l.id===id)||logos[0];return `<svg class="logo" width="${size}" height="${size}" viewBox="0 0 56 56" fill="currentColor" aria-hidden="true">${l.paths}</svg>`;}
function icon(id) {return `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[id]||icons.home}</svg>`;}
root.Brand = {palettes,fonts,logos,icons,tokens,contrast,logo,icon};
})(typeof window === 'undefined' ? globalThis : window);
+44
View File
@@ -0,0 +1,44 @@
/* Design 3: Console. Command bar on top, project tree on the left, dense table, inspector drawer on the right. */
html{font-size:15px}
.cmdbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:10px;padding:8px var(--pad);background:var(--surface);border-bottom:1px solid var(--line);flex-wrap:wrap}
.cmd-open{font:inherit;display:flex;align-items:center;gap:8px;flex:1 1 220px;max-width:480px;min-height:38px;padding:0 10px;border:1px solid var(--border);border-radius:var(--r);background:var(--canvas);color:var(--muted);cursor:text;text-align:left}
.cmd-open kbd{margin-left:auto;font-family:var(--mono);font-size:.72rem;border:1px solid var(--border);border-radius:4px;padding:1px 5px;color:var(--muted)}
.cmd-nav{min-width:0;max-width:100%}
.cmd-nav ul{list-style:none;margin:0;padding:0;display:flex;gap:2px;overflow-x:auto;position:relative}
.cmd-link{display:inline-flex;align-items:center;gap:6px;padding:7px 9px;border-radius:var(--r);color:var(--muted);text-decoration:none;font-size:.88rem;font-weight:500;white-space:nowrap;min-height:38px}
.cmd-link[aria-current=page]{color:var(--action);background:color-mix(in srgb,var(--action) 12%,var(--surface))}
.cmd-right{margin-left:auto;display:flex;align-items:center;gap:8px}
.cmd-status{display:inline-flex;align-items:center;gap:4px;color:var(--warning);font-weight:700;font-size:.9rem}
.frame{display:grid;grid-template-columns:240px minmax(0,1fr);min-height:calc(100vh - 56px)}
body.has-inspector .frame{grid-template-columns:240px minmax(0,1fr) 340px}
.tree{border-right:1px solid var(--line);background:var(--surface);padding:12px;position:sticky;top:56px;height:calc(100vh - 56px);overflow:auto;display:flex;flex-direction:column;gap:10px}
.tree-title{font-size:.78rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:0}
.tree-list,.tree-list ul{list-style:none;margin:0;padding:0}.tree-list ul{padding-left:22px;display:grid;gap:2px;margin:2px 0 8px}
.tree summary{display:flex;align-items:center;gap:6px;cursor:pointer;padding:5px 6px;border-radius:6px;font-weight:600;list-style:none}.tree summary::-webkit-details-marker{display:none}
.tree summary a{color:var(--text);text-decoration:none}.tree summary a[aria-current=page]{color:var(--action)}
.tree-ws{display:flex;align-items:center;gap:4px;padding:4px 6px;border-radius:6px;color:var(--text);text-decoration:none;font-size:.9rem}.tree-ws:hover{background:var(--raised)}
.tree li.retired .tree-ws{color:var(--muted);text-decoration:line-through}
.tree .btn{margin-top:auto;justify-content:center}
.content{padding:14px var(--pad);min-width:0}
.console-waiting{margin-bottom:14px}.console-waiting .waiting-row{grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr))}
.console-activity{margin-top:14px}
table.sessions tbody tr{cursor:default}table.sessions tbody tr:focus-visible{outline:3px solid var(--focus);outline-offset:-3px}
.inspector{border-left:1px solid var(--line);background:var(--surface);padding:14px;position:sticky;top:56px;height:calc(100vh - 56px);overflow:auto}
.inspector-head{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:10px}.inspector-head h2{margin:0}
.cmd{border:1px solid var(--line);border-radius:var(--r-lg);padding:0;width:min(92vw,600px);background:var(--surface);color:var(--text);box-shadow:var(--shadow);margin:8vh auto}
.cmd::backdrop{background:rgb(0 0 0/.4)}
.cmd-form{display:grid;gap:8px;padding:12px}
#cmd-input{font-size:1.05rem}
#cmd-list{list-style:none;margin:0;padding:0;display:grid;gap:2px;max-height:50vh;overflow:auto}
#cmd-list li{padding:8px 10px;border-radius:var(--r);cursor:pointer}#cmd-list li[aria-selected=true]{background:color-mix(in srgb,var(--action) 14%,var(--surface));color:var(--action)}
@media (min-width:1900px){.frame{grid-template-columns:280px minmax(0,1fr)}body.has-inspector .frame{grid-template-columns:280px minmax(0,1fr) 420px}.content{padding:20px 32px}}
@media (min-width:2560px){.frame{grid-template-columns:320px minmax(0,1fr)}body.has-inspector .frame{grid-template-columns:320px minmax(0,1fr) 480px}.content{padding:24px 48px}html{font-size:16px}}
@media (max-width:1599px){.cmd-label{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.cmd-link{padding:7px 9px}}
@media (max-width:959px){
.frame,body.has-inspector .frame{grid-template-columns:minmax(0,1fr)}
.tree{position:static;height:auto;border-right:0;border-bottom:1px solid var(--line);flex-direction:row;flex-wrap:wrap;align-items:center}
.tree-title{flex:1}.tree .btn{margin:0;order:1}.tree-list{order:2;flex-basis:100%;display:flex;gap:6px;flex-wrap:nowrap;overflow-x:auto;padding-bottom:4px}.tree-list ul{display:none}.tree summary{white-space:nowrap;border:1px solid var(--border);border-radius:99px;padding:5px 12px}.tree .prototype-note{order:3;flex-basis:100%;margin:0;border:0;padding:0}
.inspector{position:fixed;inset:auto 0 0 0;height:auto;max-height:70vh;border-left:0;border-top:1px solid var(--line);border-radius:var(--r-lg) var(--r-lg) 0 0;z-index:35;box-shadow:var(--shadow)}
}
@media (max-width:699px){.cmdbar .wordmark small{display:none}.cmd-nav{order:3;flex-basis:100%;min-width:0;max-width:100%}.cmd-open span:not(.ic){display:none}.cmd-open{flex:0 0 auto;min-width:44px;justify-content:center}.content{padding:12px}}
@media (max-width:479px){#demo-console{display:none}}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en" data-design="console" data-palette="harbor" data-mode="light">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css">
<script src="/brand.js" defer></script><script src="/app.js" defer></script></head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="cmdbar"><a href="/" class="wordmark"><i>M</i>Mosaic<small>Console</small></a>
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option>light</option><option>dim</option><option>dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button></div></header>
<div class="frame">
<nav class="tree" aria-label="Projects"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></nav>
<main id="main" class="content" tabindex="-1"><div class="page-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div></div>
<div id="error" class="state state-error" role="alert" hidden></div>
<section class="console-waiting" aria-labelledby="waiting-title"><h2 id="waiting-title">Waiting on you <span id="waiting-count" class="count">0</span></h2><div id="waiting"><p class="muted">Loading sessions…</p></div></section>
<details id="seen-section"><summary>Seen <span id="seen-count" class="count">0</span></summary><div id="seen"></div></details>
<section aria-labelledby="sessions-title"><div class="page-head"><h2 id="sessions-title">All sessions <span id="session-count" class="count">0</span></h2><div class="filters"><label><input id="hide-offline" type="checkbox" checked>Hide offline</label><label><input id="hide-seen" type="checkbox" checked>Hide seen</label></div></div><p class="small muted">Select an agent to inspect. Arrow keys move between agents; Enter opens the inspector. Scroll the table for all columns.</p><div id="sessions"></div></section>
</main>
<aside class="inspector" id="inspector" aria-labelledby="inspector-title" hidden><div class="inspector-head"><h2 id="inspector-title" tabindex="-1">Inspector</h2><button type="button" class="btn" id="close">Close</button></div><div id="inspection"></div></aside>
</div><footer class="foot"><span id="footer">Mosaic Stack</span><span id="board-url"></span></footer><div id="announce" class="sr-only" aria-live="polite"></div>
</body></html>
+11
View File
@@ -0,0 +1,11 @@
/* Live board adaptations. The Console and shared design styles remain unchanged. */
[hidden]{display:none!important}
.cmd-right{flex-wrap:wrap;max-width:100%}.cmd-right label{font-size:.75rem}.cmd-right select{width:auto;max-width:140px;min-height:36px;padding:4px 8px}
.tree #projects{min-width:0}.tree-list{display:grid;gap:4px}.tree-list button{font:inherit;text-align:left;cursor:pointer;border:1px solid transparent;background:none;color:var(--text);border-radius:6px;padding:6px;width:100%;overflow-wrap:anywhere}.tree-list button[aria-pressed=true]{background:var(--raised);border-color:var(--border)}
.tree .small{margin-top:auto}.filters{display:flex;gap:12px;flex-wrap:wrap}.filters label{display:flex;align-items:center;gap:5px}
section{margin-bottom:20px}#seen-section{margin-bottom:20px}summary{cursor:pointer}#seen{margin-top:10px}
.table-wrap{background:var(--surface);border:1px solid var(--line);border-radius:var(--r)}table.sessions{min-width:1050px}table.sessions td{max-width:270px;overflow-wrap:anywhere}table.sessions .task{min-width:190px}.source{display:block;color:var(--muted);font-size:.75rem}.session-open{font:inherit;font-weight:600;background:none;border:0;color:var(--action);padding:3px;cursor:pointer;text-align:left;overflow-wrap:anywhere}
.waiting{grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr))}.wait-item .session-open{font-size:1rem}.wait-item p{margin-top:6px}.wait-item .actions{justify-content:space-between}.wait-item .preview{white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.inspector .kv{grid-template-columns:1fr}.inspector .kv dd{margin-bottom:10px}.last-text,.receipt{white-space:pre-wrap;overflow-wrap:anywhere}.last-text{font:inherit;font-size:.9rem;margin:0}.receipt{font-size:.85rem;margin-top:8px}.receipt.failed{color:var(--danger)}.reply-form{display:grid;gap:8px;margin-top:14px}.reply-form .btn{justify-self:start}.inspector-head{flex-wrap:wrap}#inspection{min-width:0}.badge{white-space:nowrap}.foot>*{min-width:0;overflow-wrap:anywhere}
@media(max-width:959px){.tree #projects{flex-basis:100%;width:100%}.tree-list{display:flex;flex-wrap:wrap}.tree-list li{max-width:100%}.tree .small{flex-basis:100%;margin:0}.inspector{max-height:75vh}.cmdbar{position:static}}
@media(max-width:479px){.cmd-right{margin-left:0;gap:6px}.cmd-right label{flex:1 1 110px}.cmd-right select{max-width:100%;width:100%}.kv{grid-template-columns:1fr}.content{padding:12px}.tree{padding:12px}.page-head>h2{flex-basis:100%}}
+216
View File
@@ -0,0 +1,216 @@
/* Shared base for the five Mosaic Stack WUI mockups. Design pages add their own shell rules. */
@font-face{font-family:Manrope;font-weight:400;font-display:swap;src:url(../../assets/fonts/manrope-400.woff2) format("woff2")}
@font-face{font-family:Manrope;font-weight:500;font-display:swap;src:url(../../assets/fonts/manrope-500.woff2) format("woff2")}
@font-face{font-family:Manrope;font-weight:600;font-display:swap;src:url(../../assets/fonts/manrope-600.woff2) format("woff2")}
@font-face{font-family:Manrope;font-weight:700;font-display:swap;src:url(../../assets/fonts/manrope-700.woff2) format("woff2")}
:root{
--canvas:#f4f6f8;--surface:#fff;--raised:#e9edf1;--text:#16202a;--muted:#5a6774;--line:#e2e7ec;--border:#b9c3cc;--action:#1f5f8b;--onAction:#fff;--accent:#c96a2b;--focus:#1f5f8b;--success:#2e7d4f;--warning:#b26a00;--danger:#b3261e;
--font:Manrope,system-ui,sans-serif;--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--r:8px;--r-lg:14px;--gap:12px;--pad:16px;--shadow:0 1px 2px rgb(0 0 0/.06),0 6px 20px rgb(0 0 0/.05);
--nav-w:232px;--nav-w-collapsed:64px;--content-max:none;
}
html[data-mode=dark],html[data-mode=dim]{--shadow:0 1px 2px rgb(0 0 0/.35),0 6px 20px rgb(0 0 0/.3)}
*,*::before,*::after{box-sizing:border-box}
html{font-family:var(--font);font-size:16px;line-height:1.45;color:var(--text);background:var(--canvas);-webkit-text-size-adjust:100%}
body{margin:0;min-height:100vh;min-width:0;overflow-x:hidden}
h1,h2,h3,h4{margin:0 0 .35em;line-height:1.2;font-weight:600;letter-spacing:-.01em;overflow-wrap:anywhere}
h1{font-size:clamp(1.35rem,1.1rem + 1vw,1.85rem)}h2{font-size:1.05rem}h3{font-size:.95rem}
p{margin:0 0 .6em;overflow-wrap:anywhere}p:last-child{margin-bottom:0}
a{color:var(--action);text-decoration-thickness:1px;text-underline-offset:2px}a:hover{text-decoration-thickness:2px}
code{font-family:var(--mono);font-size:.85em;background:var(--raised);padding:.05em .3em;border-radius:4px;overflow-wrap:anywhere}
:focus-visible{outline:3px solid var(--focus);outline-offset:2px;border-radius:4px}
.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.skip{position:absolute;left:8px;top:-60px;z-index:50;background:var(--action);color:var(--onAction);padding:8px 12px;border-radius:var(--r)}
.skip:focus{top:8px}
.muted{color:var(--muted)}.small{font-size:.85rem}.sep{color:var(--muted);margin:0 .15em}
#main{outline:none;min-width:0}
#main:focus-visible{outline:none}
/* Icons, wordmark, avatars */
.ic{display:inline-flex;width:20px;height:20px;flex:none;vertical-align:-4px}.ic svg{width:100%;height:100%;fill:none;stroke:currentColor;stroke-width:1.75;stroke-linecap:round;stroke-linejoin:round}
.ic-label{margin-left:6px}
.wordmark{font-weight:700;letter-spacing:-.02em;font-size:1.05rem;color:var(--text);text-decoration:none;display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap}
.wordmark i{width:22px;height:22px;border-radius:6px;background:var(--action);display:inline-grid;place-items:center;color:var(--onAction);font-style:normal;font-size:.8rem;font-weight:700;flex:none}
.wordmark small{font-weight:500;color:var(--muted);font-size:.7rem;border:1px solid var(--border);border-radius:99px;padding:1px 7px}
.avatar{display:inline-grid;place-items:center;width:28px;height:28px;border-radius:50%;background:var(--raised);color:var(--text);font-size:.72rem;font-weight:700;text-transform:uppercase;margin-right:8px;vertical-align:middle;flex:none;border:1px solid var(--line)}
[data-agent=darkwing].avatar{background:color-mix(in srgb,var(--action) 22%,var(--surface))}[data-agent=velma].avatar{background:color-mix(in srgb,var(--accent) 25%,var(--surface))}[data-agent=rocko].avatar{background:color-mix(in srgb,var(--success) 22%,var(--surface))}
/* Badges, chips, feature tags */
.badge{display:inline-flex;align-items:center;gap:6px;font-size:.78rem;font-weight:600;padding:2px 9px;border-radius:99px;border:1px solid transparent;line-height:1.4}
.badge::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}
.badge-ok{color:var(--success);background:color-mix(in srgb,var(--success) 8%,var(--surface));border-color:color-mix(in srgb,var(--success) 35%,transparent)}
.badge-attn{color:var(--warning);background:color-mix(in srgb,var(--warning) 8%,var(--surface));border-color:color-mix(in srgb,var(--warning) 40%,transparent)}
.badge-danger{color:var(--danger);background:color-mix(in srgb,var(--danger) 8%,var(--surface));border-color:color-mix(in srgb,var(--danger) 35%,transparent)}
.badge-muted{color:var(--muted);background:var(--raised);border-color:var(--line)}
.badge[data-state=running]::before{animation:pulse 1.6s ease-in-out infinite}
@keyframes pulse{50%{opacity:.35}}
.feat{font-size:.68rem;font-weight:600;letter-spacing:.02em;padding:1px 6px;border-radius:4px;border:1px dashed var(--border);color:var(--muted);vertical-align:middle;margin-left:4px}
.feat-implemented{border-style:solid;color:var(--success)}.feat-planned{color:var(--action)}.feat-proposed{color:var(--accent)}.feat-mockup{color:var(--muted)}
.chips{display:flex;gap:6px;flex-wrap:wrap;margin:0 0 var(--gap)}
.chip{font-size:.85rem;padding:5px 12px;border-radius:99px;border:1px solid var(--border);background:var(--surface);color:var(--text);text-decoration:none;min-height:32px;display:inline-flex;align-items:center}
.chip[aria-current=true]{background:var(--action);color:var(--onAction);border-color:var(--action)}
.count{font-size:.8rem;font-weight:600;color:var(--muted);background:var(--raised);border-radius:99px;padding:1px 8px;margin-left:4px;vertical-align:middle}
.pill,.pills li{display:inline-flex;align-items:center;gap:4px;font-size:.85rem;padding:3px 10px;border-radius:99px;background:var(--raised);margin:0 4px 4px 0}
.pills{list-style:none;margin:0 0 8px;padding:0;display:flex;flex-wrap:wrap}.pills li.retired{opacity:.7;text-decoration:line-through}
.cfg{font-size:.8rem;font-weight:600}.cfg-matching{color:var(--success)}.cfg-changed{color:var(--warning)}.cfg-unknown{color:var(--muted)}
.gen{font-size:.75rem;color:var(--muted);margin-left:6px}
/* Buttons */
.btn{font:inherit;font-weight:600;font-size:.9rem;min-height:36px;padding:6px 14px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface);color:var(--text);cursor:pointer;display:inline-flex;align-items:center;gap:6px;text-decoration:none;line-height:1.2}
.btn:hover{background:var(--raised)}
.btn.primary{background:var(--action);color:var(--onAction);border-color:var(--action)}.btn.primary:hover{filter:brightness(1.08)}
.btn:disabled,.btn[aria-disabled=true]{opacity:.55;cursor:not-allowed}
.btn.link,button.link{background:none;border:0;padding:0;color:var(--action);text-decoration:underline;font:inherit;cursor:pointer;min-height:0}
.actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:8px}
td.actions{display:table-cell}td.actions .btn{margin:2px 4px 2px 0}
/* Layout primitives */
.page-head{display:flex;flex-wrap:wrap;gap:8px 16px;align-items:flex-start;justify-content:space-between;margin:0 0 var(--pad)}
.page-head>div:first-child,.page-head>h1{flex:1 1 320px;min-width:0}
.page-head .actions{margin:0}
.session-head{align-items:center}.session-head .avatar{width:40px;height:40px;font-size:.95rem}
.crumbs{font-size:.85rem;margin-bottom:8px;color:var(--muted);display:flex;flex-wrap:wrap;gap:0 6px;min-width:0}.crumbs>*{min-width:0;overflow-wrap:anywhere}.crumbs a{color:var(--muted)}
.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);padding:var(--pad);min-width:0;box-shadow:var(--shadow)}
.panel>h2{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.home-grid{display:grid;gap:var(--gap);grid-template-columns:1fr}
.two-col{display:grid;gap:var(--gap);grid-template-columns:1fr}
.session-grid{display:grid;gap:var(--gap);grid-template-columns:1fr}
.session-side{display:grid;gap:var(--gap);align-content:start}
.settings-grid{display:grid;gap:var(--gap);grid-template-columns:1fr}
.split{display:grid;gap:var(--gap);grid-template-columns:1fr}
.card-grid{display:grid;gap:var(--gap);grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))}
.form-grid{display:grid;gap:var(--gap);grid-template-columns:repeat(auto-fit,minmax(min(100%,200px),1fr))}
@media (min-width:768px){
.home-grid{grid-template-columns:1fr 1fr}.home-grid .panel-waiting{grid-column:1/-1}
.two-col{grid-template-columns:1fr 1fr}.settings-grid{grid-template-columns:1fr 1fr}
.split{grid-template-columns:minmax(240px,1fr) 2fr}
}
@media (min-width:1200px){
.home-grid{grid-template-columns:minmax(0,3fr) minmax(300px,1fr)}
.home-grid .panel-waiting{grid-column:1}.home-grid .panel-side{grid-row:1/4;grid-column:2}
.session-grid{grid-template-columns:minmax(0,3fr) minmax(300px,1.4fr)}
}
@media (min-width:1900px){
.home-grid{grid-template-columns:minmax(360px,2fr) minmax(0,4fr) minmax(320px,1.5fr)}
.home-grid .panel-waiting{grid-column:1;grid-row:1/3}.home-grid .panel-side{grid-column:3;grid-row:1/3}
.session-grid{grid-template-columns:minmax(0,2fr) minmax(320px,1fr) minmax(320px,1fr)}
.session-side{display:contents}
.settings-grid{grid-template-columns:repeat(3,1fr)}
}
@media (min-width:2560px){
.home-grid{grid-template-columns:minmax(400px,2fr) minmax(0,5fr) minmax(360px,1.5fr)}
html{font-size:17px}
.card-grid{grid-template-columns:repeat(auto-fill,minmax(340px,1fr))}
}
/* Waiting list */
.waiting{list-style:none;margin:0;padding:0;display:grid;gap:10px}
.waiting-row{grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr))}
.wait-item{border:1px solid var(--line);border-left:4px solid var(--warning);border-radius:var(--r);padding:12px 14px;background:color-mix(in srgb,var(--warning) 5%,var(--surface))}
.wait-blocked,.wait-stalled{border-left-color:var(--danger);background:color-mix(in srgb,var(--danger) 5%,var(--surface))}
.wait-config{border-left-color:var(--muted);background:var(--surface)}
.wait-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.wait-head h3{margin:0;flex:1 1 200px;font-size:1rem}.wait-head h3 a{color:var(--text)}
.wait-since{font-size:.8rem;color:var(--muted);white-space:nowrap}
.wait-scope{font-size:.85rem;margin:4px 0}.wait-detail{font-size:.9rem;color:var(--text)}
.scope b{font-weight:600}
.notice{border-radius:var(--r-lg);padding:var(--pad);margin-bottom:var(--gap);border:1px solid var(--line)}
.notice-attn{border-color:color-mix(in srgb,var(--warning) 50%,transparent);background:color-mix(in srgb,var(--warning) 8%,var(--surface))}
/* Tables */
.table-wrap{overflow-x:auto;max-width:100%;-webkit-overflow-scrolling:touch;position:relative}
table{width:100%;border-collapse:collapse;font-size:.9rem}
th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{font-size:.78rem;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);font-weight:600;white-space:nowrap}
tr.is-attn td{background:color-mix(in srgb,var(--warning) 6%,transparent)}
tbody tr:hover td{background:color-mix(in srgb,var(--action) 5%,transparent)}
tbody tr[aria-selected=true] td{background:color-mix(in srgb,var(--action) 12%,transparent)}
.col-agent{white-space:nowrap}.col-agent a{font-weight:600;color:var(--text)}
.col-goal{min-width:200px}.goal-text{display:block;font-weight:500}.goal-report{display:block;font-size:.82rem;color:var(--muted)}
.col-since,.col-ctl{white-space:nowrap}
.kv{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:0;font-size:.9rem}.kv dt{color:var(--muted);font-weight:500}.kv dd{margin:0;min-width:0;overflow-wrap:anywhere}
.plain{list-style:none;margin:0;padding:0;display:grid;gap:8px}.plain>li{min-width:0}
/* Cards */
.session-card,.project-card,.agent-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);padding:14px;display:flex;flex-direction:column;gap:8px;min-width:0;box-shadow:var(--shadow)}
.session-card.is-attn{border-color:color-mix(in srgb,var(--warning) 55%,transparent);box-shadow:0 0 0 2px color-mix(in srgb,var(--warning) 20%,transparent)}
.session-card header,.agent-card header{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.session-card header>div,.agent-card header>div{flex:1 1 120px;min-width:0}.session-card header .badge{margin-left:auto}
.session-card h3,.agent-card h2,.project-card h2{margin:0;font-size:1rem}.session-card h3 a{color:var(--text);text-decoration:none}.session-card h3 a:hover{text-decoration:underline}
.scope-line{font-size:.82rem;color:var(--muted);margin:0}
.session-card footer,.project-card footer{display:flex;justify-content:space-between;gap:8px;font-size:.8rem;color:var(--muted);margin-top:auto;flex-wrap:wrap}
.project-card header{display:flex;justify-content:space-between;align-items:center;gap:8px}.project-card h3{font-size:.78rem;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:6px 0 4px}
/* Columns / kanban */
.columns{display:grid;gap:var(--gap);grid-auto-flow:column;grid-auto-columns:minmax(260px,1fr);overflow-x:auto;padding-bottom:6px;align-items:start;scroll-snap-type:x proximity}
.column{background:var(--raised);border-radius:var(--r-lg);padding:10px;display:grid;gap:8px;min-width:0;grid-template-columns:minmax(0,1fr);scroll-snap-align:start;align-content:start}
.column>h2{font-size:.9rem;margin:2px 4px 4px;display:flex;align-items:center;flex-wrap:wrap;min-width:0}.column>*{min-width:0}
.column-empty{color:var(--muted);font-size:.85rem;padding:8px 4px;margin:0}
.column-attn{outline:2px solid color-mix(in srgb,var(--warning) 45%,transparent)}
.task-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:8px 10px}
.task-card summary{cursor:pointer;display:flex;flex-wrap:wrap;justify-content:space-between;gap:4px 8px;align-items:center;font-weight:500;list-style:none}.task-card summary>span{min-width:0;overflow-wrap:anywhere}.task-card .task-title{flex:1 1 140px}
.task-card summary::-webkit-details-marker{display:none}.task-card summary::before{content:"▸";color:var(--muted);flex:none}.task-card[open] summary::before{content:"▾"}
.task-title{flex:1;min-width:0}.task-meta{font-size:.8rem;color:var(--muted);display:inline-flex;align-items:center}
.task-card .kv{margin-top:8px;font-size:.85rem}
@media (max-width:699px){.columns{grid-auto-flow:row;grid-auto-columns:auto;overflow:visible}}
/* Streams */
.stream{list-style:none;margin:0;padding:0;display:grid;gap:10px}
.event{display:flex;gap:10px;align-items:flex-start}.event .ic{margin-top:2px;color:var(--muted)}.event p{margin:0;font-size:.9rem}.event time{font-size:.78rem;color:var(--muted)}
.event-waiting .ic,.event-blocked .ic{color:var(--warning)}.event-stalled .ic,.event-stop .ic{color:var(--danger)}.event-settled .ic{color:var(--success)}
/* Transcript and forms */
.turns{list-style:none;margin:0 0 var(--gap);padding:0;display:grid;gap:10px}
.turn{border-radius:var(--r-lg);padding:10px 14px;background:var(--raised);max-width:min(100%,72ch)}.turn-you{background:color-mix(in srgb,var(--action) 12%,var(--surface));margin-left:auto}
.turn .who{display:block;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin-bottom:2px}
.turn .tool{font-size:.8rem;color:var(--muted);font-family:var(--mono);display:flex;gap:6px;align-items:center;margin-top:6px}
.composer,.launch-form,.quick-launch{display:grid;gap:8px}
label{font-size:.85rem;font-weight:500;color:var(--text);display:grid;gap:4px}label.radio{grid-template-columns:auto 1fr;align-items:center;gap:8px;font-weight:400}
input[type=text],textarea,select{font:inherit;color:var(--text);background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:8px 10px;min-height:40px;width:100%;max-width:100%}
textarea{resize:vertical}
fieldset{border:1px solid var(--line);border-radius:var(--r);padding:8px 12px;margin:0;display:grid;gap:6px;min-width:0}legend{font-size:.82rem;font-weight:600;color:var(--muted);padding:0 4px}
.form-result{display:block;font-size:.9rem}.form-result:empty{display:none}
.result{display:block;padding:8px 10px;border-radius:var(--r);border:1px solid}
.result-ok{color:var(--success);border-color:color-mix(in srgb,var(--success) 40%,transparent);background:color-mix(in srgb,var(--success) 8%,var(--surface))}
.result-attn{color:var(--text);border-color:color-mix(in srgb,var(--warning) 50%,transparent);background:color-mix(in srgb,var(--warning) 10%,var(--surface))}
.result-danger{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 40%,transparent);background:color-mix(in srgb,var(--danger) 8%,var(--surface))}
.quick-launch h2{margin:0}.quick-launch .muted{margin-bottom:4px}
.segmented{display:inline-flex;flex-wrap:wrap;max-width:100%;border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
.segmented label{position:relative;display:block;padding:0}.segmented input{position:absolute;inset:0;opacity:0;margin:0}
.segmented span{display:block;padding:7px 14px;min-height:36px;font-weight:500;line-height:1.4}.segmented input:checked+span{background:var(--action);color:var(--onAction)}.segmented input:focus-visible+span{outline:3px solid var(--focus);outline-offset:-3px}
.mode-control{border:0;padding:0}.field{margin-bottom:10px}
.queue{list-style:none;margin:0;padding:0;display:grid;gap:8px}.queue li{display:flex;flex-wrap:wrap;gap:8px;align-items:center;font-size:.9rem}.queue li>span:not(.badge){flex:1 1 160px}
.cfg-panel.cfg-changed{border-color:color-mix(in srgb,var(--warning) 50%,transparent)}
.thread-list{min-width:0}.thread{display:flex;gap:8px;min-width:0;padding:8px;border-radius:var(--r);text-decoration:none;color:var(--text);font-size:.9rem;border:1px solid transparent}
.thread[aria-current=true]{background:var(--raised);border-color:var(--line)}.thread.unread b::after{content:"";display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--action);margin-left:6px}
.thread>span:not(.avatar){min-width:0;flex:1}.thread>.avatar{flex:none}.thread .preview{color:var(--muted);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.message-body{font-size:1rem}
.legend{display:flex;gap:6px;align-items:center;flex-wrap:wrap;font-size:.8rem;margin-top:8px}.legend-title{font-weight:600;color:var(--muted)}.legend .feat{margin:0}.legend-link{margin-left:auto}
/* Demo states */
.state{padding:20px;border:1px dashed var(--border);border-radius:var(--r-lg);text-align:center;color:var(--muted);display:grid;gap:8px;justify-items:center}
.state-error{border-style:solid;border-color:color-mix(in srgb,var(--danger) 45%,transparent);color:var(--text);text-align:left;justify-items:start}
.state-error .actions{margin:0}
.spinner{width:22px;height:22px;border-radius:50%;border:3px solid var(--line);border-top-color:var(--action);animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.skeleton{display:grid;gap:8px;width:100%}.skeleton i{display:block;height:14px;border-radius:99px;background:linear-gradient(90deg,var(--raised),var(--line),var(--raised));background-size:200% 100%;animation:shimmer 1.4s linear infinite}
@keyframes shimmer{to{background-position:-200% 0}}
/* Toasts and live region */
#toasts{position:fixed;inset-inline:12px;bottom:12px;z-index:60;display:grid;gap:8px;justify-items:center;pointer-events:none}
.toast{pointer-events:auto;display:flex;gap:10px;align-items:center;background:var(--text);color:var(--canvas);padding:10px 12px 10px 14px;border-radius:var(--r-lg);max-width:min(100%,560px);font-size:.9rem;box-shadow:var(--shadow)}
.toast-ok{background:var(--success);color:var(--canvas)}.toast-danger{background:var(--danger);color:var(--canvas)}
.toast-close{background:none;border:0;color:inherit;font-size:1.2rem;cursor:pointer;line-height:1;padding:4px 6px;min-width:32px;min-height:32px}
@media (min-width:768px){#toasts{justify-items:end;inset-inline:auto 16px;bottom:16px}}
/* Shared shell pieces used by several designs */
.nav-link{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:var(--r);color:var(--text);text-decoration:none;font-weight:500;min-height:40px;white-space:nowrap}
.nav-link:hover{background:var(--raised)}.nav-link[aria-current=page]{background:color-mix(in srgb,var(--action) 9%,var(--surface));color:var(--action);box-shadow:inset 3px 0 0 var(--action)}
.nav-link .ic{width:22px;height:22px}
.theme-quick{display:inline-flex;gap:4px}.theme-quick .btn{padding:6px 8px;min-width:36px;justify-content:center}
.demo-select{font-size:.85rem;min-height:36px;padding:4px 8px;width:auto}
.prototype-note{font-size:.78rem;color:var(--muted);border-top:1px dashed var(--line);padding-top:8px;margin-top:8px}
footer.foot{font-size:.8rem;color:var(--muted);padding:16px var(--pad);display:flex;gap:12px;flex-wrap:wrap;justify-content:space-between;border-top:1px solid var(--line)}
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
@media (forced-colors:active){.badge,.btn,.chip,.panel,.wait-item,.task-card,.session-card{border:1px solid CanvasText}.badge::before{background:CanvasText}.nav-link[aria-current=page]{outline:2px solid Highlight}}
@media print{#toasts,.skip,nav{display:none}.panel{box-shadow:none}}
+96
View File
@@ -0,0 +1,96 @@
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { isIP } from 'node:net';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) {
return host === 'localhost' || host === '::1' || (isIP(host) === 4 && host.startsWith('127.'));
}
export function boardURL(value) {
const url = new URL(value);
if (url.protocol !== 'http:' || !isLoopback(url.hostname.replace(/^\[|\]$/g, '')) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
throw new Error('board must be an HTTP loopback origin without credentials, path, query or fragment');
}
// Avoid hostname resolution for localhost.
if (url.hostname === 'localhost') url.hostname = '127.0.0.1';
return url.origin;
}
const root = resolve(import.meta.dirname, 'public');
const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
...['app.js', 'brand.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['shared/app.css', 'console.css', 'live.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
]);
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(body));
}
async function body(req) {
if ((req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') throw new Error('Content-Type must be application/json');
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > 4096) throw new Error('body larger than 4096 bytes');
chunks.push(chunk);
}
const bytes = Buffer.concat(chunks);
const value = JSON.parse(bytes.toString('utf8'));
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('body must be a JSON object');
return bytes;
}
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000 } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
const upstream = boardURL(board);
const server = createServer(async (req, res) => {
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
let path;
try {
const authority = new URL(`http://${req.headers.host}`);
if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' });
path = new URL(req.url, 'http://localhost').pathname;
} catch { return json(res, 400, { error: 'invalid URL' }); }
// No CORS. JSON content type and same-origin checks keep browser forms out.
if (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) return json(res, 403, { error: 'cross-origin request refused' });
const allowed = path === '/api/board' ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
if (allowed) {
if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' });
let bytes;
if (allowed === 'POST') {
try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); }
}
try {
const response = await fetch(upstream + path, {
method: allowed, headers: bytes ? { 'content-type': 'application/json' } : {}, body: bytes,
redirect: 'error', signal: AbortSignal.timeout(timeout),
});
const text = await response.text();
JSON.parse(text); // Never serve upstream HTML or scripts as API data.
res.writeHead(response.status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
return res.end(text);
} catch {
return json(res, 502, { error: `Board unreachable or invalid response at ${upstream}. Check the board server. No automatic action retry.`, board: upstream });
}
}
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
if (path === '/api/config') return json(res, 200, { board: upstream });
if (path === '/healthz') return json(res, 200, { ok: true });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
if (!file) return json(res, 404, { error: 'not found' });
try {
const content = readFileSync(resolve(root, file[0]));
res.writeHead(200, { 'content-type': file[1], 'cache-control': 'no-store' });
res.end(req.method === 'HEAD' ? undefined : content);
} catch { json(res, 500, { error: 'WebUI asset unavailable' }); }
});
return new Promise((resolvePromise, reject) => {
server.once('error', reject);
server.listen(port, host, () => { server.off('error', reject); resolvePromise(server); });
});
}
@@ -0,0 +1,63 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { startServer } from '../src/serve.mjs';
test('browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback', { timeout: 120000 }, async () => {
let index = { sessions: [], counts: {}, generatedAt: 'fixture time' }, held = null, sends = 0, holdBoard = true, boardResponse;
let resolveBoardRequest;
const boardRequested = new Promise(resolve => { resolveBoardRequest = resolve; });
const server = createServer(async (req, res) => {
for await (const chunk of req) { void chunk; }
res.setHeader('content-type', 'application/json');
if (req.url === '/api/board') { if (holdBoard) { boardResponse = res; resolveBoardRequest(); return; } return res.end(JSON.stringify(index)); }
sends++; held = res;
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const web = await startServer({ port: 0, board: `http://127.0.0.1:${server.address().port}` });
const b = await browser();
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out')})()`);
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'Object.defineProperty(window,"localStorage",{get(){throw new Error("storage denied")}});window.errors=[];addEventListener("error",e=>errors.push(e.message));' });
await b.viewport(320); await b.navigate(`http://127.0.0.1:${web.address().port}`);
assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Loading/);
await boardRequested;
holdBoard = false; boardResponse.end(JSON.stringify(index));
await wait('document.querySelector("#sessions").textContent.includes("No sessions")');
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '0');
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
const agent = 'agent"[\\<script>', project = '__proto__';
const rec = { agent, project, state: 'waiting', waitingOnYou: true, task: 'Long '.repeat(300), workspace: '/' + 'path/'.repeat(100), taskSource: 'registration', lastActivity: '2026-09-01', lastAssistantText: '<img src=x onerror=alert(1)>', registered: { alive: true, tmux: { session: agent } } };
index = { sessions: [rec], counts: { waiting: 1 } };
await b.evaluate('document.querySelector("#refresh").click()'); await wait('document.querySelectorAll("table.sessions [data-open]").length===1');
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
assert.equal(await b.evaluate('document.querySelector("#inspector-title").textContent'), agent);
assert.equal(await b.evaluate('document.querySelectorAll("#inspection img").length'), 0);
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true);
await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'original draft' });
await b.evaluate('document.querySelector(".reply-form").requestSubmit();document.querySelector(".reply-form").requestSubmit()');
await wait('document.querySelector(".reply-form button").disabled');
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.equal(await b.evaluate('document.querySelector(".reply-form button").disabled'), true);
await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'newer ' });
held.end(JSON.stringify({ delivered: true, sentAt: 'fixture', session: agent }));
await wait('document.querySelector(".receipt")?.textContent.startsWith("delivered")');
assert.equal(sends, 1); assert.match(await b.evaluate('document.querySelector("#reply").value'), /newer/);
// A stale registration removes the form, but does not destroy its draft.
index.sessions[0].registered.alive = false;
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), false);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /reply needs a registered seat/);
index.sessions[0].registered.alive = true;
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!!document.querySelector("#reply")');
assert.match(await b.evaluate('document.querySelector("#reply").value'), /newer/);
// Malformed data must not erase a valid snapshot.
index = { sessions: [null] };
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#error").hidden');
assert.match(await b.evaluate('document.querySelector("#error").textContent'), /Invalid board session data/);
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions [data-open]").length'), 1);
assert.deepEqual(await b.evaluate('window.errors'), []);
} finally { boardResponse?.end(); held?.end(); await b.close(); await close(web); await close(server); }
});
+78
View File
@@ -0,0 +1,78 @@
// Small CDP client for the installed Chromium. No application dependencies.
import { spawn } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export async function browser() {
const profile = await mkdtemp(join(tmpdir(), 'dewey-brand-browser-'));
const child = spawn(process.env.CHROMIUM || '/usr/bin/chromium', [
'--headless', '--no-first-run', '--no-default-browser-check',
'--disable-dev-shm-usage', '--remote-debugging-pipe', `--user-data-dir=${profile}`,
], { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
let id = 0, buffer = '';
const pending = new Map();
child.stdio[4].on('data', chunk => {
buffer += chunk.toString();
let end;
while ((end = buffer.indexOf('\0')) !== -1) {
const message = JSON.parse(buffer.slice(0, end));
buffer = buffer.slice(end + 1);
if (pending.has(message.id)) {
const { resolve, reject, timeout } = pending.get(message.id);
clearTimeout(timeout);
pending.delete(message.id);
message.error ? reject(new Error(JSON.stringify(message.error))) : resolve(message.result);
}
}
});
function send(method, params = {}, sessionId) {
return new Promise((resolve, reject) => {
const key = ++id;
const timeout = setTimeout(() => { pending.delete(key); reject(new Error(`CDP timeout: ${method}`)); }, 25000);
pending.set(key, { resolve, reject, timeout });
child.stdio[3].write(JSON.stringify({ id: key, method, params, ...(sessionId ? {sessionId} : {}) }) + '\0');
});
}
const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
const call = (method, params) => send(method, params, sessionId);
await call('Page.enable');
await call('Runtime.enable');
const evaluate = async expression => {
const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ': ' + r.result.description);
return r.result.value;
};
return {
call, evaluate,
async viewport(width, height = 1000) {
await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
},
async navigate(url) {
const result = await call('Page.navigate', { url });
if (result.errorText) throw new Error(result.errorText);
// Bounded page-load check, not an agent wake/watch loop.
for (let n = 0; n < 50; n++) {
if (await evaluate('document.readyState === "complete"')) break;
await new Promise(r => setTimeout(r, 100));
}
await evaluate('document.fonts.ready.then(() => true)');
return result;
},
async screenshot(path) {
const { data } = await call('Page.captureScreenshot', { format: 'png' });
await writeFile(path, Buffer.from(data, 'base64'));
},
async key(key, code = key, modifiers = 0) {
const virtual = { Tab: 9, Enter: 13, Escape: 27, ArrowDown: 40, ArrowRight: 39, ' ': 32 }[key];
await call('Input.dispatchKeyEvent', { type: 'keyDown', key, code, modifiers, windowsVirtualKeyCode: virtual, text: key === 'Enter' ? '\r' : key.length === 1 ? key : '' });
await call('Input.dispatchKeyEvent', { type: 'keyUp', key, code, modifiers, windowsVirtualKeyCode: virtual });
},
async close() {
await send('Browser.close').catch(() => {});
if (child.exitCode === null) await new Promise(resolve => child.once('exit', resolve));
await rm(profile, { recursive: true, force: true });
},
};
}
+107
View File
@@ -0,0 +1,107 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { fixture } from './fixture.mjs';
test('served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures', { timeout: 120000 }, async () => {
const f = await fixture(), b = await browser();
const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true });
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out')})()`);
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));' });
await b.viewport(1440); await b.navigate(f.base); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '2');
assert.equal(await b.evaluate('document.querySelectorAll("#projects [data-project]").length'), 3);
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
assert.equal(await b.evaluate('document.querySelectorAll("script").length'), 2);
assert.equal(await b.evaluate('!!window.injected'), false);
// Project filtering does not hide another project's waiting requests.
await b.evaluate('document.querySelector("[data-project=proj]").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 2);
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '2');
await b.evaluate('[...document.querySelectorAll("[data-project]")].find(e=>e.dataset.project==="").click()');
await b.evaluate('document.querySelector("table.sessions [data-open]").focus()');
const first = await b.evaluate('document.activeElement.dataset.open');
await b.key('ArrowDown'); assert.notEqual(await b.evaluate('document.activeElement.dataset.open'), first);
// Use the registered row regardless of scanner ordering.
await b.evaluate('[...document.querySelectorAll("table.sessions [data-open]")].find(e=>e.dataset.open==="proj/agent1").focus()');
await b.key('Enter');
assert.equal(await b.evaluate('document.activeElement.id'), 'inspector-title');
assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), false);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Registered fixture task/);
assert.equal(await b.evaluate('!!window.injected'), false);
await b.evaluate('document.querySelector("#reply").focus()');
await b.call('Input.insertText', { text: 'keep this draft' });
await b.evaluate('document.querySelector("#reply").setSelectionRange(3,7);document.querySelector("#refresh").click()');
await wait('!document.querySelector("#refresh").disabled');
assert.deepEqual(await b.evaluate('({value:document.querySelector("#reply").value,start:document.querySelector("#reply").selectionStart,end:document.querySelector("#reply").selectionEnd,focus:document.activeElement.id})'), { value: 'keep this draft', start: 3, end: 7, focus: 'reply' });
// Real proxy to real board, with only its transport stubbed.
await b.evaluate('document.querySelector(".reply-form").requestSubmit()');
await wait('document.querySelector(".receipt")?.textContent.startsWith("delivered")');
assert.equal(f.captures.length, 1); assert.equal(await b.evaluate('document.querySelector("#reply").value'), '');
f.failReply();
await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'failed draft' });
await b.evaluate('document.querySelector(".reply-form").requestSubmit()');
await wait('document.querySelector(".receipt")?.textContent.includes("fixture refusal")');
assert.equal(await b.evaluate('document.querySelector("#reply").value'), 'failed draft');
assert.match(await b.evaluate('document.querySelector(".receipt").textContent'), /failed \(exit 4\): fixture refusal <unsafe>/);
await b.evaluate('document.querySelector("#inspection [data-seen]").click()');
await wait('document.querySelector("#seen-count").textContent==="1"');
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '1');
await b.evaluate('document.querySelector("#inspection [data-seen]").click()');
await wait('document.querySelector("#seen-count").textContent==="0"');
await b.evaluate('document.querySelector("#close").focus()'); await b.key('Escape');
assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), true);
assert.equal(await b.evaluate('document.activeElement.dataset.open'), 'proj/agent1');
if (out) {
await b.screenshot(join(out, 'console-harbor-light-1440.png'));
await b.viewport(320, 1000);
await b.screenshot(join(out, 'console-harbor-light-320.png'));
await b.viewport(1440);
}
// All supported palette/mode tokens and rendered badge/text backgrounds.
const contrast = await b.evaluate(`(() => {
const failures=[]; let count=0,lowest=100;
const rgb = c => { const a=c.match(/[\\d.]+/g).map(Number);return c.startsWith('color(')?[a[0]*255,a[1]*255,a[2]*255,a[3]??1]:[a[0],a[1],a[2],a[3]??1] };
const blend=(f,g)=>[...f.slice(0,3).map((v,i)=>v*f[3]+g[i]*(1-f[3])),1];
const lum=c=>c.slice(0,3).map(v=>{v/=255;return v<=.04045?v/12.92:((v+.055)/1.055)**2.4}).reduce((s,v,i)=>s+v*[.2126,.7152,.0722][i],0);
for(const p of Brand.palettes)for(const m of ['light','dim','dark']){
document.querySelector('#palette').value=p.id;document.querySelector('#palette').dispatchEvent(new Event('change'));
document.querySelector('#mode').value=m;document.querySelector('#mode').dispatchEvent(new Event('change'));
for(const s of ['h1','.muted','.source','.badge-ok','.badge-attn','.badge-danger','.badge-muted','.count','th','.session-open','.btn']){
const e=document.querySelector(s);if(!e)continue; const layers=[];for(let n=e;n;n=n.parentElement)layers.push(rgb(getComputedStyle(n).backgroundColor));
let bg=[255,255,255,1];while(layers.length)bg=blend(layers.pop(),bg);
const style=getComputedStyle(e), fg=blend(rgb(style.color),bg),a=lum(fg),b=lum(bg),ratio=(Math.max(a,b)+.05)/(Math.min(a,b)+.05);
const large=parseFloat(style.fontSize)>=24 || (parseFloat(style.fontSize)>=18.66&&parseInt(style.fontWeight)>=700);
count++;lowest=Math.min(lowest,ratio);if(ratio<(large?3:4.5))failures.push(p.id+'/'+m+' '+s+' '+ratio.toFixed(2));
}
}return {failures,count,lowest};
})()`);
assert.deepEqual(contrast.failures, []); assert.ok(contrast.count >= 300); console.log('Rendered contrast:', JSON.stringify(contrast));
for (const width of [320, 390, 768, 1440, 2560]) {
await b.viewport(width, 1000);
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, `${width} page overflow`);
await b.evaluate('[...document.querySelectorAll("table.sessions [data-open]")].find(e=>e.dataset.open==="proj/agent1").click()');
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, `${width} inspector overflow`);
assert.equal(await b.evaluate('document.querySelector("#reply").getBoundingClientRect().right<=innerWidth'), true);
if (out && [320, 1440].includes(width)) await b.screenshot(join(out, `console-inspector-${width}.png`));
await b.evaluate('document.querySelector("#close").click()');
}
// Empty and unreachable pages are tested as browser responses, never live data claims.
// Stop only the fixture board. The page keeps the last data and reports its URL.
await new Promise(r => f.board.close(r));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('!document.querySelector("#error").hidden');
const error = await b.evaluate('document.querySelector("#error").textContent');
assert.ok(error.includes(f.boardURL)); assert.match(error, /Showing last known data/);
assert.ok(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length') > 0);
assert.deepEqual(await b.evaluate('window.errors'), []);
await b.navigate(f.base); await wait('!document.querySelector("#error").hidden');
assert.match(await b.evaluate('document.querySelector("#error").textContent'), /No board data loaded/);
} finally { await b.close(); await f.close(); }
});
+36
View File
@@ -0,0 +1,36 @@
// Same session-line fixture shape as control-board/tests/serve.test.mjs.
// Run the real board scanner/server against temporary data only.
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
export const close = server => new Promise(resolve => { server.close(resolve); server.closeIdleConnections(); });
export async function fixture() {
const root = mkdtempSync(join(tmpdir(), 'webui-fixture-'));
const seatsDir = join(root, 'seats'), specs = [];
let exitCode = 0;
const captures = [];
for (const [agent, project, state] of [['agent1', 'proj', 'waiting'], ['agent2', 'proj', 'working'], ['agent3', 'other', 'error'], ['agent4', 'other', 'offline']]) {
const sessionsDir = join(root, agent); mkdirSync(sessionsDir);
const assistant = { role: 'assistant', model: 'fixture-model', provider: 'fixture-provider', stopReason: state === 'error' ? 'error' : 'stop', content: [{ type: 'text', text: 'done? <script>window.injected=true</script>' }] };
const lines = [
{ type: 'session', id: agent, timestamp: '2026-09-01T00:00:00Z', cwd: '/fixture/workspace' },
{ type: 'message', timestamp: '2026-09-01T00:00:01Z', message: { role: 'user', content: [{ type: 'text', text: 'Build the fixture task' }] } },
{ type: 'message', timestamp: '2026-09-01T00:00:02Z', message: assistant },
];
if (state === 'working') lines.push({ type: 'message', timestamp: '2026-09-01T00:00:03Z', message: { role: 'user', content: [{ type: 'text', text: 'continue' }] } });
writeFileSync(join(sessionsDir, 's.jsonl'), lines.map(l => JSON.stringify(l)).join('\n') + '\n');
specs.push({ agent, project, sessionsDir, tmux: { session: agent } });
if (agent === 'agent1') writeRegistration(seatsDir, makeRegistration({ resolved: { seat: agent, project, sessionsDir, seatDir: sessionsDir, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: '/fixture/workspace' }, task: 'Registered fixture task', tmux: { session: agent, socket: null }, pid: process.pid }));
}
const board = await startBoard({ port: 0, specs, seatsDir, boardDir: join(root, 'board'), isAlive: tmux => tmux.session !== 'agent4', isPidAlive: () => true,
exec: (file, args) => { captures.push({ file, args }); return { status: exitCode, stdout: 'fixture transport', stderr: exitCode ? 'fixture refusal <unsafe>' : '' }; },
});
const boardURL = `http://127.0.0.1:${board.address().port}`;
const web = await startServer({ port: 0, board: boardURL });
return { root, board, web, boardURL, base: `http://127.0.0.1:${web.address().port}`, captures, failReply: () => { exitCode = 4; },
async close() { await close(web); await close(board); rmSync(root, { recursive: true, force: true }); },
};
}
+91
View File
@@ -0,0 +1,91 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, get } from 'node:http';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { startServer, boardURL, isLoopback } from '../src/serve.mjs';
import { fixture, close } from './fixture.mjs';
const post = (base, path, body, extra = {}) => fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json', ...extra }, body: typeof body === 'string' ? body : JSON.stringify(body) });
test('loopback host and board origin fail closed', async () => {
for (const h of ['127.0.0.1', '127.2.3.4', '::1', 'localhost']) assert.equal(isLoopback(h), true);
for (const h of ['0.0.0.0', '192.168.1.1', '::', 'example.com', '']) {
assert.equal(isLoopback(h), false);
await assert.rejects(startServer({ host: h, port: 0 }), /non-loopback/);
}
for (const u of ['https://127.0.0.1', 'http://example.com', 'http://127.0.0.1/path', 'http://user:[email protected]', 'http://127.0.0.1?x', 'http://127.0.0.1#x']) assert.throws(() => boardURL(u));
assert.equal(boardURL('http://localhost:7331'), 'http://127.0.0.1:7331');
assert.equal(boardURL('http://[::1]:7331'), 'http://[::1]:7331');
});
test('real board fixture passes through WebUI; assets and isolated seen/reply work', async () => {
const f = await fixture();
try {
assert.equal(f.web.address().address, '127.0.0.1');
const data = await (await fetch(f.base + '/api/board')).json();
assert.equal(data.sessions.length, 4);
assert.equal(data.sessions.filter(r => r.waitingOnYou).length, 2);
assert.deepEqual(data.counts, { working: 1, waiting: 1, error: 1, offline: 1, idle: 0, unknown: 0 });
const r = data.sessions.find(r => r.agent === 'agent1');
const seen = await post(f.base, '/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: true });
assert.equal(seen.status, 200); assert.equal((await seen.json()).seen.length, 1);
const reply = await post(f.base, '/api/reply', { agent: 'proj/agent1', text: 'fixture reply' });
assert.equal((await reply.json()).delivered, true); assert.equal(f.captures.length, 1);
assert.match(f.captures[0].args.at(-1), /^fixture reply\n\(control-board:/);
for (const path of ['/', '/app.js', '/brand.js', '/console.css', '/live.css', '/shared/app.css', '/assets/fonts/manrope-400.woff2']) {
const response = await fetch(f.base + path); assert.equal(response.status, 200, path); assert.equal(response.headers.get('cache-control'), 'no-store');
}
assert.equal((await fetch(f.base + '/../../AGENTS.md')).status, 404);
assert.equal((await fetch(f.base + '/api/reply')).status, 405);
assert.equal((await fetch(f.base + '/api/board', { method: 'OPTIONS' })).status, 405);
assert.equal((await post(f.base, '/api/reply', { agent: 'proj/agent1', text: 'blocked' }, { origin: 'https://evil.example' })).status, 403);
assert.equal(f.captures.length, 1);
const hostileHost = await new Promise((resolve, reject) => {
get(f.base + '/api/board', { headers: { host: 'evil.example' } }, res => { res.resume(); resolve(res.statusCode); }).on('error', reject);
});
assert.equal(hostileHost, 403);
} finally { await f.close(); }
});
test('proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect', async () => {
const requests = [];
const upstream = createServer(async (req, res) => {
let body = ''; for await (const c of req) body += c;
requests.push({ url: req.url, method: req.method, body });
if (req.url === '/api/board') { res.writeHead(302, { location: 'http://192.0.2.1/' }); return res.end('{}'); }
res.writeHead(409, { 'content-type': 'application/json' }); res.end('{"error":"fixture refusal <unsafe>"}');
});
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}` });
const base = `http://127.0.0.1:${web.address().port}`;
try {
for (const path of ['/api/reply', '/api/seen']) {
const raw = '{ "agent": "proj/agent1", "text": "hello Ω", "seen": false }';
const res = await post(base, path, raw);
assert.equal(res.status, 409); assert.equal(await res.text(), '{"error":"fixture refusal <unsafe>"}');
assert.deepEqual(requests.at(-1), { url: path, method: 'POST', body: raw });
}
for (const raw of ['{', 'null', '[]', JSON.stringify({ text: 'x'.repeat(5000) })]) {
try { assert.equal((await post(base, '/api/reply', raw)).status, 400); } catch (err) { if (raw.length < 4096) throw err; }
}
assert.equal((await post(base, '/api/reply', '{}', { 'content-type': 'text/plain' })).status, 400);
assert.equal(requests.length, 2);
assert.equal((await fetch(base + '/api/board')).status, 502);
assert.equal(requests.length, 3);
} finally { await close(web); await close(upstream); }
});
test('unreachable board reports URL; CLI rejects unsupported options', async () => {
const probe = createServer(); await new Promise(r => probe.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${probe.address().port}`; await close(probe);
const web = await startServer({ port: 0, board: url, timeout: 100 });
try {
const res = await fetch(`http://127.0.0.1:${web.address().port}/api/board`);
assert.equal(res.status, 502); const result = await res.json(); assert.equal(result.board, url); assert.ok(result.error.includes(url));
} finally { await close(web); }
for (const args of [[], ['serve', '--port', 'abc'], ['serve', '--host', '0.0.0.0'], ['serve', '--board', 'http://example.com']]) {
const result = spawnSync(process.execPath, [fileURLToPath(new URL('../src/cli.mjs', import.meta.url)), ...args], { encoding: 'utf8' });
assert.equal(result.status, 2); assert.match(result.stderr, /^refused:/);
}
});