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
+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:/);
}
});