diff --git a/agents/dewey/work/queue-53/candidate-manifest.sha256 b/agents/dewey/work/queue-53/candidate-manifest.sha256 new file mode 100644 index 00000000..7910a15e --- /dev/null +++ b/agents/dewey/work/queue-53/candidate-manifest.sha256 @@ -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 diff --git a/packages/webui/README.md b/packages/webui/README.md index 90f905ed..edb3118b 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -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 ``. 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. diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js index b862a985..efe7ba9e 100644 --- a/packages/webui/src/public/app.js +++ b/packages/webui/src/public/app.js @@ -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 ? `${esc(labels[value] || value)}` : ''; // 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 ? `set by ${esc(r.taskSetBy)}` : ''; - const badge = state => `${esc(state || 'unknown')}`; + // Status mark: the class adds the glyph (shell.css); the word is the text, so colour is never the only signal. + const badge = state => `${esc(state || 'unknown')}`; const connectorStatus = r => r.connector ? `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'} · owner ${esc(r.connector.ownerState)}` : ''; 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 ``; } function table(rows) { - if (!rows.length) return '

No sessions match these filters.

'; - return `
${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => ``).join('')}${rows.map(r => ``).join('')}
${h}
${openButton(r)}${esc(r.project)}${historyButton(r)}${badge(r.state)}${connectorStatus(r)}${r.seen ? 'seen' : ''}${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}${esc(r.model || 'unknown')}${esc(r.provider)}${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `${esc(r.lastActivity)}`}` : 'unknown'}
`; + if (!rows.length) return `
No sessions match these filters.

${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.'}

`; + return `
${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => ``).join('')}${rows.map(r => ``).join('')}
${h}
${openButton(r)}${esc(r.project)}${historyButton(r)}${badge(r.state)}${connectorStatus(r)}${r.seen ? 'seen' : ''}${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}${esc(r.model || 'unknown')}${esc(r.provider)}${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `${esc(r.lastActivity)}`}` : 'unknown'}
`; } function registered(r) { const reg = r.registered; @@ -267,7 +269,7 @@ $('projects').innerHTML = ``; 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) : '

Nothing is waiting on you.

'; + $('waiting-count').textContent = waiting.length; $('waiting').innerHTML = waiting.length ? cards(waiting) : '
Nothing is waiting on you.

A session shows here when its agent stops on a question or an error and you have not marked it seen.

'; $('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 not read: refused' : failed ? 'Board not read: the read failed' : 'Board not read yet'; return; } + const scan = `Board scanned ${esc(Number.isFinite(at) ? `${new Date(at).toISOString().slice(11, 19)} UTC` : 'unknown')}${ago ? ` (${ago} ago)` : ''}`; + $('fresh-board').innerHTML = failed ? `${scan} · stale: the last refresh failed` : 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 = `

Not read: ${why}.

`; + 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 + ? `

The board refused the read. refused: ${esc(err.message)} Board: ${esc(board)}. Console shows nothing rather than a guess. Use Refresh to try again.

` + : `

${esc(err.message)} Board: ${esc(board)}. ${data ? `Showing last known data.${data.generatedAt ? ` Scanned ${esc(data.generatedAt)}.` : ''}` : 'No board data loaded.'} Use Refresh to try again.

