feat(webui): console tokens and shell restyle (#1542, row 53)

The console takes the design package's tokens.css and icons.svg as byte
copies, guarded by a test, and a new shell.css holds the components:
command bar with freshness line, section list (a strip that scrolls
inside itself below 760px), dense table, inspector, status mark, chips,
copy command, toast, Ctrl+K palette, not found, banners and skeleton
rows. JetBrains Mono 2.304 ships from the official archive with sha256
and OFL. Harbor following the system setting is the default palette.
A refusal (403, not-configured, no-bus-host, or the board's 403) fails
closed everywhere: no kept rows, counts, inspector, palette entries or
conversation history, and a later 500 or 503 doesn't bring them back.

Dewey built it in three rounds. Round 1 (75569953) and round 2
(78c3aef9) got changes from Darkwing (27148, 27165) and Filbert (27143,
27163) for refusal leaks outside the main view; round 3 (2562c05d) was
approved by Darkwing (27171) and Filbert (27174). Sage's gate on
ea080fd8 plus the candidate: runs 41/0, queue 148/0, webui 27/0,
conversation 182/0, control-board 124/0, build-tokens --check current,
every scripts/test-*.sh 0 failed (task 98/0 and release 14/0 with
Docker). The round 2 gate had one discord engine timing failure in the
full run, 66/0 alone, filed as #1553.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-10 14:49:48 -05:00
co-authored by Claude Opus 5.5
parent 1bb8ab54fe
commit 1bdb6f9b13
17 changed files with 1833 additions and 44 deletions
@@ -0,0 +1,16 @@
eb355c2d22fbfcc14e973a1f793891b0caab04a50cf6fd83c64b9de73a84bfac packages/webui/README.md
6154b49628a613059bb35c26b577817e8ed5fd084c9dfb50aef11431578f14e7 packages/webui/src/public/app.js
a9cb1cd82332b23a47e3a1239d25d13c86d16c4220695e34b243effa999f45f2 packages/webui/src/public/assets/fonts/jetbrains-mono-400.woff2
086c48dfbea9ddaff1320f7e09399b8e2924e88ce67453721255db3bdbb5a353 packages/webui/src/public/assets/fonts/jetbrains-mono-500.woff2
c503cc5ec5f8b2c7666b7ecda1adf44bd45f2e6579b2eba0fc292150416588a2 packages/webui/src/public/assets/fonts/jetbrains-mono-700.woff2
30f0c136e3c88e422d0791acd97238870f9054a9729bc34cf2ff0d4ed8cac4ad packages/webui/src/public/assets/fonts/jetbrains-mono-OFL.txt
f6f89aaaa6a02e9a16020de26fb6a664f1da152e0b0413248da2d9f6b68395cc packages/webui/src/public/assets/fonts/jetbrains-mono-sources.txt
d8bc0fc7ad10fa188a6f68b925449476af4ca56ba343b24b089fb48cca296295 packages/webui/src/public/bus.js
01b456c1ca685e69e2974d66a615b7b8736abbd51aef7a3f014bca3783710430 packages/webui/src/public/icons.svg
fbbb51e5669a1f559015e0fdebf6f450b63383070472d2461f0bce9b9723d890 packages/webui/src/public/index.html
d7b4d0ca32f07a63265d89131847cbb72c7e532101e693b31b0f38839b22f7e6 packages/webui/src/public/shell.css
4fdb72ec24c7245584c89280415e10113dba1c4cb03d98f5570ac745ba2e95cb packages/webui/src/public/tokens.css
5a65fea95e6225f812c196b0199f3f6562a60e523da685b255f749f63b480c64 packages/webui/src/serve.mjs
6f68f67e99d5e832c0ba219911201b8adf08505e234647f2d9eac7292d55f50f packages/webui/tests/browser.mjs
713c86bd6b1d1b27949bbae071f1bdf1f7d744f84b723ad5a1bdbd1e8adc5fb6 packages/webui/tests/serve.test.mjs
da37a24308e51875502b81a5694d4e9c69dae24d36ee272a0b99b1ace7c4d317 packages/webui/tests/shell.test.mjs
+60 -3
View File
@@ -38,8 +38,9 @@ bytes. Do not expose either unauthenticated server through a public proxy.
- 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.
- Palette and appearance come from `tokens.css` (see "Shell" below) and persist
in this browser when local storage is available. Appearance defaults to
System. No settings screen is added.
- History opens a read-only conversation view for a seat, from its Waiting
card, its table row or its inspector (#1507, CHAT-02). It shows the whole
branch with nothing clipped. Tool calls, tool results and thinking start
@@ -89,11 +90,53 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
- Views refresh on the board's ten-second interval and stop on Pause. Focus,
open sections and the copy status survive a refresh.
- A failed read keeps the last good answer for that read and says it is old,
with the time it was read. A page never read shows the failure instead,
with the time it was read. A refusal (403, `not-configured` or `no-bus-host`)
never shows kept rows (row 53), and it drops every kept bus read: the Inbox
count and the palette's decisions and tasks go too, until a read succeeds.
A page never read, or refused, shows the failure instead,
titled "Bus refused the read" (403: the bus refused the Console's read, for
example `human-required` from a Console started inside an agent run), "No bus to read" (no system
config, or no bus host and no `--business`) or "The read failed".
## Shell (row 53, #1542)
The Console is restyled to `docs/design` (`IMPLEMENTING.md`). It is a restyle
only: no new data, route or write.
- `src/public/tokens.css` and `src/public/icons.svg` are byte copies of
`docs/design/tokens.css` and `docs/design/icons.svg`; `tests/shell.test.mjs`
fails if either differs. Edit `docs/design`, rebuild there, then copy.
- Colours come only from `tokens.css`, selected by `data-palette` and
`data-mode` on `<html>`. The page sets no colour inline. Appearance System
removes `data-mode`, so the OS light or dark setting applies; it is the
default, with Harbor. The choice is kept under `mosaic-console-appearance`.
- `shell.css` loads last and holds the restyle: radii `--r` 6px and `--r-lg`
10px, the type scale, tabular numbers, the command bar, section list, dense
table, inspector, status marks, class chips, toast, command palette and
not-found page. The earlier CSS files are unchanged.
- JetBrains Mono 400, 500 and 700 come from the official JetBrains release
(v2.304); `assets/fonts/jetbrains-mono-sources.txt` has the URL and sha256
of the archive and each file, and `jetbrains-mono-OFL.txt` the licence.
Manrope has no 800 file here, so 800 headings render at 700.
- Ctrl+K (or the search field) opens the palette over views, inbox decisions
and tasks; arrows move, Enter opens, Esc closes and returns focus. Arrow
keys also move between task links in the bus tables.
- Copy reports in a toast. If the clipboard is unavailable, the toast stays
until dismissed and the command is selected for Ctrl+C. When no toast shows,
the toast is an empty live region, visually hidden but still in the
accessibility tree, so the first copy is announced.
- The command bar shows, in UTC, when the board was scanned and the bus read,
and says when the last attempt failed. A board refusal drops the last scan
and everything drawn from it: the cards, the table, the inspector, the
project filter and tree, the counts, the footer and the scan line all say
the board was not read. A failed read with no scan held, after a refusal or
before the first scan, shows the board empty and says the read failed; it
never brings back rows from a refused scan. A refusal also closes an open
conversation and clears its history.
- From 760px the footer is a one-line status bar and the side column fits
between it and the command bar. Below 760px the section list is one strip
that scrolls inside itself.
## Data and boundaries
GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST
@@ -138,6 +181,7 @@ node --test packages/control-board/tests/ packages/seat/tests/ packages/ledger/t
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/browser.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/conversation.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/bus-browser.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/shell.test.mjs
```
Node's test runner and installed `/usr/bin/chromium` are required. Set `CHROMIUM`
@@ -168,6 +212,19 @@ They cover each view, the gap labels, inert hostile text, Copy, the stale
fallback and each failure title, and that no route writes. They never start
the human CLI against a live bus.
Shell tests (`shell.test.mjs`) check the copies, the font hashes and the kept
strings, and in the browser at 400px: System mode under an emulated dark and
light OS, the empty, stale and refused states of the board and the bus views
(a board refusal with the inspector open and a project picked, then a failed
read, and a refusal with a conversation open), not found, the palette, the toast,
keyboard and visible focus, the section strip at 360 and 400px, no sideways
scroll, no script error and no request other than GET. A second test walks
the Board, Inbox, Tasks, Agents and a decision trail through loading (a held
read), normal, a failed read over kept rows, and a refusal by the bus
(`human-required`) and for want of one (`no-bus-host`). It then checks that a
refusal empties the palette and the Inbox count, from a view, from the
palette's own reads and from the board page, and that palette labels are text.
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.
+47 -12
View File
@@ -7,12 +7,13 @@
// Delivered replies with no newer answer yet, by row: the board's sentAt. Page memory only.
const awaiting = new Map();
let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, timer;
let board = 'the configured board', palette = 'harbor', mode = 'light';
let board = 'the configured board', palette = 'harbor', mode = 'system', failed = false;
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>` : '';
// Who set a registered task (#1511): null from the scan unless the task shown is the registered one. A caller's claim, not verified; it grants nothing.
const setBy = r => r.taskSetBy ? `<span class="source">set by ${esc(r.taskSetBy)}</span>` : '';
const badge = state => `<span class="badge ${ { working: 'badge-ok', waiting: 'badge-attn', error: 'badge-danger' }[state] || 'badge-muted'}">${esc(state || 'unknown')}</span>`;
// Status mark: the class adds the glyph (shell.css); the word is the text, so colour is never the only signal.
const badge = state => `<span class="badge st st-${esc(state || 'unknown')} ${ { working: 'badge-ok', waiting: 'badge-attn', error: 'badge-danger' }[state] || 'badge-muted'}">${esc(state || 'unknown')}</span>`;
const connectorStatus = r => r.connector ? `<span class="source">${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'} · owner ${esc(r.connector.ownerState)}</span>` : '';
const relaunchNotice = r => r.relaunchedAt ? `relaunched at ${r.relaunchedAt}, no messages since` : '';
// Relative age is the board's ageSeconds: time since last activity as of the scan, not session lifetime (#1507).
@@ -34,12 +35,13 @@
const canReply = r => !r.connector && 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; };
// Colours come from tokens.css by data-palette and data-mode. System removes data-mode, so the OS setting picks light or dark.
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;
if (!['system', 'light', 'dim', 'dark'].includes(mode)) mode = 'system';
document.documentElement.dataset.palette = palette;
if (mode === 'system') delete document.documentElement.dataset.mode; else document.documentElement.dataset.mode = mode;
$('palette').value = palette; $('mode').value = mode;
try { localStorage.setItem('mosaic-console-appearance', JSON.stringify({ palette, mode })); } catch {}
}
@@ -59,8 +61,8 @@
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'))}</span><span>${historyButton(r)}${seenButton(r)}</span></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>${historyButton(r)}</td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</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>${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `<span class="source">${esc(r.lastActivity)}</span>`}` : 'unknown'}</td></tr>`).join('')}</tbody></table></div>`;
if (!rows.length) return `<div class="empty"><b>No sessions match these filters.</b><p>${data.sessions.length ? 'Clear Hide offline or Hide seen, or pick All projects, to see the rest.' : 'Sessions appear here when the board scan finds an agent session on this machine.'}</p></div>`;
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))}"${selected === key(r) ? ' class="is-selected"' : ''}><td>${openButton(r)}<span class="source">${esc(r.project)}</span>${historyButton(r)}</td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</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>${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `<span class="source">${esc(r.lastActivity)}</span>`}` : 'unknown'}</td></tr>`).join('')}</tbody></table></div>`;
}
function registered(r) {
const reg = r.registered;
@@ -267,7 +269,7 @@
$('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>';
$('waiting-count').textContent = waiting.length; $('waiting').innerHTML = waiting.length ? cards(waiting) : '<div class="empty"><b>Nothing is waiting on you.</b><p>A session shows here when its agent stops on a question or an error and you have not marked it seen.</p></div>';
$('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));
@@ -275,21 +277,54 @@
$('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(); convForm(); if (conv) convStatus(); restore(focus);
fresh(); inspect(); convForm(); if (conv) convStatus(); restore(focus);
}
// Freshness line in the command bar: when the board last scanned, and warning words when the last refresh failed.
function fresh() {
const at = Date.parse(data?.generatedAt), ago = Number.isFinite(at) ? age(Math.max(0, Math.round((Date.now() - at) / 1000))) : null;
if (!data) { $('fresh-board').innerHTML = failed === 'refused' ? 'Board <span class="warn">not read: refused</span>' : failed ? 'Board <span class="warn">not read: the read failed</span>' : 'Board not read yet'; return; }
const scan = `Board scanned <b>${esc(Number.isFinite(at) ? `${new Date(at).toISOString().slice(11, 19)} UTC` : 'unknown')}</b>${ago ? ` (${ago} ago)` : ''}`;
$('fresh-board').innerHTML = failed ? `${scan} · <span class="warn">stale: the last refresh failed</span>` : scan;
}
setInterval(() => { if (data) fresh(); }, 5000);
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}`);
if (!res.ok) throw Object.assign(new Error(result.error || `HTTP ${res.status}`), { status: 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;
data = value; failed = false; $('error').hidden = true; $('error').className = 'banner err';
$('board-refused').hidden = true; document.body.classList.remove('board-refused');
for (const id of awaiting.keys()) { const r = row(id); if (r && !pending(r)) awaiting.delete(id); }
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.`; }
// A refusal (403) fails closed: the last data is dropped, not kept or shown as current, and so is
// everything drawn from it: the cards, the table, the inspector, an open conversation and its
// history, the project tree, the counts and the footer. Any other failure keeps the last data with
// its scan time. With no data, from a refusal or before the first scan, the board is drawn empty
// and says why, so a later failure never brings back old rows.
function error(err) {
const refused = err.status === 403;
failed = refused ? 'refused' : true; $('error').hidden = false;
if (refused) { data = null; selected = null; project = null; if (conv) { $('conv-log').replaceChildren(); closeConversation(); } }
if (!data) {
const why = refused ? 'the board refused the read' : 'the read failed';
$('projects').innerHTML = `<p class="muted">Not read: ${why}.</p>`;
for (const id of ['waiting', 'seen', 'sessions']) $(id).replaceChildren();
for (const id of ['waiting-count', 'seen-count', 'session-count']) $(id).textContent = '–';
$('footer').textContent = `Board not read: ${refused ? 'refused' : 'the read failed'}`;
$('status').textContent = `Not read: ${why}`;
inspect();
}
$('error').className = refused ? 'banner ref' : 'banner err';
$('board-refused').hidden = !refused; document.body.classList.toggle('board-refused', refused);
$('error').innerHTML = refused
? `<p><b>The board refused the read.</b> refused: <code>${esc(err.message)}</code> Board: ${esc(board)}. Console shows nothing rather than a guess. Use Refresh to try again.</p>`
: `<p><b>${esc(err.message)}</b> Board: ${esc(board)}. ${data ? `Showing last known data.${data.generatedAt ? ` Scanned ${esc(data.generatedAt)}.` : ''}` : 'No board data loaded.'} Use Refresh to try again.</p>`;
fresh();
}
function schedule() { clearTimeout(timer); if (!paused) timer = setTimeout(refresh, 10000); }
async function refresh() {
if (busy) return;
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
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:
https://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,12 @@
JetBrains Mono 2.304
Release: https://github.com/JetBrains/JetBrainsMono/releases/tag/v2.304
Archive: https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip
Archive sha256: 6f6376c6ed2960ea8a963cd7387ec9d76e3f629125bc33d1fdcd7eb7012f7bbf
License: OFL.txt in the archive, copied as jetbrains-mono-OFL.txt
400: fonts/webfonts/JetBrainsMono-Regular.woff2 -> jetbrains-mono-400.woff2
500: fonts/webfonts/JetBrainsMono-Medium.woff2 -> jetbrains-mono-500.woff2
700: fonts/webfonts/JetBrainsMono-Bold.woff2 -> jetbrains-mono-700.woff2
a9cb1cd82332b23a47e3a1239d25d13c86d16c4220695e34b243effa999f45f2 jetbrains-mono-400.woff2
086c48dfbea9ddaff1320f7e09399b8e2924e88ce67453721255db3bdbb5a353 jetbrains-mono-500.woff2
c503cc5ec5f8b2c7666b7ecda1adf44bd45f2e6579b2eba0fc292150416588a2 jetbrains-mono-700.woff2
30f0c136e3c88e422d0791acd97238870f9054a9729bc34cf2ff0d4ed8cac4ad jetbrains-mono-OFL.txt
+105 -20
View File
@@ -181,6 +181,12 @@
const when = iso => iso ? `<time datetime="${esc(iso)}" title="${esc(iso)}">${esc(ago(iso))}</time>` : 'never';
const clock = iso => iso ? `<time datetime="${esc(iso)}" title="${esc(iso)}">${esc(String(iso).slice(11, 16))}</time>` : '';
const badge = (text, kind = 'muted') => `<span class="badge badge-${kind}">${txt(text)}</span>`;
// Class chip: the decision class, the action in mono. BLOCKING is its own chip (CSS capitalises it; the text stays lowercase).
const CLASSES = ['routine', 'within-role', 'cross-role', 'gated'];
const chip = (cls, action) => `<span class="tag${CLASSES.includes(cls) ? ` tag-${cls}` : ''}">${txt(cls)} · <code>${txt(action)}</code></span>`;
const blockChip = text => `<span class="tag tag-block">${txt(text)}</span>`;
// An empty state says what would appear and where it comes from.
const empty = (what, from) => `<div class="empty"><b>${what}</b>${from ? `<p>${from}</p>` : ''}</div>`;
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(String(ref).split('/')[1])}</a>` : '';
// Rows without an actor role come from the human (CLI, outside any run) or from the broker itself.
const actor = (a, by) => `<span class="source">${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : 'broker'}</span>`;
@@ -200,10 +206,10 @@
// ---------------------------------------------------------------- views
let inboxCount = null;
function sections(route) {
const items = [['/', 'Control board', ''], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${badge(`${inboxCount.blocking} blocking`, 'attn')}` : ''}` : ''], ['/tasks', 'Tasks', ''], ['/agents', 'Agents', '']];
$('sections').innerHTML = items.map(([h, l, extra]) => {
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents']];
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && route.startsWith('/trail/decision'));
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}>${l}${extra ? ` ${extra}` : ''}</a></li>`;
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true"><use href="/icons.svg#i-${icon}"/></svg>${l}${extra ? ` ${extra}` : ''}</a></li>`;
}).join('');
}
function countInbox(rows) { inboxCount = { open: rows.length, blocking: rows.filter(d => d.blocking).length }; }
@@ -211,7 +217,7 @@
// Inbox
function decisionCard(d) {
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
<p class="s1-badges">${d.blocking ? badge('blocking', 'attn') : ''}${badge(`${d.class} · ${d.action}`)}</p>
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}</p>
<p class="small s1-auth">${approves(d.authorization)}</p>
<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
}
@@ -219,7 +225,7 @@
// The broker orders gated first, then oldest; lift blocking ones to the top.
const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at)));
countInbox(rows);
const body = rows.length ? `<ul class="waiting s1-decs">${rows.map(decisionCard).join('')}</ul>` : '<p class="state">Nothing is waiting on you.</p>';
const body = rows.length ? `<ul class="waiting s1-decs">${rows.map(decisionCard).join('')}</ul>` : empty('Nothing is waiting on you.', 'A decision shows here when an agent raises one routed to you through the bus.');
return `${head('Inbox', `${readNote()} Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.`)}
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
@@ -245,8 +251,8 @@
: !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : '';
return `${head(`Decision ${txt(short(d.id))}`, status)}
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>
<dl class="kv"><dt>Class</dt><dd>${txt(d.class)} · ${txt(d.action)}${d.blocking ? ` ${badge('blocking', 'attn')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status" role="status"></p></section>
<dl class="kv"><dt>Class</dt><dd>${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status"></p></section>
<section aria-labelledby="b-c"><h2 id="b-c">Earlier versions</h2>${earlier.length ? `<ul class="s1-plain">${earlier.map(x => `<li><a href="#/inbox/${enc(x.id)}">${txt(short(x.id))}</a> ${badge(x.closed ? x.closed.op : 'open')} ${txt(first(x.question))}${x.closed?.note ? `<span class="source">${txt(x.closed.note)}</span>` : ''}</li>`).join('')}</ul>` : '<p class="muted">None.</p>'}<p class="small muted">${gap('A later version that replaces this one')}</p></section>
<section aria-labelledby="b-h"><h2 id="b-h">History</h2>${history.length ? `<ul class="s1-plain">${history.join('')}</ul>` : '<p class="muted">Not seen yet.</p>'}<p><a href="#/trail/decision/${enc(d.id)}">Full trail for this decision</a></p></section>`;
}
@@ -273,7 +279,7 @@
const byChanged = (x, y) => String(y.changedAt).localeCompare(String(x.changedAt));
async function tasksView(get) {
const rows = await get('tasks'), ts = rows.map(r => task(r));
if (!ts.length) return `${head('Tasks', freshness(rows))}<p class="state">No tasks in this business yet. Tasks appear after the PM creates them or the poller reads them from Vikunja.</p>`;
if (!ts.length) return `${head('Tasks', freshness(rows))}${empty('No tasks in this business yet.', 'Tasks appear after the PM creates them or the poller reads them from Vikunja.')}`;
const open = ts.filter(t => !t.missing && !t.done), done = ts.filter(t => !t.missing && t.done).sort(byChanged), missing = ts.filter(t => t.missing);
const buckets = [...new Set(open.map(t => t.bucket))].sort((a, b) => a - b);
const groups = buckets.map(b => {
@@ -282,7 +288,7 @@
}).join('');
const doneHtml = done.length ? `<details class="s1-done" id="b-done"><summary><h2 id="b-done-title">Done <span class="count">${done.length}</span></h2></summary>${taskTable(done.slice(0, 50), 'Done tasks')}${done.length > 50 ? `<p class="small muted">The 50 most recently changed of ${done.length}.</p>` : ''}</details>` : '';
const missingHtml = missing.length ? `<section aria-labelledby="b-missing"><h2 id="b-missing">No longer readable <span class="count">${missing.length}</span></h2><ul class="s1-plain">${missing.map(t => `<li><a href="#/tasks/${enc(t.ref)}">${title(t)}</a> ${badge(`missing: ${t.missing.reason}`, 'danger')} <span class="source">since ${clock(t.missing.at)}</span></li>`).join('')}</ul></section>` : '';
return `${head('Tasks', freshness(rows))}<p class="small muted">Edit tasks in Vikunja; Console only reads them. ${gap('Bucket names, bot names and the Vikunja address')}</p>${groups || '<p class="state">No open tasks.</p>'}${doneHtml}${missingHtml}`;
return `${head('Tasks', freshness(rows))}<p class="small muted">Edit tasks in Vikunja; Console only reads them. ${gap('Bucket names, bot names and the Vikunja address')}</p>${groups || empty('No open tasks.', 'Open tasks are read from Vikunja; done and unreadable ones are listed below.')}${doneHtml}${missingHtml}`;
}
async function taskView(get, ref) {
const row = (await get('tasks')).find(r => r.task_ref === ref);
@@ -305,7 +311,7 @@
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${txt(c.holder_run)}</td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
return `${head('Agents', readNote())}
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear.</p>
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : '<p class="state">No role is held right now.</p>'}</section>
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}</section>
<p class="small muted">Not shown here yet: ${gap('Role instances, launch limits and model families')} ${gap('Launch state')} ${gap('Live and ended sessions')} ${gap('Credential notices')}</p>
<p class="small muted">Board seats stay on the <a href="#/">control board</a>. This view is about roles.</p>`;
}
@@ -314,7 +320,7 @@
const GROUPS = [['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']];
function trailList(rows, filter) {
const shown = filter === 'all' ? rows : rows.filter(r => r.group === filter);
if (!shown.length) return '<p class="muted">Nothing of this kind in the trail.</p>';
if (!shown.length) return empty('Nothing of this kind in the trail.', 'Every step the stack takes on this subject writes a trail row.');
return `<ol class="s1-trail">${shown.map(r => `<li class="s1-trow s1-g-${esc(r.group)}"><span class="s1-twhen">${clock(r.at)}</span><span class="s1-tkind">${txt(r.kind)}</span><div class="s1-ttext"><p>${txt(r.text)}${r.decision && !r.kind.startsWith('decision.') && !r.kind.startsWith('delivery.') && SAFE.test(String(r.decision)) ? ` <a href="#/inbox/${enc(r.decision)}">decision ${txt(short(r.decision))}</a>` : ''}</p>${actor(r.actor, r.by)}${r.note ? `<span class="source">${esc(r.note)}</span>` : ''}</div></li>`).join('')}</ol>`;
}
async function trailView(get, kind, ref, q) {
@@ -341,8 +347,8 @@
}
// Not found and states
function notFound(why) { return `${head('Not found')}<p class="state">${why || 'No page at this address.'} <a href="#/inbox">Go to the inbox</a></p>`; }
const loadingView = () => `${head('Reading…')}<p class="state" aria-busy="true"><span class="spinner" aria-hidden="true"></span>Reading from the bus.</p>`;
function notFound(why) { return `${head('Not found')}<div class="empty nf"><b>${why || 'No page at this address.'}</b><p>Nothing is at <code>${txt(location.hash || '#/')}</code>. <a href="#/">Go to the Board</a> or press <kbd>Ctrl K</kbd> to search.</p></div>`; }
const loadingView = () => `${head('Reading…')}<div class="skel-rows" aria-busy="true"><span class="sr-only">Reading from the bus.</span><span class="skel"></span><span class="skel"></span><span class="skel"></span><span class="skel"></span></div>`;
const retry = '<div class="actions"><button type="button" class="btn" data-retry>Read again</button></div>';
// Why a read failed, in words, with the bus code as given. Never a path or a guess.
function why(err) {
@@ -361,8 +367,18 @@
return `${say} (${code})`;
}
const failTitle = err => err.status === 403 ? 'Bus refused the read' : ['not-configured', 'no-bus-host'].includes(err.code) ? 'No bus to read' : 'The read failed';
const failedView = err => `${head(failTitle(err))}<div class="state state-error" role="alert"><p><b>${why(err)}</b> Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>`;
const staleBanner = err => `<div class="state state-error s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
// A refusal fails closed: no kept data, the code in mono. Any other failure keeps the last read, labelled with its time.
const refusal = err => err.status === 403 || ['not-configured', 'no-bus-host'].includes(err.code);
// After a refusal nothing read before is shown anywhere: not in a view, the palette or the Inbox count.
function forget() { last.clear(); inboxCount = null; }
const failedView = err => `${head(failTitle(err))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : 'Read again, or come back when the bus answers.')}`;
const staleBanner = err => `<div class="banner err s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
// Freshness line in the command bar for bus pages.
const stamp = iso => { const t = Date.parse(iso); return Number.isFinite(t) ? `${new Date(t).toISOString().slice(11, 19)} UTC` : 'unknown'; };
function freshBus(state) {
$('fresh-bus').innerHTML = state === 'refused' ? 'Bus <span class="warn">not read: refused</span>' : state === 'failed' ? 'Bus <span class="warn">not read: the read failed</span>'
: `Bus read <b>${esc(stamp(readAt))}</b>${readAt ? ` (${esc(ago(readAt))})` : ''}${state === 'stale' ? ' · <span class="warn">stale: the last read failed</span>' : ''}`;
}
// ---------------------------------------------------------------- router
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
@@ -406,20 +422,23 @@
if (board) {
// The board page keeps its own title; the Inbox count still follows the bus.
if (shown !== null) { shown = null; $('bus-view').innerHTML = ''; document.title = 'Mosaic Console'; if (navigated) $('main').focus(); }
try { const r = await fetchRead('inbox'); last.set('inbox', r); if (g === gen) { countInbox(r.rows); sections(path); } } catch {}
try { const r = await fetchRead('inbox'); last.set('inbox', r); if (g === gen) { countInbox(r.rows); sections(path); } }
catch (err) { if (refusal(err)) { forget(); if (g === gen) sections(path); } }
if (g === gen) schedule();
return;
}
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(); }
let html;
let html, state = 'fresh';
const live = reader(true);
try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
catch (err) {
const kept = reader(false);
try { html = await view(kept, path, new URLSearchParams(query)); readAt = kept.oldest(); html = staleBanner(err) + html; }
catch { html = failedView(err); }
try { if (refusal(err)) throw err; html = await view(kept, path, new URLSearchParams(query)); readAt = kept.oldest(); html = staleBanner(err) + html; state = 'stale'; }
catch { html = failedView(err); state = refusal(err) ? 'refused' : 'failed'; }
if (refusal(err)) forget();
}
if (g !== gen) return;
freshBus(state);
const k = !navigated && shown === route ? keep() : null;
$('bus-view').innerHTML = html; shown = route;
sections(path);
@@ -439,16 +458,82 @@
const b = e.target.closest('#bus-view [data-copy]');
if (b) {
const text = b.dataset.copy, out = $('copy-status');
try { await navigator.clipboard.writeText(text); out.textContent = `Copied: ${text}`; }
try { await navigator.clipboard.writeText(text); out.textContent = `Copied: ${text}`; toast(out.textContent); }
catch {
const r = document.createRange(); r.selectNodeContents(b.previousElementSibling);
const s = getSelection(); s.removeAllRanges(); s.addRange(r);
out.textContent = 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.';
toast(out.textContent, true);
}
return;
}
if (e.target.closest('#toast [data-dismiss]')) { toast(null); return; }
const item = e.target.closest('#cmdk-list [data-href]');
if (item) { pkGo(item.dataset.href); return; }
if (e.target.closest('#cmdk')) { pkOpen(); return; }
if (e.target.closest('#bus-view [data-retry]')) render(false);
});
// ---------------------------------------------------------------- toast
// One line in a polite live region. Success goes after 2.6 s; an error stays until dismissed.
let toastTimer;
function toast(text, error = false) {
const t = $('toast'); clearTimeout(toastTimer);
if (text === null) { t.className = 'toast'; t.textContent = ''; return; }
t.className = `toast on${error ? ' err' : ''}`;
t.innerHTML = `<span>${esc(text)}</span>${error ? '<button type="button" data-dismiss>Dismiss</button>' : ''}`;
if (!error) toastTimer = setTimeout(() => toast(null), 2600);
}
// ---------------------------------------------------------------- command palette
// Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and
// tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus
// back where it was. It only navigates.
const VIEWS = [['#/', 'Board'], ['#/inbox', 'Inbox'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents']];
let pkAll = [], pkShown = [], pkAt = 0, pkBack = null;
function pkItems() {
const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` }));
const tasks = (last.get('tasks')?.rows || []).filter(r => TASK.test(String(r.task_ref))).map(r => { const t = task(r); return { href: `#/tasks/${enc(t.ref)}`, label: `#${t.id} ${t.title ?? '(no title in the current read)'}`, group: 'task' }; });
pkAll = [...VIEWS.map(([href, label]) => ({ href, label, group: 'view' })), ...decisions, ...tasks];
}
function pkFill() {
const q = $('cmdk-input').value.trim().toLowerCase();
pkShown = pkAll.filter(i => !q || `${i.label} ${i.group}`.toLowerCase().includes(q)).slice(0, 50);
pkAt = Math.min(pkAt, Math.max(0, pkShown.length - 1));
$('cmdk-list').innerHTML = pkShown.length ? pkShown.map((i, n) => `<li role="option" id="cmdk-opt-${n}" data-href="${esc(i.href)}" aria-selected="${n === pkAt}"><span>${txt(i.label)}</span><span class="g">${txt(i.group)}</span></li>`).join('')
: '<li class="pk-none" role="option" aria-disabled="true" aria-selected="false">No view, decision or task matches.</li>';
if (pkShown.length) { $('cmdk-input').setAttribute('aria-activedescendant', `cmdk-opt-${pkAt}`); $(`cmdk-opt-${pkAt}`)?.scrollIntoView({ block: 'nearest' }); }
else $('cmdk-input').removeAttribute('aria-activedescendant');
}
function pkOpen() {
const d = $('cmdk-dialog');
if (d.open) return;
pkBack = document.activeElement; pkAt = 0; $('cmdk-input').value = '';
pkItems(); pkFill(); d.showModal(); $('cmdk-input').focus();
Promise.allSettled(['inbox', 'tasks'].map(async v => last.set(v, await fetchRead(v)))).then(rs => {
if (rs.some(r => r.status === 'rejected' && refusal(r.reason))) { forget(); sections(routeOf().split('?')[0]); }
if (d.open) { pkItems(); pkFill(); }
});
}
function pkGo(href) {
pkBack = null; $('cmdk-dialog').close();
if (location.hash === href || (href === '#/' && !location.hash.startsWith('#/'))) { render(true); return; }
location.hash = href;
}
$('cmdk-dialog').addEventListener('close', () => { const b = pkBack; pkBack = null; if (b && document.contains(b)) b.focus(); });
$('cmdk-input').addEventListener('input', () => { pkAt = 0; pkFill(); });
$('cmdk-input').addEventListener('keydown', e => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (pkShown.length) { pkAt = (pkAt + (e.key === 'ArrowDown' ? 1 : -1) + pkShown.length) % pkShown.length; pkFill(); } }
else if (e.key === 'Enter') { e.preventDefault(); if (pkShown[pkAt]) pkGo(pkShown[pkAt].href); }
});
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pkOpen(); return; }
// Up and down move between the task links of a table, as on the board's session table.
const a = e.target.closest?.('#bus-view table.s1-table td.s1-title a');
if (!a || (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')) return;
const links = [...a.closest('table').querySelectorAll('td.s1-title a')], i = links.indexOf(a);
e.preventDefault(); links[i + (e.key === 'ArrowDown' ? 1 : -1)]?.focus();
});
window.addEventListener('hashchange', () => render(true));
$('refresh').addEventListener('click', () => render(false));
$('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
+23
View File
@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg">
<!-- Mosaic Stack icons, draft 1. 24-unit grid, 1.75 stroke, round caps and joins, no fill.
Use: <svg class="i" aria-hidden="true"><use href="icons.svg#i-inbox"/></svg> with color set by CSS.
"mark" is the placeholder Relay mark (fill, not stroke). -->
<symbol id="mark" viewBox="0 0 16 17" fill="currentColor"><path fill-rule="evenodd" d="M1 7 5 3 9 3 12 6 9 9 6 6 4 8 4 9 7 12 5 14 1 10ZM15 10 11 14 7 14 4 11 7 8 10 11 12 9 12 8 9 5 11 3 15 7Z"/></symbol>
<symbol id="i-board" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="13.5" width="7" height="7" rx="1.5"/></symbol>
<symbol id="i-inbox" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 13.5 6 5.5h12l2.5 8v5a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/><path d="M3.5 13.5h5l1 2.5h5l1-2.5h5"/></symbol>
<symbol id="i-talk" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 5h15a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H10l-4.5 3.5V16h-1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z"/><path d="M8 10.5h8"/></symbol>
<symbol id="i-tasks" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M10 6.5h10M10 12h10M10 17.5h10"/><path d="m3.5 6.5 1.5 1.5 2.5-3"/><path d="m3.5 12 1.5 1.5 2.5-3"/><path d="M4 17.5h3"/></symbol>
<symbol id="i-agents" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8.5" r="3"/><path d="M3.5 19.5c.6-3.2 2.8-5 5.5-5s4.9 1.8 5.5 5"/><circle cx="17" cy="9.5" r="2.4"/><path d="M15.8 14.6c2.2.2 3.9 1.8 4.7 4.9"/></symbol>
<symbol id="i-trail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5.5" r="2"/><circle cx="18" cy="18.5" r="2"/><path d="M8 5.5h7.5a3.25 3.25 0 0 1 0 6.5h-7a3.25 3.25 0 0 0 0 6.5H16"/></symbol>
<symbol id="i-queue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6.5h16M4 12h16M4 17.5h9"/><path d="m16.5 15.5 2 2 2-2"/></symbol>
<symbol id="i-runs" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5 20 8v8l-8 4.5L4 16V8z"/><path d="m4 8 8 4.5L20 8M12 12.5v8"/></symbol>
<symbol id="i-ledger" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 20.5V10M10 20.5V4M15 20.5v-8M20 20.5v-5"/></symbol>
<symbol id="i-business" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 20.5V8.5l6-4v16M10.5 20.5h9v-9l-9-3"/><path d="M3 20.5h18M14 13.5h2M14 16.5h2M6.5 10.5h2M6.5 13.5h2"/></symbol>
<symbol id="i-library" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 4.5h4v15h-4zM10 4.5h4v15h-4z"/><path d="m15.5 5.3 3.4-.9 3.1 14.3-3.4.9z"/></symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="6"/><path d="m20 20-4.5-4.5"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="8.5" y="8.5" width="11" height="11" rx="1.5"/><path d="M15.5 8.5v-3a1 1 0 0 0-1-1h-9a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h3"/></symbol>
<symbol id="i-close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M18 6 6 18"/></symbol>
<symbol id="i-term" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="m7.5 9.5 3 2.5-3 2.5M12.5 15h4"/></symbol>
<symbol id="i-flag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5.5 20.5v-16M5.5 4.5h12l-2.5 4 2.5 4h-12"/></symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

+11 -6
View File
@@ -1,24 +1,29 @@
<!doctype html>
<html lang="en" data-design="console" data-palette="harbor" data-mode="light">
<html lang="en" data-design="console" data-palette="harbor">
<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"><link rel="stylesheet" href="/bus.css">
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/shell.css">
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/bus.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>
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
<button type="button" class="cmdk" id="cmdk" aria-haspopup="dialog" aria-controls="cmdk-dialog"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><span>Go to a view, decision or task</span><kbd>Ctrl K</kbd></button>
<p class="fresh" id="fresh"><span id="fresh-board"></span><span id="fresh-bus"></span></p>
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">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="Console"><h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><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></div></nav>
<main id="main" class="content" tabindex="-1"><div class="page-head" id="board-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>
<div id="error" class="banner err" role="alert" hidden></div>
<div id="board-refused" class="empty" hidden><b>Nothing to show.</b><p>The board refused the read, so Console shows nothing rather than a guess. The reason is above.</p></div>
<section id="conversation" class="conversation" aria-labelledby="conv-title" hidden><div class="page-head"><div><h2 id="conv-title" tabindex="-1">Conversation</h2><p id="conv-meta" class="small muted"></p></div><div class="conv-actions"><label>Session<select id="conv-pick"></select></label><button class="btn" id="conv-back" type="button">Back to board</button></div></div>
<div id="conv-markers"></div><p id="conv-status" class="small muted" role="status"></p><ol id="conv-log" class="turns conv-log" aria-label="Conversation history"></ol>
<form id="conv-form" class="conv-form" hidden><label for="conv-reply" id="conv-reply-label">Reply</label><textarea id="conv-reply" maxlength="2000" rows="3" placeholder="Message this seat"></textarea><button class="btn primary" type="submit" id="conv-send">Send</button><p id="conv-receipt" class="conv-receipt" role="status" hidden></p></form><p id="conv-no-reply" class="muted" hidden></p></section>
<div id="board-view"><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></div>
<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 class="skel-rows" aria-busy="true"><span class="sr-only">Loading sessions…</span><span class="skel"></span><span class="skel"></span><span class="skel"></span><span class="skel"></span></div></div></section></div>
<div id="bus-view" hidden></div>
</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>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<dialog class="pk" id="cmdk-dialog" aria-label="Go to"><div class="pk-in"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><input id="cmdk-input" type="text" role="combobox" aria-expanded="true" aria-controls="cmdk-list" aria-autocomplete="list" aria-label="Go to a view, decision or task" autocomplete="off" spellcheck="false"></div><ul id="cmdk-list" class="pk-list" role="listbox" aria-label="Results"></ul><p class="pk-foot small"><kbd>↑</kbd><kbd>↓</kbd> move · <kbd>Enter</kbd> open · <kbd>Esc</kbd> close</p></dialog>
</body></html>
+177
View File
@@ -0,0 +1,177 @@
/* Row 53 (#1542): the console shell restyled to docs/design (tokens.css, icons.svg, IMPLEMENTING.md).
Loaded last, after tokens.css, so it overrides app.css, console.css, live.css and bus.css
without editing them. Colours come only from tokens.css; nothing here is set inline. */
@font-face{font-family:"JetBrains Mono";font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/jetbrains-mono-400.woff2) format("woff2")}
@font-face{font-family:"JetBrains Mono";font-style:normal;font-weight:500;font-display:swap;src:url(/assets/fonts/jetbrains-mono-500.woff2) format("woff2")}
@font-face{font-family:"JetBrains Mono";font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/jetbrains-mono-700.woff2) format("woff2")}
:root{--bar-h:53px;--foot-h:33px}
/* app.css sets a light --shadow and a darker one for html[data-mode=dark|dim]; System on a dark OS needs the darker one too. */
@media (prefers-color-scheme:dark){:root:not([data-mode]){--shadow:0 1px 2px rgb(0 0 0/.3),0 8px 24px rgb(0 0 0/.35)}}
/* Type scale: page title 28/800, section 20/800, question 16/700, text 14/1.5, table 13, labels 11/700 caps, mono 12.5. */
body{font:400 14px/1.5 var(--font);color:var(--text);background:var(--canvas)}
.content h1{font-size:28px;font-weight:800;letter-spacing:-.02em;color:var(--strong)}
.content h2,.inspector h2{font-size:20px;font-weight:800;letter-spacing:-.015em;color:var(--strong)}
.content h3{font-size:16px;font-weight:700}
.s1-q{font-size:16px;font-weight:700;color:var(--strong)}
.tree-title,.label{font:700 11px/1.3 var(--font);letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
code,kbd,pre,.mono{font-family:var(--mono);font-size:12.5px}
kbd{border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:0 .35em;background:var(--surface);color:var(--strong);white-space:nowrap;font-size:11.5px}
.count,td,.s1-twhen,time,.fresh,.kv dd{font-variant-numeric:tabular-nums}
:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
table.sessions tbody tr:focus-visible,table.s1-table tbody tr:focus-visible{outline:2px solid var(--focus);outline-offset:-2px}
svg.i{width:18px;height:18px;flex:none;fill:none;stroke:currentColor;stroke-width:1.75;stroke-linecap:round;stroke-linejoin:round}
/* Command bar: mark, Ctrl+K, freshness line, appearance. */
.cmdbar{flex-wrap:nowrap;gap:10px;padding:8px 12px;min-height:var(--bar-h);min-width:0}
.wordmark{color:var(--strong);font-weight:800;font-size:16px;flex-wrap:nowrap;white-space:nowrap}
.wordmark .mark{width:20px;height:auto;fill:var(--action);flex:none}
.wordmark small{font-size:11px}
.cmdk{flex:1 1 160px;max-width:460px;display:flex;align-items:center;gap:8px;min-height:36px;border:1px solid var(--line);background:var(--canvas);border-radius:var(--r);padding:5px 10px;color:var(--muted);cursor:pointer;text-align:left;min-width:0;font:inherit;font-size:13px}
.cmdk span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cmdk:hover{border-color:var(--border)}
.fresh{font-size:12px;color:var(--muted);white-space:nowrap;margin:0 0 0 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
.fresh b{color:var(--success);font-weight:600}
.fresh .warn{color:var(--warning);font-weight:700}
#fresh-bus,body.on-bus #fresh-board{display:none}
body.on-bus #fresh-bus{display:inline}
.fresh+.cmd-right{margin-left:0}
.cmd-right{flex-wrap:nowrap}
.cmd-right .btn{min-height:32px;padding:4px 10px;font-size:13px}
.cmd-right select{min-height:32px;font-size:13px;border-radius:var(--r)}
/* Labels sit beside their selects so the bar stays one row of --bar-h. */
.cmd-right label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
@media (max-width:1279px){.cmd-right label{font-size:0;gap:0}.cmd-right label select{font-size:13px}}
/* Section list: icon, label, count. */
.s1-section{gap:9px;padding:6px 8px;border:0;border-radius:var(--r);color:var(--text);font-size:13.5px;min-height:34px;flex-wrap:nowrap}
.s1-section:hover{background:var(--raised)}
.s1-section[aria-current=page]{background:color-mix(in srgb,var(--action) 13%,var(--surface));color:var(--strong);border:0}
.s1-section[aria-current=page] svg{color:var(--action)}
.s1-section .count{font:600 11px var(--mono);background:none;padding:0;color:var(--muted)}
.s1-section .count.hot{color:var(--surface);background:var(--danger);border-radius:999px;padding:0 6px}
/* Dense table. */
.table-wrap{border-radius:var(--r-table)}
table.sessions,table.s1-table{border-collapse:collapse;width:100%;font-size:13px}
table.sessions th,table.s1-table thead th{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;background:var(--surface)}
table.sessions td,table.s1-table td,table.s1-table tbody th{padding:7px 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);vertical-align:top}
table.sessions tbody tr:last-child td,table.s1-table tbody tr:last-child>*{border-bottom:0}
table.sessions tbody tr:hover,table.s1-table tbody tr:hover{background:color-mix(in srgb,var(--raised) 55%,var(--surface))}
table.sessions tbody tr.is-selected{background:color-mix(in srgb,var(--action) 10%,var(--surface));box-shadow:inset 3px 0 0 var(--action)}
table.s1-table td code{font-size:12.5px;white-space:nowrap}
/* Status mark: glyph and word, never colour alone. The glyph is generated content with empty alt text, so the word is what is read. */
.badge{font-size:12px;padding:1px 7px}
.st::before{width:auto;height:auto;background:none;border-radius:0;line-height:1;content:"?" / ""}
.st-working::before{content:"●" / ""}
.st-waiting::before{content:"◐" / ""}
.st-error::before{content:"▲" / ""}
.st-offline::before{content:"○" / ""}
.st-idle::before{content:"–" / ""}
/* Class chip. BLOCKING is its own chip. */
/* Inline-block, not flex: flex drops the space after the dot in "gated · <code>action</code>". */
.tag{display:inline-block;vertical-align:middle;font-size:12px;font-weight:600;line-height:18px;padding:1px 7px;border-radius:4px;background:var(--raised);color:var(--strong);white-space:nowrap;max-width:100%;overflow:hidden;text-overflow:ellipsis}
.tag code{font-size:11.5px}
.tag-gated{background:color-mix(in srgb,var(--danger) 14%,var(--surface));color:var(--danger)}
.tag-block{background:var(--danger);color:var(--surface);text-transform:uppercase;letter-spacing:.04em}
.tag-cross-role{background:color-mix(in srgb,var(--accent) 16%,var(--surface));color:var(--accent)}
.tag-within-role,.tag-routine{background:var(--raised);color:var(--muted)}
/* Copy command. */
.s1-cmd code{font-size:12.5px;border-radius:var(--r)}
/* View states: empty, banner (error and refusal), skeleton rows. */
.empty{padding:28px 16px;text-align:center;color:var(--muted);border:1px dashed var(--line);border-radius:var(--r-lg);background:var(--surface)}
.empty b{display:block;color:var(--strong);font-size:15px;margin-bottom:4px}
.empty p{margin:4px auto 0;max-width:62ch}
.banner{display:flex;flex-wrap:wrap;gap:6px 10px;align-items:flex-start;padding:10px 12px;border-radius:var(--r-table);margin-bottom:12px;border:1px solid;font-size:13px;color:var(--text);text-align:left}
.banner>p,.banner>div:not(.actions){flex:1 1 280px;min-width:0;margin:0;overflow-wrap:anywhere}
.banner .actions{margin:0}
.banner.err{border-color:color-mix(in srgb,var(--danger) 50%,var(--line));background:color-mix(in srgb,var(--danger) 8%,var(--surface))}
.banner.ref{border-color:color-mix(in srgb,var(--warning) 50%,var(--line));background:color-mix(in srgb,var(--warning) 9%,var(--surface))}
.banner b,.banner strong{color:var(--strong)}
.banner code{overflow-wrap:anywhere}
.skel-rows{display:grid;gap:10px;padding:14px 12px;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface)}
.skel{display:block;height:12px;border-radius:4px;background:var(--raised);animation:skel 1.4s ease-in-out infinite}
.skel:nth-child(odd){width:82%}
@keyframes skel{50%{opacity:.45}}
body.board-refused #board-view{display:none}
body.on-bus #board-refused{display:none}
/* Toast: one line, polite, bottom centre. */
.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);display:flex;align-items:center;gap:10px;background:var(--strong);color:var(--surface);padding:6px 8px 6px 14px;border-radius:var(--r);font-size:13px;z-index:60;max-width:calc(100% - 24px);box-shadow:var(--shadow)}
/* Off, the toast stays in the accessibility tree, empty and visually hidden, so the first copy is announced. */
.toast:not(.on){width:1px;height:1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;box-shadow:none}
.toast span{min-width:0;overflow-wrap:anywhere}
.toast.err{background:var(--danger)}
.toast button{font:inherit;font-weight:600;border:1px solid currentColor;background:none;color:inherit;border-radius:4px;padding:1px 8px;cursor:pointer}
.toast:not(.err) button{display:none}
/* Command palette. */
.pk{width:min(560px,calc(100% - 24px));padding:0;margin:10vh auto auto;background:var(--surface);color:var(--text);border:1px solid var(--line);border-radius:var(--r-lg);box-shadow:var(--shadow);overflow:hidden}
.pk::backdrop{background:color-mix(in srgb,var(--strong) 28%,transparent)}
.pk-in{display:flex;align-items:center;gap:8px;padding:0 14px;border-bottom:1px solid var(--line);color:var(--muted)}
.pk input{flex:1;min-width:0;border:0;padding:12px 0;background:none;font:inherit;font-size:15px;color:var(--strong)}
.pk input:focus{outline:none}
.pk-in:focus-within{box-shadow:inset 0 -2px 0 var(--focus)}
.pk-list{list-style:none;margin:0;padding:6px;max-height:min(340px,60vh);overflow:auto}
.pk-list li{display:flex;gap:10px;align-items:center;padding:7px 10px;border-radius:var(--r);cursor:pointer;font-size:13.5px;min-width:0}
.pk-list li>span:first-of-type{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pk-list li[aria-selected=true]{background:color-mix(in srgb,var(--action) 14%,var(--surface));color:var(--strong)}
.pk-list li .g{margin-left:auto;font-size:11px;color:var(--muted);white-space:nowrap}
.pk-list .pk-none{cursor:default;color:var(--muted)}
.pk-foot{margin:0;padding:6px 14px;border-top:1px solid var(--line);color:var(--muted)}
/* Not found. */
.nf code{overflow-wrap:anywhere}
/* Two columns and up: the footer is a one-line status bar held at the bottom, and the side column
fits between it and the command bar, so a short page does not scroll and a long one keeps the nav in view. */
@media (min-width:760px){
footer.foot{position:sticky;bottom:0;z-index:15;background:var(--surface);min-height:var(--foot-h);padding:6px var(--pad);align-items:center;flex-wrap:nowrap;font-size:12px}
footer.foot>*{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;overflow-wrap:normal}
.frame{min-height:calc(100vh - var(--bar-h) - var(--foot-h))}
.tree,.inspector{top:var(--bar-h);height:calc(100vh - var(--bar-h) - var(--foot-h))}
}
/* The live region after the footer sits at its static place, one pixel past the screen; pin it so it adds no scroll. */
#announce{top:0;left:0}
/* Breakpoints. 760 to 959 px keeps the side column (console.css drops it at 959). */
@media (min-width:760px) and (max-width:959px){
.frame,body.has-inspector .frame,body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:200px minmax(0,1fr)}
.tree{position:sticky;top:var(--bar-h);height:calc(100vh - var(--bar-h) - var(--foot-h));border-right:1px solid var(--line);border-bottom:0;flex-direction:column;flex-wrap:nowrap;align-items:stretch}
.tree-title{flex:none}
.tree-list{display:grid;flex-wrap:nowrap;overflow:visible}
.tree .small{flex-basis:auto;margin-top:auto}
.s1-sections{display:grid;gap:2px}
.s1-section{border-radius:var(--r);padding:6px 8px}
.cmdbar{position:sticky}
}
@media (max-width:1099px){
body.has-inspector .frame{grid-template-columns:240px minmax(0,1fr)}
.inspector{position:fixed;top:var(--bar-h);right:0;bottom:0;left:auto;width:min(400px,100%);height:auto;max-height:none;border-left:1px solid var(--line);border-top:0;border-radius:0;z-index:30;box-shadow:-12px 0 30px -12px color-mix(in srgb,#000 35%,transparent)}
}
@media (max-width:959px){body.has-inspector .frame{grid-template-columns:minmax(0,1fr)}}
@media (min-width:760px) and (max-width:959px){body.has-inspector .frame{grid-template-columns:200px minmax(0,1fr)}}
@media (max-width:759px){
.cmdbar{position:static;flex-wrap:wrap}
/* One column: the nav and the page take their own height, not a share of the screen. */
.frame{align-content:start}
.cmdk{flex:1 1 100%;max-width:none;order:3}
.fresh{display:none}
/* Both selects and both buttons share one row; the selects give up width first. */
.cmd-right{margin-left:0;flex:1 1 100%;flex-wrap:nowrap;min-width:0}
.cmd-right label{flex:1 1 0;min-width:0}
.cmd-right .btn{flex:none}
/* The section list is one strip that scrolls inside itself (IMPLEMENTING.md), so more sections never add rows. */
.s1-sections{display:flex;flex-wrap:nowrap;overflow-x:auto;flex-basis:100%;min-width:0;max-width:100%;gap:2px;padding-bottom:2px;scrollbar-width:thin}
.s1-sections>li{flex:none}
.s1-section{border-radius:var(--r);padding:5px 8px;white-space:nowrap;flex:none}
.inspector{inset:0;top:0;width:100%;z-index:40;box-shadow:none}
}
/* Below 360 px one row would cut the select labels; the buttons wrap under them instead. */
@media (max-width:359px){.cmd-right{flex-wrap:wrap}.cmd-right label{flex:1 1 110px}}
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
@media (forced-colors:active){.tag,.empty,.banner,.toast,.pk{border:1px solid CanvasText}.s1-section[aria-current=page]{outline:2px solid Highlight}table.sessions tbody tr.is-selected{outline:2px solid Highlight}}
+813
View File
@@ -0,0 +1,813 @@
/* Generated from tokens.json by tools/build-tokens.mjs. Do not edit by hand. */
/* Fonts: Manrope and JetBrains Mono. Self-host the woff2 files: the console's CSP blocks external hosts. packages/webui already serves Manrope 400-700; weight 800 and JetBrains Mono 400/500/700 still need adding (or drop 800 and use 700). The design preview loads both from Google Fonts only because it is a standalone page. */
:root {
--font: "Manrope", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
--r: 6px;
--r-lg: 10px;
--r-table: 8px;
--pad: 16px;
--gap: 12px;
}
/* Harbor: the default palette */
:root {
--canvas: #f3f5f7;
--surface: #ffffff;
--raised: #e6eaef;
--line: #ced5df;
--border: #636e7e;
--text: #434d5b;
--muted: #5e6978;
--action: #2266bf;
--onAction: #ffffff;
--accent: #8d5e20;
--focus: #2266bf;
--success: #287658;
--warning: #8f5c19;
--danger: #ba2e26;
--strong: color-mix(in srgb, var(--text) 72%, #000);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-mode="light"]):not([data-mode="dim"]) {
--canvas: #12161c;
--surface: #1c232b;
--raised: #28323e;
--line: #384556;
--border: #a4acb7;
--text: #b9c1cb;
--muted: #a4acb7;
--action: #74a6e7;
--onAction: #10141a;
--accent: #e1b47a;
--focus: #74a6e7;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e37d78;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
}
:root[data-mode="dark"] {
--canvas: #12161c;
--surface: #1c232b;
--raised: #28323e;
--line: #384556;
--border: #a4acb7;
--text: #b9c1cb;
--muted: #a4acb7;
--action: #74a6e7;
--onAction: #10141a;
--accent: #e1b47a;
--focus: #74a6e7;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e37d78;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
:root[data-mode="dim"] {
--canvas: #242d38;
--surface: #2e3947;
--raised: #384556;
--line: #47576c;
--border: #a4acb7;
--text: #d6dbe1;
--muted: #a9b1bc;
--action: #8ab4ea;
--onAction: #10141a;
--accent: #e1b47a;
--focus: #8ab4ea;
--success: #85d5b7;
--warning: #e7b574;
--danger: #ea9d99;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
/* Carmine */
:root[data-palette="carmine"] {
--canvas: #f7f3f3;
--surface: #ffffff;
--raised: #efe7e8;
--line: #deced1;
--border: #7e6367;
--text: #5e4549;
--muted: #7b6065;
--action: #b92740;
--onAction: #ffffff;
--accent: #2e706c;
--focus: #b92740;
--success: #287658;
--warning: #8f5c19;
--danger: #ba2e26;
--strong: color-mix(in srgb, var(--text) 72%, #000);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root[data-palette="carmine"]:not([data-mode="light"]):not([data-mode="dim"]) {
--canvas: #1b1214;
--surface: #2b1d1f;
--raised: #3d292c;
--line: #55393e;
--border: #b7a4a7;
--text: #c9b6b9;
--muted: #b7a4a7;
--action: #e2788a;
--onAction: #10141a;
--accent: #8bd0cb;
--focus: #e2788a;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e37d78;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
}
:root[data-palette="carmine"][data-mode="dark"] {
--canvas: #1b1214;
--surface: #2b1d1f;
--raised: #3d292c;
--line: #55393e;
--border: #b7a4a7;
--text: #c9b6b9;
--muted: #b7a4a7;
--action: #e2788a;
--onAction: #10141a;
--accent: #8bd0cb;
--focus: #e2788a;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e37d78;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
:root[data-palette="carmine"][data-mode="dim"] {
--canvas: #372528;
--surface: #462f33;
--raised: #55393e;
--line: #6b484e;
--border: #b7a4a7;
--text: #ded3d5;
--muted: #bca9ac;
--action: #e996a4;
--onAction: #10141a;
--accent: #8bd0cb;
--focus: #e996a4;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e99995;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
/* Atlantic */
:root[data-palette="atlantic"] {
--canvas: #f3f6f7;
--surface: #ffffff;
--raised: #e6ecef;
--line: #cdd9df;
--border: #63757e;
--text: #3f4e55;
--muted: #5c6d75;
--action: #19719a;
--onAction: #ffffff;
--accent: #257467;
--focus: #19719a;
--success: #287658;
--warning: #935f1a;
--danger: #ba2e26;
--strong: color-mix(in srgb, var(--text) 72%, #000);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root[data-palette="atlantic"]:not([data-mode="light"]):not([data-mode="dim"]) {
--canvas: #12191c;
--surface: #1c272b;
--raised: #28373e;
--line: #384d57;
--border: #a4b1b7;
--text: #bcc8cd;
--muted: #a4b1b7;
--action: #73c3e8;
--onAction: #10141a;
--accent: #83d8ca;
--focus: #73c3e8;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e4817c;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
}
:root[data-palette="atlantic"][data-mode="dark"] {
--canvas: #12191c;
--surface: #1c272b;
--raised: #28373e;
--line: #384d57;
--border: #a4b1b7;
--text: #bcc8cd;
--muted: #a4b1b7;
--action: #73c3e8;
--onAction: #10141a;
--accent: #83d8ca;
--focus: #73c3e8;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e4817c;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
:root[data-palette="atlantic"][data-mode="dim"] {
--canvas: #243238;
--surface: #2e3f47;
--raised: #384d57;
--line: #46606d;
--border: #a4b1b7;
--text: #e2e7e9;
--muted: #afbbc0;
--action: #73c3e8;
--onAction: #10141a;
--accent: #83d8ca;
--focus: #73c3e8;
--success: #85d5b7;
--warning: #e7b574;
--danger: #edaaa6;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
/* Terracotta */
:root[data-palette="terracotta"] {
--canvas: #f7f4f3;
--surface: #ffffff;
--raised: #eee9e7;
--line: #ded3cf;
--border: #7e6b63;
--text: #584841;
--muted: #78665e;
--action: #a74f2a;
--onAction: #ffffff;
--accent: #356f88;
--focus: #a74f2a;
--success: #287658;
--warning: #8f5c19;
--danger: #ba2e26;
--strong: color-mix(in srgb, var(--text) 72%, #000);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root[data-palette="terracotta"]:not([data-mode="light"]):not([data-mode="dim"]) {
--canvas: #1b1513;
--surface: #2a211d;
--raised: #3c2f2a;
--line: #54423b;
--border: #b7a9a4;
--text: #cbbeb9;
--muted: #b7a9a4;
--action: #de9a7c;
--onAction: #10141a;
--accent: #89bcd1;
--focus: #de9a7c;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e37d78;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
}
:root[data-palette="terracotta"][data-mode="dark"] {
--canvas: #1b1513;
--surface: #2a211d;
--raised: #3c2f2a;
--line: #54423b;
--border: #b7a9a4;
--text: #cbbeb9;
--muted: #b7a9a4;
--action: #de9a7c;
--onAction: #10141a;
--accent: #89bcd1;
--focus: #de9a7c;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e37d78;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
:root[data-palette="terracotta"][data-mode="dim"] {
--canvas: #362b26;
--surface: #453630;
--raised: #54423b;
--line: #695349;
--border: #b7a9a4;
--text: #e5dfdc;
--muted: #beb2ac;
--action: #e2a68d;
--onAction: #10141a;
--accent: #89bcd1;
--focus: #e2a68d;
--success: #85d5b7;
--warning: #e7b574;
--danger: #eba29e;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
/* Aubergine */
:root[data-palette="aubergine"] {
--canvas: #f5f4f6;
--surface: #ffffff;
--raised: #ece8ed;
--line: #d9d2db;
--border: #78637e;
--text: #59455e;
--muted: #75607b;
--action: #88469b;
--onAction: #ffffff;
--accent: #77672c;
--focus: #88469b;
--success: #287658;
--warning: #8f5c19;
--danger: #ba2e26;
--strong: color-mix(in srgb, var(--text) 72%, #000);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root[data-palette="aubergine"]:not([data-mode="light"]):not([data-mode="dim"]) {
--canvas: #18141a;
--surface: #262028;
--raised: #362d39;
--line: #4c3f50;
--border: #b3a4b7;
--text: #c7b9cb;
--muted: #b3a4b7;
--action: #bf8ecc;
--onAction: #10141a;
--accent: #d3c388;
--focus: #bf8ecc;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e37d78;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
}
:root[data-palette="aubergine"][data-mode="dark"] {
--canvas: #18141a;
--surface: #262028;
--raised: #362d39;
--line: #4c3f50;
--border: #b3a4b7;
--text: #c7b9cb;
--muted: #b3a4b7;
--action: #bf8ecc;
--onAction: #10141a;
--accent: #d3c388;
--focus: #bf8ecc;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e37d78;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
:root[data-palette="aubergine"][data-mode="dim"] {
--canvas: #312933;
--surface: #3e3441;
--raised: #4c3f50;
--line: #5f4f63;
--border: #b3a4b7;
--text: #e1d9e3;
--muted: #baacbe;
--action: #cba4d6;
--onAction: #10141a;
--accent: #d3c388;
--focus: #cba4d6;
--success: #85d5b7;
--warning: #e7b574;
--danger: #ea9d99;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
/* Mineral */
:root[data-palette="mineral"] {
--canvas: #f3f6f6;
--surface: #ffffff;
--raised: #e8eded;
--line: #d0dcda;
--border: #637e7a;
--text: #3d524f;
--muted: #586f6c;
--action: #2a7469;
--onAction: #ffffff;
--accent: #9f414f;
--focus: #2a7469;
--success: #287658;
--warning: #935f1a;
--danger: #ba2e26;
--strong: color-mix(in srgb, var(--text) 72%, #000);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root[data-palette="mineral"]:not([data-mode="light"]):not([data-mode="dim"]) {
--canvas: #141a19;
--surface: #1f2927;
--raised: #2c3a38;
--line: #3d514e;
--border: #a4b7b4;
--text: #b9cbc8;
--muted: #a4b7b4;
--action: #87d4c8;
--onAction: #10141a;
--accent: #d18f99;
--focus: #87d4c8;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e58580;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
}
:root[data-palette="mineral"][data-mode="dark"] {
--canvas: #141a19;
--surface: #1f2927;
--raised: #2c3a38;
--line: #3d514e;
--border: #a4b7b4;
--text: #b9cbc8;
--muted: #a4b7b4;
--action: #87d4c8;
--onAction: #10141a;
--accent: #d18f99;
--focus: #87d4c8;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e58580;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
:root[data-palette="mineral"][data-mode="dim"] {
--canvas: #273432;
--surface: #324340;
--raised: #3d514e;
--line: #4d6662;
--border: #a4b7b4;
--text: #e5ebea;
--muted: #b2c2c0;
--action: #87d4c8;
--onAction: #10141a;
--accent: #e0b3ba;
--focus: #87d4c8;
--success: #85d5b7;
--warning: #e7b574;
--danger: #eeaeaa;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
/* Cobalt */
:root[data-palette="cobalt"] {
--canvas: #f3f3f7;
--surface: #ffffff;
--raised: #e6e8ef;
--line: #cdd1df;
--border: #63687e;
--text: #454a5e;
--muted: #60657b;
--action: #1c3bc4;
--onAction: #ffffff;
--accent: #79671b;
--focus: #1c3bc4;
--success: #277255;
--warning: #8f5c19;
--danger: #ba2e26;
--strong: color-mix(in srgb, var(--text) 72%, #000);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root[data-palette="cobalt"]:not([data-mode="light"]):not([data-mode="dim"]) {
--canvas: #12141c;
--surface: #1c1f2c;
--raised: #282c3e;
--line: #383d57;
--border: #a4a7b7;
--text: #b6b9c9;
--muted: #a4a7b7;
--action: #798eec;
--onAction: #10141a;
--accent: #e1ce7a;
--focus: #798eec;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e37d78;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
}
:root[data-palette="cobalt"][data-mode="dark"] {
--canvas: #12141c;
--surface: #1c1f2c;
--raised: #282c3e;
--line: #383d57;
--border: #a4a7b7;
--text: #b6b9c9;
--muted: #a4a7b7;
--action: #798eec;
--onAction: #10141a;
--accent: #e1ce7a;
--focus: #798eec;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e37d78;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
:root[data-palette="cobalt"][data-mode="dim"] {
--canvas: #242838;
--surface: #2e3248;
--raised: #383d57;
--line: #464d6d;
--border: #a4a7b7;
--text: #d0d2dc;
--muted: #a6aab9;
--action: #94a5f0;
--onAction: #10141a;
--accent: #e1ce7a;
--focus: #94a5f0;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e89591;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
/* Rosewood */
:root[data-palette="rosewood"] {
--canvas: #f6f3f5;
--surface: #ffffff;
--raised: #ede8eb;
--line: #dcd0d6;
--border: #7e6371;
--text: #5e4552;
--muted: #7b606e;
--action: #a53b72;
--onAction: #ffffff;
--accent: #416c95;
--focus: #a53b72;
--success: #287658;
--warning: #8f5c19;
--danger: #ba2e26;
--strong: color-mix(in srgb, var(--text) 72%, #000);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root[data-palette="rosewood"]:not([data-mode="light"]):not([data-mode="dim"]) {
--canvas: #1a1417;
--surface: #291f24;
--raised: #3a2c33;
--line: #513d48;
--border: #b7a4ae;
--text: #cbb9c2;
--muted: #b7a4ae;
--action: #d487af;
--onAction: #10141a;
--accent: #8eaecd;
--focus: #d487af;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e37d78;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
}
:root[data-palette="rosewood"][data-mode="dark"] {
--canvas: #1a1417;
--surface: #291f24;
--raised: #3a2c33;
--line: #513d48;
--border: #b7a4ae;
--text: #cbb9c2;
--muted: #b7a4ae;
--action: #d487af;
--onAction: #10141a;
--accent: #8eaecd;
--focus: #d487af;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e37d78;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
:root[data-palette="rosewood"][data-mode="dim"] {
--canvas: #34272e;
--surface: #43323b;
--raised: #513d48;
--line: #664d5a;
--border: #b7a4ae;
--text: #e1d6db;
--muted: #beacb5;
--action: #dc9ebe;
--onAction: #10141a;
--accent: #95b3d0;
--focus: #dc9ebe;
--success: #85d5b7;
--warning: #e7b574;
--danger: #ea9d99;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
/* Graphite */
:root[data-palette="graphite"] {
--canvas: #f5f5f5;
--surface: #ffffff;
--raised: #eaeaeb;
--line: #d5d6d7;
--border: #636e7e;
--text: #434d5b;
--muted: #5e6978;
--action: #616975;
--onAction: #ffffff;
--accent: #5e6978;
--focus: #616975;
--success: #287658;
--warning: #8f5c19;
--danger: #ba2e26;
--strong: color-mix(in srgb, var(--text) 72%, #000);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root[data-palette="graphite"]:not([data-mode="light"]):not([data-mode="dim"]) {
--canvas: #161718;
--surface: #232425;
--raised: #323334;
--line: #454749;
--border: #a4abb7;
--text: #bcc3cd;
--muted: #a4abb7;
--action: #a6acb5;
--onAction: #10141a;
--accent: #a4abb7;
--focus: #a6acb5;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e37d78;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
}
:root[data-palette="graphite"][data-mode="dark"] {
--canvas: #161718;
--surface: #232425;
--raised: #323334;
--line: #454749;
--border: #a4abb7;
--text: #bcc3cd;
--muted: #a4abb7;
--action: #a6acb5;
--onAction: #10141a;
--accent: #a4abb7;
--focus: #a6acb5;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e37d78;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
:root[data-palette="graphite"][data-mode="dim"] {
--canvas: #2d2e2f;
--surface: #393a3c;
--raised: #454749;
--line: #57595c;
--border: #a4abb7;
--text: #dfe2e7;
--muted: #afb6c0;
--action: #b1b6be;
--onAction: #10141a;
--accent: #afb6c0;
--focus: #b1b6be;
--success: #85d5b7;
--warning: #e7b574;
--danger: #eba29e;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
/* Grove */
:root[data-palette="grove"] {
--canvas: #f4f6f5;
--surface: #ffffff;
--raised: #e8edea;
--line: #d2dbd6;
--border: #637e6e;
--text: #3d5246;
--muted: #586f62;
--action: #377752;
--onAction: #ffffff;
--accent: #9c444a;
--focus: #377752;
--success: #287658;
--warning: #935f1a;
--danger: #ba2e26;
--strong: color-mix(in srgb, var(--text) 72%, #000);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root[data-palette="grove"]:not([data-mode="light"]):not([data-mode="dim"]) {
--canvas: #141917;
--surface: #202823;
--raised: #2d3932;
--line: #3f4f46;
--border: #a4b7ac;
--text: #b9cbc1;
--muted: #a4b7ac;
--action: #8fcca9;
--onAction: #10141a;
--accent: #cd8e92;
--focus: #8fcca9;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e58580;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
}
:root[data-palette="grove"][data-mode="dark"] {
--canvas: #141917;
--surface: #202823;
--raised: #2d3932;
--line: #3f4f46;
--border: #a4b7ac;
--text: #b9cbc1;
--muted: #a4b7ac;
--action: #8fcca9;
--onAction: #10141a;
--accent: #cd8e92;
--focus: #8fcca9;
--success: #85d5b7;
--warning: #e7b574;
--danger: #e58580;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
:root[data-palette="grove"][data-mode="dim"] {
--canvas: #29332d;
--surface: #34413a;
--raised: #3f4f46;
--line: #4f6358;
--border: #a4b7ac;
--text: #e2e9e5;
--muted: #afc0b6;
--action: #8fcca9;
--onAction: #10141a;
--accent: #ddb1b4;
--focus: #8fcca9;
--success: #85d5b7;
--warning: #e7b574;
--danger: #edaaa6;
--strong: color-mix(in srgb, var(--text) 70%, #fff);
color-scheme: dark;
}
+3 -1
View File
@@ -21,8 +21,10 @@ const root = resolve(import.meta.dirname, 'public');
const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['shared/app.css', 'console.css', 'live.css', 'bus.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
['/icons.svg', ['icons.svg', 'image/svg+xml']],
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]),
]);
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
+2 -2
View File
@@ -65,8 +65,8 @@ export async function browser() {
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 : '' });
const virtual = { Tab: 9, Enter: 13, Escape: 27, ArrowDown: 40, ArrowUp: 38, ArrowRight: 39, ' ': 32, k: 75 }[key];
await call('Input.dispatchKeyEvent', { type: 'keyDown', key, code, modifiers, windowsVirtualKeyCode: virtual, text: modifiers ? '' : key === 'Enter' ? '\r' : key.length === 1 ? key : '' });
await call('Input.dispatchKeyEvent', { type: 'keyUp', key, code, modifiers, windowsVirtualKeyCode: virtual });
},
async close() {
+4
View File
@@ -36,6 +36,10 @@ test('real board fixture passes through WebUI; assets and isolated seen/reply wo
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');
}
// #1542: the shell's tokens, sprite, stylesheet and mono fonts, each with its type.
for (const [path, type] of [['/tokens.css', 'text/css'], ['/shell.css', 'text/css'], ['/icons.svg', 'image/svg+xml'], ...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2'])]) {
const response = await fetch(f.base + path); assert.equal(response.status, 200, path); assert.ok(response.headers.get('content-type').startsWith(type), path);
}
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);
+467
View File
@@ -0,0 +1,467 @@
// #1542 (row 53): the Console shell restyle. Static checks: the design copies match
// docs/design byte for byte, the mono fonts match their recorded digests, and the shipped
// strings are unchanged. Browser checks: System is the default appearance, the board and
// bus states (empty, a failed read over kept data, a refusal), no sideways scroll at 400px,
// visible keyboard focus, the Ctrl+K palette, and each view in its five states. Nothing here
// writes; the bus is a stub.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { createServer } from 'node:http';
import { mkdirSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs';
const pub = new URL('../src/public/', import.meta.url), design = new URL('../../../docs/design/', import.meta.url);
const read = (name, base = pub) => readFileSync(new URL(name, base));
test('tokens.css and icons.svg are byte copies of docs/design', () => {
for (const name of ['tokens.css', 'icons.svg']) assert.ok(read(name).equals(read(name, design)), `${name} differs from docs/design/${name}; copy it again`);
});
test('JetBrains Mono files match the digests in their sources file, with the OFL text', () => {
const fonts = new URL('assets/fonts/', pub);
const listed = new Map(read('jetbrains-mono-sources.txt', fonts).toString().split('\n').map(l => l.match(/^([0-9a-f]{64}) {2}(\S+)$/)).filter(Boolean).map(([, sum, file]) => [file, sum]));
const shipped = readdirSync(fonts).filter(f => f.startsWith('jetbrains-mono-') && f !== 'jetbrains-mono-sources.txt').sort();
assert.deepEqual([...listed.keys()].sort(), shipped);
assert.deepEqual(shipped, ['jetbrains-mono-400.woff2', 'jetbrains-mono-500.woff2', 'jetbrains-mono-700.woff2', 'jetbrains-mono-OFL.txt']);
for (const [file, sum] of listed) assert.equal(createHash('sha256').update(read(file, fonts)).digest('hex'), sum, file);
assert.match(read('jetbrains-mono-OFL.txt', fonts).toString(), /SIL OPEN FONT LICENSE Version 1\.1/);
const css = read('shell.css').toString();
for (const w of [400, 500, 700]) assert.ok(css.includes(`url(/assets/fonts/jetbrains-mono-${w}.woff2)`), `shell.css @font-face ${w}`);
});
test('shipped strings are unchanged, and the web keeps its write boundary', () => {
// Markup in the source (<code>, <b>) is not part of the string. Each string is counted in the
// file that ships it, so dropping one of two copies fails too.
const src = ['app.js', 'bus.js', 'index.html'].map(f => read(f).toString()).join('\n');
const plain = Object.fromEntries(['app.js', 'bus.js'].map(f => [f, read(f).toString().replace(/<\/?(code|b)>/g, '')]));
for (const [file, s, n = 1] of [
['bus.js', 'Copied: ${text}'],
['bus.js', 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.'],
['bus.js', 'Decisions are answered in a terminal with mosaic decide; Console only shows them.'],
['bus.js', 'No task ${txt(ref)} in this business.', 2], ['bus.js', 'No decision with id ${txt(id)}.'],
['bus.js', '${txt(b)} is not a decision id.'], ['bus.js', '${txt(b)} is not a task reference.'],
['bus.js', 'No page at this address.'], ['app.js', 'Nothing is waiting on you.'], ['bus.js', 'Nothing is waiting on you.'],
['bus.js', 'No tasks in this business yet.'], ['bus.js', 'Tasks appear after the PM creates them or the poller reads them from Vikunja.'],
['bus.js', 'No open tasks.'], ['bus.js', 'No role is held right now.'], ['bus.js', 'Nothing of this kind in the trail.'],
['bus.js', 'Console shows nothing rather than a guess. Nothing was written.'],
]) assert.equal(plain[file].split(s).length - 1, n, `${file}: ${s}`);
// No Resolve control and no bus write: the only POSTs are the board's Seen and reply.
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
assert.doesNotMatch(read('bus.js').toString(), /method:\s*['"](POST|PUT|PATCH|DELETE)/);
assert.equal(read('icons.svg').toString().includes('<script'), false);
});
function board() {
let reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } };
const one = { agent: 'agent1', project: 'proj', state: 'waiting', waitingOnYou: true, task: 'Fixture task', lastActivity: '2026-10-10T00:00:00Z', lastAssistantText: 'Input needed: fixture', registered: null };
// While the board answers, agent1 has one conversation with one message. A failure or refusal answers every path.
const catalogue = { conversations: [{ project: 'proj', seat: 'agent1', conversation: 'c1', availability: 'available', title: 'Fixture', conversationCreatedAt: '2026-10-10T00:00:00Z', lastActivityAt: '2026-10-10T00:00:00Z' }] };
const page = { page: { branch: 'b1', entries: [{ message: 'm1', role: 'assistant', createdAt: '2026-10-10T00:00:00Z', content: [{ block: 0, type: 'text', text: 'Fixture history line' }] }] }, view: { incomplete: false, defaultBranch: 'b1', branch: 'b1' }, cursor: null, follow: null };
let gate = null;
const server = createServer(async (req, res) => {
for await (const chunk of req) { void chunk; }
await gate;
const path = new URL(req.url, 'http://localhost').pathname;
const body = reply.status !== 200 ? reply.body : path === '/api/conversations' ? catalogue : path === '/api/conversation' ? page : reply.body;
res.writeHead(reply.status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body));
});
return {
server,
// Holds every reply until the returned function is called.
hold: () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; },
empty: () => { reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } }; },
one: () => { reply = { status: 200, body: { sessions: [one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], counts: { waiting: 1, working: 1 }, generatedAt: new Date().toISOString() } }; },
fail: () => { reply = { status: 500, body: { error: 'board scan failed' } }; },
refuse: () => { reply = { status: 403, body: { error: 'board-refused' } }; },
// One seat a reply can reach: registered, alive, with a tmux session.
registered: () => { reply = { status: 200, body: { sessions: [{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], counts: { waiting: 1 }, generatedAt: new Date().toISOString() } }; },
unavailable: () => { reply = { status: 503, body: { error: 'board unavailable' } }; },
};
}
// The four Q1 verbs over fixed rows, or a BusReadError with a chosen code.
function stubBus() {
const rows = { inbox: [], tasks: [], agents: [], trail: [] };
let fail = null, gate = null;
const verb = name => async () => { await gate; if (fail) throw new BusReadError(fail, `fixture ${fail}`); return structuredClone(rows[name]); };
const hold = () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; };
return { rows, hold, fail: code => { fail = code; }, bus: { inbox: verb('inbox'), tasks: verb('tasks'), agents: verb('agents'), trail: verb('trail') } };
}
test('shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette', { timeout: 180000 }, async () => {
const brd = board(); await new Promise(r => brd.server.listen(0, '127.0.0.1', r));
const s = stubBus();
const web = await startServer({ port: 0, board: `http://127.0.0.1:${brd.server.address().port}`, bus: s.bus });
const base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true });
const shot = async name => { if (out) await b.screenshot(join(out, `shell-${name}.png`)); };
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: '+${JSON.stringify(expr)})})()`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const count = sel => b.evaluate(`document.querySelectorAll(${JSON.stringify(sel)}).length`);
const flat = async () => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, 'sideways scroll at 400px');
const refresh = async () => { await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); };
const go = async (hash, h1) => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`); };
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];const f=window.fetch;window.fetch=(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);return f(u,o)};' });
await b.viewport(400, 900);
// System is the default: no data-mode, Harbor, the OS picks light or dark. Colours come from tokens.css, not inline.
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'dark' }] });
await b.navigate(base); await wait('!document.querySelector("#sessions [aria-busy]")');
assert.equal(await b.evaluate('document.documentElement.hasAttribute("data-mode")'), false);
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
assert.equal(await b.evaluate('document.querySelector("#mode").value'), 'system');
assert.equal(await b.evaluate('document.documentElement.getAttribute("style")'), null);
const lum = await b.evaluate('(()=>{const [r,g,bb]=getComputedStyle(document.body).backgroundColor.match(/[\\d.]+/g).map(Number);return (r+g+bb)/3})()');
assert.ok(lum < 80, `dark OS gives a dark page under System (${lum})`);
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] });
assert.ok(await b.evaluate('(()=>{const [r,g,bb]=getComputedStyle(document.body).backgroundColor.match(/[\\d.]+/g).map(Number);return (r+g+bb)/3})()') > 180, 'light OS gives a light page');
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).getPropertyValue("--r").trim()'), '6px');
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).getPropertyValue("--r-lg").trim()'), '10px');
assert.equal(await b.evaluate('document.fonts.check("12px \\"JetBrains Mono\\"")'), true);
await b.evaluate('document.querySelector("#mode").value="dark";document.querySelector("#mode").dispatchEvent(new Event("change"))');
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'dark');
assert.equal(await b.evaluate('JSON.parse(localStorage.getItem("mosaic-console-appearance")).mode'), 'dark');
await b.evaluate('document.querySelector("#mode").value="system";document.querySelector("#mode").dispatchEvent(new Event("change"))');
assert.equal(await b.evaluate('document.documentElement.hasAttribute("data-mode")'), false);
// Board, empty: what would appear and where it comes from.
assert.match(await text('#sessions .empty'), /No sessions match these filters\.Sessions appear here when the board scan finds/);
assert.match(await text('#waiting .empty'), /Nothing is waiting on you\./);
assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/);
await flat(); await shot('board-empty-400');
// Board, a failed read over kept data: the data stays, labelled with its scan time, and the bar says stale.
brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions .st-waiting").length'), 1);
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("table.sessions .st-waiting"),"::before").content'), '"◐" / ""');
brd.fail(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
assert.match(await text('#error'), /board scan failed.*Showing last known data\. Scanned /);
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 2);
assert.match(await text('#fresh-board'), /stale: the last refresh failed/);
await flat(); await shot('board-stale-400');
// Board, a refusal: fails closed. The kept rows are hidden, and so is everything drawn from
// them: the open inspector, the project filter, the project tree's counts and the footer.
await b.evaluate('document.querySelector("#projects [data-project=proj]").click()');
assert.equal(await b.evaluate('document.querySelector("#projects [data-project=proj]").getAttribute("aria-pressed")'), 'true');
await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); await wait('!document.querySelector("#inspector").hidden');
assert.equal(await text('#footer'), 'waiting 1 · working 1');
assert.equal(await b.evaluate('document.querySelectorAll("#projects .count").length'), 2);
assert.equal(await count('#seen .wait-item'), 1);
brd.refuse(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner ref');
assert.match(await text('#error'), /The board refused the read\. refused: board-refused/);
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), true);
assert.equal(await b.evaluate('document.body.classList.contains("has-inspector")'), false);
assert.equal(await b.evaluate('document.querySelectorAll("#projects .count, #projects [data-project]").length'), 0);
assert.equal(await text('#footer'), 'Board not read: refused');
assert.equal(await text('#fresh-board'), 'Board not read: refused');
assert.doesNotMatch(await text('#status'), /Scanned/);
for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
await flat(); await shot('board-refused-400');
// A failed read after the refusal shows the board again, empty: no row, card or count from
// the refused scan, and the footer says what the banner says.
brd.unavailable(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
assert.match(await text('#error'), /board unavailable.*No board data loaded\./);
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await count('#sessions tr, #sessions [data-open], #waiting .wait-item, #seen .wait-item, #board-view [data-history]'), 0);
for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
assert.equal(await text('#footer'), 'Board not read: the read failed');
assert.equal(await text('#fresh-board'), 'Board not read: the read failed');
assert.equal(await text('#status'), 'Not read: the read failed');
assert.equal(await text('#projects'), 'Not read: the read failed.');
await flat(); await shot('board-refused-then-failed-400');
brd.one(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true);
assert.equal(await b.evaluate('document.querySelector("#projects [data-project=\'\']").getAttribute("aria-pressed")'), 'true');
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await text('#footer'), 'waiting 1 · working 1');
// A good read after a failed one drops the stale words.
brd.fail(); await refresh(); assert.match(await text('#fresh-board'), /stale/);
brd.one(); await refresh(); assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/); assert.doesNotMatch(await text('#fresh-board'), /stale/);
// A refusal with a conversation open closes it and clears its history and reply form: the
// session is in no scan the page holds.
brd.registered(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===1');
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
assert.equal(await b.evaluate('document.querySelector("#conv-form").hidden'), false);
brd.refuse(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true);
assert.equal(await b.evaluate('document.body.classList.contains("has-conversation")'), false);
assert.equal(await count('#conv-log > *'), 0);
assert.equal(await b.evaluate('document.querySelector("#conv-form").checkVisibility()'), false);
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
// Keyboard on the board: down moves between rows, Enter opens, Esc closes and returns focus.
await b.evaluate('document.querySelector("table.sessions [data-open]").focus()');
await b.key('ArrowDown'); assert.equal(await b.evaluate('document.activeElement.textContent'), 'agent2');
await b.key('ArrowUp'); assert.equal(await b.evaluate('document.activeElement.textContent'), 'agent1');
await b.key('Enter'); await wait('document.querySelector("#inspector-title")?.textContent==="agent1"');
await flat();
await b.evaluate('document.querySelector("#close").focus()'); await b.key('Escape');
await wait('document.activeElement?.matches("[data-open]") && document.activeElement.textContent==="agent1"');
// Visible focus: a keyboard-focused control has an outline of at least 2px.
await b.evaluate('document.querySelector(".skip").focus();document.querySelector(".skip").blur()');
for (const sel of ['#cmdk', '#sections a', '#refresh']) {
await b.evaluate(`document.querySelector(${JSON.stringify(sel)}).focus()`);
const ring = await b.evaluate(`(()=>{const c=getComputedStyle(document.activeElement);return [c.outlineStyle, parseFloat(c.outlineWidth), parseFloat(c.outlineOffset)]})()`);
assert.notEqual(ring[0], 'none', `${sel} focus ring`); assert.ok(ring[1] >= 2, `${sel} outline ${ring[1]}px`); assert.equal(ring[2], 2, `${sel} outline offset`);
}
// Bus pages, empty: each says what would appear and where it comes from.
for (const [hash, h1, words] of [
['#/inbox', 'Inbox', /^Nothing is waiting on you\.A decision shows here when an agent raises one routed to you through the bus\.$/],
['#/tasks', 'Tasks', /^No tasks in this business yet\.Tasks appear after the PM creates them or the poller reads them from Vikunja\.$/],
['#/agents', 'Agents', /^No role is held right now\.A role shows here while a run holds its claim on the bus\.$/],
]) {
await go(hash, h1);
assert.match(await text('#bus-view .empty'), words, hash);
assert.match(await text('#fresh-bus'), /^Bus read \d\d:\d\d:\d\d UTC/, hash);
await flat(); await shot(`bus-empty-${h1.toLowerCase()}-400`);
}
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#fresh-board")).display'), 'none');
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 4);
for (const sel of ['#fresh', '#session-count']) assert.equal(await b.evaluate(`getComputedStyle(document.querySelector(${JSON.stringify(sel)})).fontVariantNumeric`), 'tabular-nums', sel);
// Before any copy the toast is an empty live region still in the accessibility tree, out of sight.
assert.deepEqual(await b.evaluate('(()=>{const t=document.querySelector("#toast"),c=getComputedStyle(t),r=t.getBoundingClientRect();return [c.display!=="none",c.visibility,t.textContent,r.width<=1&&r.height<=1]})()'), [true, 'visible', '', true]);
// Below 760px the section list is one strip that scrolls inside itself: every link on one
// line, no label wrapped, and still no sideways scroll on the page. 360px, then back to 400px.
for (const w of [360, 400]) {
await b.viewport(w, 900);
assert.equal(await b.evaluate('new Set([...document.querySelectorAll("#sections a")].map(a=>a.offsetTop)).size'), 1, `one row at ${w}px`);
assert.match(await b.evaluate('getComputedStyle(document.querySelector("#sections")).overflowX'), /^(auto|scroll)$/, `strip scrolls at ${w}px`);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections a")].filter(a=>a.getClientRects().length>1||a.scrollWidth>a.clientWidth+1).map(a=>a.textContent)'), [], `no wrapped label at ${w}px`);
await flat();
}
// Bus, a failed read over kept data: the kept rows stay under a stale banner; the bar says stale.
s.rows.tasks = [{ task_ref: 'vikunja:32/7', fields: { title: 'Kept task', bucket: 2, done: false, assignees: [] }, source: 'self' }, { task_ref: 'vikunja:32/8', fields: { title: 'Second task', bucket: 2, done: false, assignees: [] }, source: 'self' }];
await go('#/trail/task/vikunja%3A32%2F7', 'Trail for task #7');
assert.match(await text('#bus-view .empty'), /^Nothing of this kind in the trail\.Every step the stack takes on this subject writes a trail row\.$/);
await flat(); await shot('bus-empty-trail-400');
await go('#/tasks', 'Tasks');
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view td.s1-title a").length'), 2);
s.fail('outcome-unknown'); await b.evaluate('document.querySelector("#refresh").click()');
await wait('!!document.querySelector("#bus-view .s1-stale")');
assert.equal(await b.evaluate('document.querySelector("#bus-view .s1-stale").className'), 'banner err s1-stale');
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view td.s1-title a").length'), 2);
assert.match(await text('#fresh-bus'), /stale: the last read failed/);
// Up and down move between task links.
await b.evaluate('document.querySelector("#bus-view td.s1-title a").focus()');
await b.key('ArrowDown'); assert.match(await b.evaluate('document.activeElement.textContent'), /Second task/);
await b.key('ArrowUp'); assert.match(await b.evaluate('document.activeElement.textContent'), /Kept task/);
await flat(); await shot('bus-stale-400');
// Bus, a refusal: fails closed even with kept data, the code in mono, nothing shown as current.
s.fail('human-required'); await b.evaluate('document.querySelector("#refresh").click()');
await wait('!!document.querySelector("#bus-view .banner.ref")');
assert.equal(await b.evaluate('document.querySelector("#bus-view .banner.ref code").textContent'), 'human-required');
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view td.s1-title a").length'), 0);
assert.match(await text('#bus-view .empty'), /Nothing to show\./);
assert.equal(await text('#fresh-bus'), 'Bus not read: refused');
await flat(); await shot('bus-refused-400');
s.fail(null);
// Not found names the address and points to the Board and to Ctrl K.
await go('#/nowhere', 'Not found');
assert.match(await text('#bus-view .empty'), /No page at this address\.Nothing is at #\/nowhere\. Go to the Board or press Ctrl K to search\./);
assert.equal(await b.evaluate('document.querySelector("#bus-view .empty a").getAttribute("href")'), '#/');
// Ctrl+K: opens on the views and the kept tasks, filters, Enter goes, Esc restores focus.
await go('#/inbox', 'Inbox');
await b.evaluate('document.querySelector("#refresh").focus()');
await b.key('k', 'KeyK', 3); await new Promise(r => setTimeout(r, 200));
assert.equal(await b.evaluate('document.querySelector("#cmdk-dialog").open'), false, 'Ctrl+Alt+K opens nothing');
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
assert.equal(await b.evaluate('document.activeElement.id'), 'cmdk-input');
await wait('[...document.querySelectorAll("#cmdk-list [data-href]")].some(e=>e.dataset.href==="#/tasks/vikunja%3A32%2F7")');
await b.call('Input.insertText', { text: 'agen' });
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)'), ['#/agents']);
// The view render focuses its h1; wait for it so a late render can't take focus from the next step.
await b.key('Enter'); await wait('location.hash==="#/agents" && !document.querySelector("#cmdk-dialog").open && document.activeElement===document.querySelector("#bus-view h1") && document.activeElement.textContent==="Agents"');
await b.evaluate('document.querySelector("#refresh").focus()');
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
await b.call('Input.insertText', { text: 'kept' });
assert.equal(await b.evaluate('document.querySelector("#cmdk-list [aria-selected=true]").dataset.href'), '#/tasks/vikunja%3A32%2F7');
await b.key('ArrowDown'); await b.key('ArrowUp');
assert.equal(await b.evaluate('document.querySelector("#cmdk-input").getAttribute("aria-activedescendant")'), 'cmdk-opt-0');
await flat(); await shot('palette-400');
await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open && document.activeElement?.id==="refresh"');
await b.evaluate('{const c=document.querySelector("#cmdk");c.focus();c.click()}'); await wait('document.querySelector("#cmdk-dialog").open');
await b.call('Input.insertText', { text: 'zzzz' });
assert.match(await text('#cmdk-list'), /No view, decision or task matches\./);
await b.key('Escape'); await wait('document.activeElement?.id==="cmdk"');
// Copy shows a toast and keeps the status line; the toast never steals focus.
s.rows.inbox = [{ id: 'd'.repeat(32), question: 'Push it?', class: 'gated', action: 'git.push.protected', target: 'refactor', blocking: true, options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', at: new Date().toISOString(), raised_by: 'coder' }];
// The decision page reads the decision from its trail, in the broker's row shape.
s.rows.trail = [{ table: 'decisions', seq: 1, at: s.rows.inbox[0].at, id: 'd'.repeat(32), class: 'gated', action: 'git.push.protected', route_to: 'human', question: 'Push it?', options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', blocking: 1, raised_by_role: 'coder', raised_by_run: 'coder-run' }];
await go('#/inbox', 'Inbox');
assert.equal(await b.evaluate('document.querySelector("#bus-view .tag.tag-gated code").textContent'), 'git.push.protected');
assert.equal(await b.evaluate('document.querySelector("#bus-view .tag.tag-block").textContent'), 'blocking');
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#bus-view .tag.tag-block")).textTransform'), 'uppercase');
await flat(); await shot('bus-inbox-400');
await b.evaluate('document.querySelector("#bus-view .s1-dec-open").click()'); await wait('document.querySelectorAll("#bus-view [data-copy]").length===1');
await b.evaluate('window.copied=[];navigator.clipboard.writeText=async t=>{copied.push(t)}');
await b.evaluate('{const c=document.querySelector("#bus-view [data-copy]");c.focus();c.click()}');
await wait('document.querySelector("#toast").classList.contains("on")');
assert.equal(await text('#toast'), `Copied: mosaic decide ${'d'.repeat(32)} yes`);
assert.equal(await text('#copy-status'), `Copied: mosaic decide ${'d'.repeat(32)} yes`);
assert.equal(await b.evaluate('document.activeElement.matches("[data-copy]")'), true);
await flat(); await shot('toast-400');
await b.evaluate('navigator.clipboard.writeText=async()=>{throw new Error("denied")}');
await b.evaluate('document.querySelector("#bus-view [data-copy]").click()');
await wait('document.querySelector("#toast").classList.contains("err")');
assert.equal(await text('#toast span'), 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.');
assert.equal(await b.evaluate('getSelection().toString()'), `mosaic decide ${'d'.repeat(32)} yes`);
await new Promise(r => setTimeout(r, 3000));
assert.equal(await b.evaluate('document.querySelector("#toast").className'), 'toast on err', 'the error toast stays until dismissed');
await b.evaluate('document.querySelector("#toast [data-dismiss]").click()');
assert.equal(await b.evaluate('document.querySelector("#toast").classList.contains("on")'), false);
assert.deepEqual(await b.evaluate('window.errors'), []);
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
await b.viewport(1440, 900); await go('#/tasks', 'Tasks'); await shot('bus-tasks-1440');
await b.evaluate('location.hash="#/"'); await wait('document.querySelectorAll("table.sessions tbody tr").length===2'); await shot('board-1440');
// The side column sits between the command bar and the status footer: a short page has no scroll,
// and scrolled to the end of a long one the column is still below the bar.
const edges = 'JSON.stringify(["cmdbar","tree","foot"].map(c=>document.querySelector("."+c).getBoundingClientRect()).map(r=>[Math.round(r.top),Math.round(r.bottom)]).concat([[document.documentElement.scrollHeight,innerHeight]]))';
let [bar, tree, foot, [scrollH, innerH]] = JSON.parse(await b.evaluate(edges));
assert.equal(scrollH, innerH);
assert.deepEqual([tree[0], tree[1]], [bar[1], foot[0]]);
await b.viewport(1440, 420); await b.evaluate('scrollTo(0, 1e5)'); await flat();
[bar, tree, foot, [scrollH, innerH]] = JSON.parse(await b.evaluate(edges));
assert.ok(scrollH > innerH, `the board page is taller than ${innerH}px`);
assert.deepEqual([tree[0], tree[1]], [bar[1], foot[0]]);
assert.deepEqual(await b.evaluate('window.errors'), []);
} finally { await b.close(); await close(web); await close(brd.server); }
});
// Every restyled view in its five states: loading (a held read), normal, empty, a failed read
// over kept data, and a refusal. Empty is seeded per view above; here each view is read once
// with rows, then made to fail, then refused, at 400px.
test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }, async () => {
const brd = board(); await new Promise(r => brd.server.listen(0, '127.0.0.1', r));
const s = stubBus();
const at = new Date().toISOString(), id = 'd'.repeat(32);
s.rows.inbox = [{ id, question: 'Push it?', class: 'gated', action: 'git.push.protected', target: 'refactor', blocking: true, options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', at, raised_by: 'coder' }];
s.rows.trail = [{ table: 'decisions', seq: 1, at, id, class: 'gated', action: 'git.push.protected', route_to: 'human', question: 'Push it?', options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', blocking: 1, raised_by_role: 'coder', raised_by_run: 'coder-run' }];
s.rows.tasks = [{ task_ref: 'vikunja:32/7', fields: { title: 'Kept task', bucket: 2, done: false, assignees: [] }, source: 'self' }, { task_ref: 'vikunja:32/8', fields: { title: 'Second task', bucket: 2, done: false, assignees: [] }, source: 'self' }];
s.rows.agents = [{ role: 'coder', holder_run: 'coder-run', harness: 'pi', address: 'coder@bus', at }];
const web = await startServer({ port: 0, board: `http://127.0.0.1:${brd.server.address().port}`, bus: s.bus });
const base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true });
const shot = async name => { if (out) await b.screenshot(join(out, `states-${name}.png`)); };
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: '+${JSON.stringify(expr)})})()`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const count = sel => b.evaluate(`document.querySelectorAll(${JSON.stringify(sel)}).length`);
const flat = async () => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, 'sideways scroll at 400px');
const refresh = async () => { await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); };
const go = async (hash, h1) => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`); };
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];const f=window.fetch;window.fetch=(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);return f(u,o)};' });
await b.viewport(400, 900);
// Board, loading: the first scan is held, so the sessions list shows its skeleton.
brd.one(); let release = brd.hold();
await b.navigate(base); await wait('!!document.querySelector("#sessions .skel-rows[aria-busy=true]")');
await flat(); await shot('board-loading-400');
release(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true);
await flat(); await shot('board-normal-400');
for (const [hash, h1, rows, name] of [
['#/inbox', 'Inbox', '#bus-view .s1-dec-open', 'inbox'],
['#/tasks', 'Tasks', '#bus-view td.s1-title a', 'tasks'],
['#/agents', 'Agents', '#bus-view table.s1-agents tbody tr', 'agents'],
[`#/trail/decision/${id}`, 'Trail for decision', '#bus-view ol.s1-trail li', 'trail'],
]) {
// Loading: the read is held, the view shows the skeleton under "Reading…".
release = s.hold();
await b.evaluate(`location.hash=${JSON.stringify(hash)}`);
await wait('!!document.querySelector("#bus-view .skel-rows[aria-busy=true]")');
assert.equal(await text('#bus-view h1'), 'Reading…', hash);
await flat(); await shot(`${name}-loading-400`);
// Normal.
release();
await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`);
const n = await count(rows);
assert.ok(n >= 1, `${hash} shows its rows`);
assert.equal(await count('#bus-view .banner'), 0, hash);
assert.match(await text('#fresh-bus'), /^Bus read \d\d:\d\d:\d\d UTC/, hash);
await flat(); await shot(`${name}-normal-400`);
// A failed read over kept data: the same rows under a stale banner.
s.fail('outcome-unknown'); await refresh();
await wait('!!document.querySelector("#bus-view .s1-stale")');
assert.equal(await b.evaluate('document.querySelector("#bus-view .s1-stale").className'), 'banner err s1-stale', hash);
assert.equal(await count(rows), n, `${hash} keeps its rows`);
assert.match(await text('#fresh-bus'), /stale: the last read failed/, hash);
await flat(); await shot(`${name}-stale-400`);
// A refusal, by the bus (403) or for want of one (no-bus-host): no kept rows, the code
// shown, nothing current, and no count in the section list.
for (const code of ['human-required', 'no-bus-host']) {
s.fail(code); await refresh();
await wait('!!document.querySelector("#bus-view .banner.ref")');
assert.equal(await text('#bus-view .banner.ref code'), code, hash);
assert.equal(await count(rows), 0, `${hash} hides kept rows on a ${code} refusal`);
assert.match(await text('#bus-view .empty'), /Nothing to show\./, hash);
assert.equal(await text('#fresh-bus'), 'Bus not read: refused', hash);
assert.equal(await count('#sections .count, #sections .tag-block'), 0, `${hash} drops the Inbox count on a ${code} refusal`);
if (code === 'human-required') { await flat(); await shot(`${name}-refused-400`); }
s.fail(null); await refresh();
await wait(`!document.querySelector("#bus-view .banner") && document.querySelectorAll(${JSON.stringify(rows)}).length===${n}`);
}
}
// After a refusal the palette lists only the views: no kept decision or task.
const views = ['#/', '#/inbox', '#/tasks', '#/agents'];
const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)';
await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1');
assert.match(await text('#sections'), /Inbox 1 1 blocking/);
s.fail('human-required'); await refresh(); await wait('!!document.querySelector("#bus-view .banner.ref")');
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
assert.deepEqual(await b.evaluate(palette), views);
await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
s.fail(null);
// The palette's own reads: kept rows show until a refused refetch, then go, with the count.
await go('#/tasks', 'Tasks'); await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1');
await b.key('k', 'KeyK', 2); await wait(`${palette}.some(h=>h.startsWith("#/tasks/"))`);
await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
s.fail('human-required');
await b.key('k', 'KeyK', 2); await wait(`document.querySelector("#cmdk-dialog").open && ${palette}.length===${views.length} && !document.querySelector("#sections .count")`);
assert.deepEqual(await b.evaluate(palette), views);
await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
// Palette labels are bus text: markup in a question or a task title shows as text.
s.fail(null);
s.rows.inbox = [{ ...s.rows.inbox[0], question: '<img src=x onerror="errors.push(1)" id="pk-q">Push?' }];
s.rows.tasks = [{ ...s.rows.tasks[0], fields: { ...s.rows.tasks[0].fields, title: '<b id="pk-t">Bold</b> task' } }, s.rows.tasks[1]];
await b.key('k', 'KeyK', 2); await wait(`${palette}.length===${views.length + 3}`);
assert.equal(await count('#cmdk-list #pk-q, #cmdk-list #pk-t, #cmdk-list img, #cmdk-list b'), 0);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#cmdk-list [data-href] span:first-child")].slice(4).map(e=>e.textContent)'), ['<img src=x onerror="errors.push(1)" id="pk-q">Push?', '#7 <b id="pk-t">Bold</b> task', '#8 Second task']);
await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
// The board page's Inbox read: a refusal there drops the count too.
s.fail(null); await refresh(); await wait('document.querySelectorAll("#sections .count").length===1');
s.fail('no-bus-host'); await b.evaluate('location.hash="#/"'); await wait('!document.querySelector("#sections .count")');
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
assert.deepEqual(await b.evaluate(palette), views);
await b.key('Escape'); s.fail(null);
assert.deepEqual(await b.evaluate('window.errors'), []);
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
} finally { await b.close(); await close(web); await close(brd.server); }
});