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