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:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
Reference in New Issue
Block a user