`; + fresh(); + } function schedule() { clearTimeout(timer); if (!paused) timer = setTimeout(refresh, 10000); } async function refresh() { if (busy) return; diff --git a/packages/webui/src/public/assets/fonts/jetbrains-mono-400.woff2 b/packages/webui/src/public/assets/fonts/jetbrains-mono-400.woff2 new file mode 100644 index 00000000..40da4276 Binary files /dev/null and b/packages/webui/src/public/assets/fonts/jetbrains-mono-400.woff2 differ diff --git a/packages/webui/src/public/assets/fonts/jetbrains-mono-500.woff2 b/packages/webui/src/public/assets/fonts/jetbrains-mono-500.woff2 new file mode 100644 index 00000000..669d04cd Binary files /dev/null and b/packages/webui/src/public/assets/fonts/jetbrains-mono-500.woff2 differ diff --git a/packages/webui/src/public/assets/fonts/jetbrains-mono-700.woff2 b/packages/webui/src/public/assets/fonts/jetbrains-mono-700.woff2 new file mode 100644 index 00000000..4917f434 Binary files /dev/null and b/packages/webui/src/public/assets/fonts/jetbrains-mono-700.woff2 differ diff --git a/packages/webui/src/public/assets/fonts/jetbrains-mono-OFL.txt b/packages/webui/src/public/assets/fonts/jetbrains-mono-OFL.txt new file mode 100644 index 00000000..8bee4148 --- /dev/null +++ b/packages/webui/src/public/assets/fonts/jetbrains-mono-OFL.txt @@ -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. diff --git a/packages/webui/src/public/assets/fonts/jetbrains-mono-sources.txt b/packages/webui/src/public/assets/fonts/jetbrains-mono-sources.txt new file mode 100644 index 00000000..5d8ac649 --- /dev/null +++ b/packages/webui/src/public/assets/fonts/jetbrains-mono-sources.txt @@ -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 diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 4a759708..93df4371 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -181,6 +181,12 @@ const when = iso => iso ? `` : 'never'; const clock = iso => iso ? `` : ''; const badge = (text, kind = 'muted') => `${txt(text)}`; + // 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) => `${txt(cls)} · ${txt(action)}`; + const blockChip = text => `${txt(text)}`; + // An empty state says what would appear and where it comes from. + const empty = (what, from) => `
${what}${from ? `

${from}

` : ''}
`; const taskLink = ref => ref ? `#${esc(String(ref).split('/')[1])}` : ''; // Rows without an actor role come from the human (CLI, outside any run) or from the broker itself. const actor = (a, by) => `${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : 'broker'}`; @@ -200,10 +206,10 @@ // ---------------------------------------------------------------- views let inboxCount = null; function sections(route) { - const items = [['/', 'Control board', ''], ['/inbox', 'Inbox', inboxCount ? `${inboxCount.open}${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 ? `${inboxCount.open}${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 `
  • ${l}${extra ? ` ${extra}` : ''}
  • `; + return `
  • ${l}${extra ? ` ${extra}` : ''}
  • `; }).join(''); } function countInbox(rows) { inboxCount = { open: rows.length, blocking: rows.filter(d => d.blocking).length }; } @@ -211,7 +217,7 @@ // Inbox function decisionCard(d) { return `
  • ${txt(first(d.question))}
    -

    ${d.blocking ? badge('blocking', 'attn') : ''}${badge(`${d.class} · ${d.action}`)}

    +

    ${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}

    ${approves(d.authorization)}

    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)}` : ''}

  • `; } @@ -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 ? `` : '

    Nothing is waiting on you.

    '; + const body = rows.length ? `` : 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 mosaic decide; Console only shows them.`)}

    For you ${rows.length}

    ${body}

    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.

    `; @@ -245,8 +251,8 @@ : !c ? `

    This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.

    ` : ''; return `${head(`Decision ${txt(short(d.id))}`, status)}

    Question

    ${txt(d.question)}

    -
    Class
    ${txt(d.class)} · ${txt(d.action)}${d.blocking ? ` ${badge('blocking', 'attn')}` : ''}
    Approval
    ${approves(d.authorization)}
    Routed to
    ${txt(d.routeTo)}
    Raised by
    ${txt(d.raisedBy.role)} ${txt(d.raisedBy.run)}
    Raised
    ${when(d.at)}
    ${d.taskRef ? `
    Task
    ${taskLink(d.taskRef)} ${txt(d.taskRef)}
    ` : ''}${d.requirementRef ? `
    Requirement
    ${txt(d.requirementRef)}
    ` : ''}
    DM
    ${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}
    Id
    ${txt(d.id)}
    -

    Options

    ${help}${options}

    +
    Class
    ${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}
    Approval
    ${approves(d.authorization)}
    Routed to
    ${txt(d.routeTo)}
    Raised by
    ${txt(d.raisedBy.role)} ${txt(d.raisedBy.run)}
    Raised
    ${when(d.at)}
    ${d.taskRef ? `
    Task
    ${taskLink(d.taskRef)} ${txt(d.taskRef)}
    ` : ''}${d.requirementRef ? `
    Requirement
    ${txt(d.requirementRef)}
    ` : ''}
    DM
    ${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}
    Id
    ${txt(d.id)}
    +

    Options

    ${help}${options}

    Earlier versions

    ${earlier.length ? `` : '

    None.

    '}

    ${gap('A later version that replaces this one')}

    History

    ${history.length ? `` : '

    Not seen yet.

    '}

    Full trail for this decision

    `; } @@ -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))}

    No tasks in this business yet. Tasks appear after the PM creates them or the poller reads them from Vikunja.

    `; + 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 ? `

    Done ${done.length}

    ${taskTable(done.slice(0, 50), 'Done tasks')}${done.length > 50 ? `

    The 50 most recently changed of ${done.length}.

    ` : ''}
    ` : ''; const missingHtml = missing.length ? `

    No longer readable ${missing.length}

    ` : ''; - return `${head('Tasks', freshness(rows))}

    Edit tasks in Vikunja; Console only reads them. ${gap('Bucket names, bot names and the Vikunja address')}

    ${groups || '

    No open tasks.

    '}${doneHtml}${missingHtml}`; + return `${head('Tasks', freshness(rows))}

    Edit tasks in Vikunja; Console only reads them. ${gap('Bucket names, bot names and the Vikunja address')}

    ${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 => `${txt(c.role)}${txt(c.holder_run)}${txt(c.harness)}${c.address ? `${txt(c.address)}` : ''}${when(c.at)}`).join(''); return `${head('Agents', readNote())}

    Held roles ${claims.length}

    The current holder of each claimed role. A role nobody holds does not appear.

    - ${claims.length ? `
    ${['Role', 'Holder', 'Harness', 'Held since'].map(h => ``).join('')}${rows}
    ${h}
    ` : '

    No role is held right now.

    '}
    + ${claims.length ? `
    ${['Role', 'Holder', 'Harness', 'Held since'].map(h => ``).join('')}${rows}
    ${h}
    ` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}

    Not shown here yet: ${gap('Role instances, launch limits and model families')} ${gap('Launch state')} ${gap('Live and ended sessions')} ${gap('Credential notices')}

    Board seats stay on the control board. This view is about roles.

    `; } @@ -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 '

    Nothing of this kind in the trail.

    '; + 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 `
      ${shown.map(r => `
    1. ${clock(r.at)}${txt(r.kind)}

      ${txt(r.text)}${r.decision && !r.kind.startsWith('decision.') && !r.kind.startsWith('delivery.') && SAFE.test(String(r.decision)) ? ` decision ${txt(short(r.decision))}` : ''}

      ${actor(r.actor, r.by)}${r.note ? `${esc(r.note)}` : ''}
    2. `).join('')}
    `; } async function trailView(get, kind, ref, q) { @@ -341,8 +347,8 @@ } // Not found and states - function notFound(why) { return `${head('Not found')}

    ${why || 'No page at this address.'} Go to the inbox

    `; } - const loadingView = () => `${head('Reading…')}

    Reading from the bus.

    `; + function notFound(why) { return `${head('Not found')}
    ${why || 'No page at this address.'}

    Nothing is at ${txt(location.hash || '#/')}. Go to the Board or press Ctrl K to search.

    `; } + const loadingView = () => `${head('Reading…')}
    Reading from the bus.
    `; const retry = '
    '; // 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))}`; - const staleBanner = err => ``; + // 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))}${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 => ``; + // 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 not read: refused' : state === 'failed' ? 'Bus not read: the read failed' + : `Bus read ${esc(stamp(readAt))}${readAt ? ` (${esc(ago(readAt))})` : ''}${state === 'stale' ? ' · stale: the last read failed' : ''}`; + } // ---------------------------------------------------------------- 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 = `${esc(text)}${error ? '' : ''}`; + 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) => `
  • ${txt(i.label)}${txt(i.group)}
  • `).join('') + : '
  • No view, decision or task matches.
  • '; + 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 diff --git a/packages/webui/src/public/icons.svg b/packages/webui/src/public/icons.svg new file mode 100644 index 00000000..bda6fd69 --- /dev/null +++ b/packages/webui/src/public/icons.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html index e87a0824..7faa1883 100644 --- a/packages/webui/src/public/index.html +++ b/packages/webui/src/public/index.html @@ -1,24 +1,29 @@ - + Mosaic Console - + -
    MMosaicConsole -
    +
    MosaicConsole + +

    +

    Control board

    Loading board…

    - + +

    Waiting on you 0

    Loading sessions…

    Seen 0
    -

    All sessions 0

    Select an agent to inspect. Arrow keys move between agents; Enter opens the inspector. Scroll the table for all columns.

    +

    All sessions 0

    Select an agent to inspect. Arrow keys move between agents; Enter opens the inspector. Scroll the table for all columns.

    Loading sessions…
    +
    +

    ↑↓ move · Enter open · Esc close

    diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css new file mode 100644 index 00000000..ed6d2d96 --- /dev/null +++ b/packages/webui/src/public/shell.css @@ -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 · action". */ +.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}} diff --git a/packages/webui/src/public/tokens.css b/packages/webui/src/public/tokens.css new file mode 100644 index 00000000..bd895c63 --- /dev/null +++ b/packages/webui/src/public/tokens.css @@ -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; +} diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 0a03da27..280a228f 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -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' }); diff --git a/packages/webui/tests/browser.mjs b/packages/webui/tests/browser.mjs index c501f6c5..6aa7ad77 100644 --- a/packages/webui/tests/browser.mjs +++ b/packages/webui/tests/browser.mjs @@ -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() { diff --git a/packages/webui/tests/serve.test.mjs b/packages/webui/tests/serve.test.mjs index a3a21517..cec64cce 100644 --- a/packages/webui/tests/serve.test.mjs +++ b/packages/webui/tests/serve.test.mjs @@ -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); diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs new file mode 100644 index 00000000..6ca5c065 --- /dev/null +++ b/packages/webui/tests/shell.test.mjs @@ -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 (, ) 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, /]*>[^<]*(Resolve|Decide|Approve)/i); + assert.doesNotMatch(read('bus.js').toString(), /method:\s*['"](POST|PUT|PATCH|DELETE)/); + assert.equal(read('icons.svg').toString().includes(' { + 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: 'Push?' }]; + s.rows.tasks = [{ ...s.rows.tasks[0], fields: { ...s.rows.tasks[0].fields, title: 'Bold 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)'), ['Push?', '#7 Bold 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); } +});