feat(webui): W15 shell rail, drawer and Projects nav (#797)

This commit is contained in:
jason.woltje committed 2026-11-05 21:50:48 -06:00
1 parent 191061a9e7
commit 3fd82b0840
12 files changed
+229 -37

No files matched your search

+36 -4
View File
@@ -1252,6 +1252,10 @@
document.body.classList.toggle('on-bus', !board);
$('bus-view').hidden = board;
sections(path);
// Conversations owns the project tree. The optional mount receives only the route,
// and must obtain authorized project data itself. It may be absent during integration.
window.MosaicProjectTree?.mount?.({ container: $('project-tree'), path });
document.querySelectorAll('.rail a').forEach(a => { const target = a.getAttribute('href').slice(1); const active = target === '/projects' ? /^\/(projects|chat)(\/|$)/.test(path) : target === '/' ? path === '/' : path === target; if (active) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); });
const chatPath = path === '/projects' ? '/chat' : /^\/projects\/(threads|rooms)\//.test(path) ? '/chat' + path.slice('/projects'.length) : /^\/projects\/[^/]+$/.test(path) ? '/chat/projects/' + path.slice('/projects/'.length) : path;
window.MosaicChat?.leave(chatPath);
if (path === '/settings/display' && window.MosaicDisplay) {
@@ -1259,7 +1263,9 @@
}
if (chatPath === '/chat' || chatPath.startsWith('/chat/')) {
// Row 177 (#1675): the tester Console writes to its own threads.
const focusedHeading = document.activeElement === $('bus-view').querySelector('h1');
await window.MosaicChat.render({ path: chatPath, container: $('bus-view'), canSend: canSend || isTester, tester: isTester, navigated });
if ((focusedHeading || shown === null) && document.activeElement === document.body) $('bus-view').querySelector('h1')?.focus({ preventScroll: true });
if (g !== gen || out()) return;
if (chatPath === '/chat') { const h = $('bus-view').querySelector('h1'); if (h) { h.textContent = 'Projects'; document.title = 'Projects · Mosaic Console'; } }
shown = route; schedule(); return;
@@ -1472,8 +1478,20 @@
// Normalize the renderer's legacy project links without changing the chat API.
function projectChrome() {
const path = routeOf().split('?')[0];
if (!(path === '/chat' || path.startsWith('/chat/') || path === '/projects' || path.startsWith('/projects/'))) return;
if (!(path === '/chat' || path.startsWith('/chat/') || path === '/projects' || path.startsWith('/projects/'))) { $('project-context').replaceChildren(); $('project-context').dataset.signature = ''; return; }
const view = $('bus-view');
const context = $('project-context');
const conversation = view.querySelector('.chat-conversation, .chat-room');
const projectLink = conversation?.querySelector('p a[href^="#/chat/projects/"], p a[href^="#/projects/"]');
const overview = /^\/(?:projects\/|chat\/projects\/)([0-9a-f-]+)$/i.exec(path);
const project = projectLink?.textContent || (overview ? view.querySelector('.chat-area h1')?.textContent : '');
const name = conversation?.querySelector('h1')?.textContent || '';
const href = projectLink?.getAttribute('href') || (overview ? '#/projects/' + overview[1] : '');
const signature = JSON.stringify([project || '', name, href]);
if (context.dataset.signature !== signature) {
context.dataset.signature = signature; context.replaceChildren();
if (project && href) { const label = document.createElement('p'); label.className = 'tree-title'; label.textContent = 'Current project'; const link = document.createElement('a'); link.href = href; link.textContent = project; link.className = 'context-project'; context.append(label, link); if (name) { const title = document.createElement('p'); title.className = 'context-conversation'; title.textContent = name; context.append(title); } }
}
for (const a of view.querySelectorAll('a[href^="#/chat"]')) {
const h = a.getAttribute('href');
const canonical = h === '#/chat' ? '#/projects' : h.startsWith('#/chat/projects/') ? '#/projects/' + h.slice('#/chat/projects/'.length) : h.replace(/^#\/chat\/(threads|rooms)\//, '#/projects/$1/');
@@ -1485,11 +1503,25 @@
if (h && h.textContent === 'Chat') { h.textContent = 'Projects'; document.title = 'Projects · Mosaic Console'; }
}
}
new MutationObserver(projectChrome).observe($('bus-view'), { childList: true, subtree: true });
let lastViewFocus = null;
$('bus-view').addEventListener('focusin', e => { lastViewFocus = e.target; });
new MutationObserver(() => {
projectChrome();
if (lastViewFocus && !lastViewFocus.isConnected && document.activeElement === document.body) {
const replacement = lastViewFocus.id && document.getElementById(lastViewFocus.id);
(replacement && !replacement.disabled ? replacement : $('bus-view').querySelector('h1'))?.focus({ preventScroll: true });
}
}).observe($('bus-view'), { childList: true, subtree: true });
// The drawer keeps the background inert and returns focus to its opener.
const nav = $('console-nav'), opener = $('nav-open'), closer = $('nav-close'), scrim = $('nav-scrim');
const processing = $('processing-controls'), mobileProcessing = $('processing-mobile');
function placeProcessing() {
if (innerWidth < 760 && processing.parentElement !== mobileProcessing) mobileProcessing.append(processing);
else if (innerWidth >= 760 && processing.parentElement !== nav) nav.insertBefore(processing, nav.querySelector('.tree-title'));
}
placeProcessing();
let drawerOpen = false;
const background = [document.querySelector('.cmdbar'), $('main'), $('inspector'), document.querySelector('.foot')];
const background = [document.querySelector('.cmdbar'), document.querySelector('.rail'), mobileProcessing, $('main'), $('inspector'), document.querySelector('.foot')];
function drawer(open, restore = true) {
drawerOpen = open; document.body.classList.toggle('nav-open', open);
opener.setAttribute('aria-expanded', String(open)); scrim.hidden = !open;
@@ -1510,7 +1542,7 @@
else if (!e.shiftKey && document.activeElement === controls.at(-1)) { e.preventDefault(); controls[0].focus(); }
});
nav.addEventListener('click', e => { if (drawerOpen && e.target.closest('a[href^="#/"]')) drawer(false, false); });
window.addEventListener('resize', () => { if (drawerOpen && innerWidth >= 760) drawer(false, false); else if (drawerOpen) $('nav-overflow').hidden = nav.scrollHeight <= nav.clientHeight + 1; });
window.addEventListener('resize', () => { if (drawerOpen && innerWidth >= 760) drawer(false, false); placeProcessing(); if (drawerOpen) $('nav-overflow').hidden = nav.scrollHeight <= nav.clientHeight + 1; });
window.addEventListener('hashchange', () => render(true));
$('refresh').addEventListener('click', () => render(false));
$('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
+2 -2
View File
@@ -32,7 +32,7 @@
window.addEventListener('hashchange', e => {
const destination = location.hash || '#/';
if (current === '#/settings/display' && destination !== current && dirty) {
e.stopImmediatePropagation(); pending = destination; history.replaceState(null, '', current); dialog.showModal(); dialog.querySelector('[data-choice=save]').focus(); return;
e.stopImmediatePropagation(); pending = destination; history.replaceState(null, '', current); document.getElementById('display-leave-status').textContent = ''; dialog.showModal(); dialog.querySelector('[data-choice=save]').focus(); return;
}
current = destination;
});
@@ -45,7 +45,7 @@
if (choice === 'edit') { pending = null; document.querySelector('#display-form input')?.focus(); return; }
const destination = pending; pending = null; if (choice === 'save') sessionStorageNotice(); location.hash = destination;
});
function sessionStorageNotice() { const a = document.getElementById('announce'); if (a) a.textContent = 'Sidebar display saved.'; }
function sessionStorageNotice() { const a = document.getElementById('display-announcement'); if (a) a.textContent = 'Sidebar display saved.'; }
document.addEventListener('mosaic-signed-out', () => { draft = { ...saved }; dirty = false; pending = null; if (dialog.open) dialog.close(); });
window.MosaicDisplay = { render };
})();
+3 -2
View File
@@ -13,8 +13,9 @@
<p class="fresh" id="fresh"><span id="fresh-board"></span><span id="fresh-bus"></span></p>
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button><form id="sign-out" method="post" action="/auth/logout" hidden><button class="btn" type="submit">Sign out</button></form></div></header>
<div id="processing-banner" class="processing-banner" role="alert" hidden></div>
<div id="processing-mobile"></div>
<div class="frame">
<div id="nav-scrim" hidden></div><nav class="tree" id="console-nav" aria-label="Console"><div class="drawer-head"><strong>Navigation</strong><button type="button" class="btn" id="nav-close">Close</button></div><p id="nav-overflow" class="small muted" hidden>Scroll for more destinations</p><div id="project-context"></div><div id="processing-controls" class="processing-controls" role="region" aria-label="Instance processing">
<div id="nav-scrim" hidden></div><nav class="rail" aria-label="Primary destinations"><a href="#/projects" aria-label="Projects" title="Projects"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-inbox"/></svg><span>Projects</span></a><a href="#/inbox" aria-label="Inbox" title="Inbox"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-inbox"/></svg><span>Inbox</span></a><a href="#/" aria-label="Board" title="Board"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-board"/></svg><span>Board</span></a><a href="#/settings/display" aria-label="Sidebar display" title="Sidebar display"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-settings"/></svg><span>Display</span></a></nav><nav class="tree" id="console-nav" aria-label="Console"><div class="drawer-head"><strong>Navigation</strong><button type="button" class="btn" id="nav-close">Close</button></div><p id="nav-overflow" class="small muted" hidden>Scroll for more destinations</p><div id="project-context" aria-label="Current project and conversation"></div><!-- Conversations slice owns this mount and its contents. --><div id="project-tree" aria-label="Project conversations"></div><div id="processing-controls" class="processing-controls" role="region" aria-label="Instance processing">
<strong>Processing</strong><p id="processing-state" role="status">Reading processing state.</p>
<button id="processing-halt" type="button" class="btn" disabled>Stop processing</button>
<button id="processing-resume" type="button" class="btn" disabled hidden>Resume processing</button>
@@ -34,7 +35,7 @@
<div id="bus-view" hidden></div>
</main>
<aside class="inspector" id="inspector" aria-labelledby="inspector-title" hidden><div class="inspector-head"><h2 id="inspector-title" tabindex="-1">Inspector</h2><button type="button" class="btn" id="close">Close</button></div><div id="inspection"></div></aside>
</div><footer class="foot"><span id="footer">Mosaic Stack</span><span id="board-url"></span></footer><div id="announce" class="sr-only" aria-live="polite"></div>
</div><footer class="foot"><span id="footer">Mosaic Stack</span><span id="board-url"></span></footer><div id="announce" class="sr-only" aria-live="polite"></div><div id="display-announcement" class="sr-only" role="status" aria-live="polite"></div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<dialog class="pk" id="cmdk-dialog" aria-label="Go to"><div class="pk-in"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><input id="cmdk-input" type="text" role="combobox" aria-expanded="true" aria-controls="cmdk-list" aria-autocomplete="list" aria-label="Go to a view, decision or task" autocomplete="off" spellcheck="false"></div><ul id="cmdk-list" class="pk-list" role="listbox" aria-label="Results"></ul><p class="pk-foot small"><kbd>↑</kbd><kbd>↓</kbd> move · <kbd>Enter</kbd> open · <kbd>Esc</kbd> close</p></dialog>
</body></html>
+27 -3
View File
@@ -136,7 +136,7 @@ body.on-bus #board-refused{display:none}
.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}
#announce,#display-announcement{position:fixed;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){
@@ -181,6 +181,7 @@ body.on-bus #board-refused{display:none}
.project-archive-control,.project-archived-filter{min-height:44px;border-radius:var(--r)}
#nav-open,.drawer-head{display:none}
.s1-section{min-height:44px}
.content .btn.primary{min-height:var(--action-target)}
[data-sidebar-density=compact] .s1-section{min-height:44px;padding-block:3px}
.display-settings fieldset{border:1px solid var(--line);border-radius:var(--r);margin:0 0 16px;padding:12px}
.display-settings label{display:block;min-height:44px;padding:10px 0}
@@ -193,11 +194,34 @@ body.on-bus #board-refused{display:none}
#nav-open{display:inline-flex;min-height:44px;align-items:center}
.drawer-head{display:flex;align-items:center;justify-content:space-between;position:sticky;top:-12px;z-index:2;background:var(--surface);padding:8px 0}
#nav-close{min-height:44px}
.tree{display:none;position:fixed;inset:0 auto 0 0;z-index:100;width:min(330px,calc(100vw - 32px));height:100dvh;max-height:none;overflow-y:auto;flex-direction:column;flex-wrap:nowrap;align-items:stretch;padding:12px;background:var(--surface);box-shadow:var(--shadow)}
body.nav-open .tree{display:flex}
.tree{display:flex;visibility:hidden;pointer-events:none;position:fixed;inset:0 auto 0 0;z-index:100;width:min(330px,calc(100vw - 32px));height:100dvh;max-height:none;overflow-y:auto;flex-direction:column;flex-wrap:nowrap;align-items:stretch;padding:12px;background:var(--surface);box-shadow:var(--shadow)}
body.nav-open .tree{visibility:visible;pointer-events:auto}
#nav-scrim:not([hidden]){display:block;position:fixed;inset:0;background:rgb(0 0 0 / .5);z-index:99}
.s1-sections{display:grid;overflow:visible;flex-basis:auto}
.s1-sections>li{width:100%}
.s1-section{white-space:normal}
.content h1{font-size:22px}
}
/* W15 navigation: the rail is a sibling of the project/sidebar column. */
.rail{display:none}
@media (min-width:960px){
.frame,body.on-bus .frame,body.has-inspector .frame,body.on-bus.has-inspector .frame{grid-template-columns:var(--rail-width) var(--sidebar-width) minmax(0,1fr)}
body.has-inspector .frame,body.on-bus.has-inspector .frame{grid-template-columns:var(--rail-width) var(--sidebar-width) minmax(0,1fr) 340px}
.frame.frame > .tree{position:sticky;width:auto}
.frame.frame > .content{grid-column:3}
body{--processing-nav:calc(var(--rail-width) + var(--sidebar-width))}
.rail{position:sticky;top:var(--bar-h);height:calc(100vh - var(--bar-h) - var(--foot-h));display:flex;flex-direction:column;gap:4px;padding:10px 6px;border-right:1px solid var(--line);background:var(--surface)}
.rail a{display:flex;min-height:58px;align-items:center;justify-content:center;flex-direction:column;gap:3px;border-radius:var(--r);color:var(--text);font-size:11px;text-decoration:none}
.rail a:hover,.rail a[aria-current=page]{background:var(--raised);color:var(--strong)}
.rail a[aria-current=page]{box-shadow:inset 3px 0 var(--action)}
}
#project-context:not(:empty){border-bottom:1px solid var(--line);padding:0 4px 10px;overflow-wrap:anywhere}
.context-project{display:block;min-height:var(--action-target);padding:10px 0;color:var(--action);font-weight:700}
.context-conversation{margin:0;color:var(--strong);overflow-wrap:anywhere}
#project-tree:not(:empty){border-bottom:1px solid var(--line);padding-bottom:10px}
.project-archive-control,.project-archived-filter{border:1px solid var(--border);background:var(--surface);color:var(--text);padding:8px 12px}
.project-archive-control:hover,.project-archived-filter:hover{background:var(--raised)}
a{ text-underline-offset: .15em }
button:disabled,[aria-disabled=true]{cursor:not-allowed}
@media (max-width:759px){.tree{width:100%;overscroll-behavior:contain;scrollbar-gutter:stable}#console-nav>*{flex:0 0 auto}}
+6 -2
View File
@@ -6,10 +6,14 @@
--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;
--r-lg: 6px;
--r-table: 6px;
--pad: 16px;
--gap: 12px;
/* Shared W15 geometry and interaction tokens. */
--rail-width: 88px;
--sidebar-width: 226px;
--action-target: 44px;
}
/* Harbor: the default palette */
+1
View File
@@ -35,6 +35,7 @@ const files = new Map([
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
['/display.js', ['display.js', 'text/javascript; charset=utf-8']],
['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']],
// Row 225 (C2-10b): the one reply renderer, shared with the Discord relay, imported by chat.js.
['/render.mjs', ['../../../library/src/render.mjs', 'text/javascript; charset=utf-8']],
+1 -1
View File
@@ -19,7 +19,7 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/agents.js', '/bus.js', '/requests.js']);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/agents.js', '/bus.js', '/requests.js']);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false);
+2 -2
View File
@@ -221,10 +221,10 @@ test('chat browser: list/create named projects and threads, hostile labels inert
const projectText = await b.evaluate('document.querySelector(".chat-projects").textContent');
assert.doesNotMatch(projectText, /[\u202a-\u202e\u2066-\u2069]/u);
assert.match(projectText, /\\u202e/);
assert.equal(await b.evaluate(`!!document.querySelector('#sections a[href="#/chat"]')`), true);
assert.equal(await b.evaluate(`!!document.querySelector('#sections a[href="#/projects"]')`), true);
await b.key('k', 'KeyK', 2);
await wait('document.querySelector("#cmdk-dialog").open');
assert.equal(await b.evaluate(`!!document.querySelector('#cmdk-list [data-href="#/chat"]')`), true);
assert.equal(await b.evaluate(`!!document.querySelector('#cmdk-list [data-href="#/projects"]')`), true);
await b.key('Escape');
await set('#chat-project-name', 'new project');
await set('#chat-repo', 'stack');
+6 -4
View File
@@ -1,5 +1,5 @@
// #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
// icons match docs/design, shared tokens remain available, mono fonts match their 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
@@ -18,8 +18,10 @@ 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('icons retain the design source and W15 geometry tokens are present', () => {
assert.ok(read('icons.svg').equals(read('icons.svg', design)));
const css = read('tokens.css').toString();
for (const token of ['--rail-width: 88px', '--sidebar-width: 226px', '--action-target: 44px']) assert.ok(css.includes(token), token);
});
test('JetBrains Mono files match the digests in their sources file, with the OFL text', () => {
@@ -135,7 +137,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
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('getComputedStyle(document.documentElement).getPropertyValue("--r-lg").trim()'), '6px');
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');
+1 -1
View File
@@ -56,7 +56,7 @@ async function page(b, base) {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
await b.viewport(400, 900);
await b.navigate(base);
await wait(`window.__stubReady === true && document.querySelectorAll("#sections a").length===10 && document.querySelector('#sections a[href="#/chat"]')?.textContent.trim()==='Chat'`);
await wait(`window.__stubReady === true && document.querySelectorAll("#sections a").length===10 && document.querySelector('#sections a[href="#/projects"]')?.textContent.trim()==='Projects'`);
return { wait, text, count, flat, refresh, go };
}
+13 -11
View File
@@ -1,4 +1,4 @@
import { mkdir, writeFile, readFile } from 'node:fs/promises';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
@@ -7,23 +7,21 @@ export const ids = Object.freeze({ project: '10000000-0000-4000-8000-00000000000
// All API responses are explicit, and all network destinations outside the fixture
// origin are refused. Tests may override a response by assigning window.__scenario.
export async function w15Fixture(t, { serveDisplay = false } = {}) {
export async function w15Fixture(t, { scenario = {}, slice = 'shell' } = {}) {
const f = await fixture(); t.after(() => f.close());
const b = await browser({ machine: false }); t.after(() => b.close());
const errors = []; const networkErrors = [];
b.on('Network.responseReceived', ({response}) => { if (response.status >= 400) networkErrors.push({url:response.url,status:response.status}); });
b.on('Network.loadingFailed', e => networkErrors.push({error:e.errorText}));
await b.call('Network.enable');
const display = serveDisplay ? (await readFile(new URL('../src/public/display.js', import.meta.url))).toString('base64') : null;
b.on('Fetch.requestPaused', ({ requestId, request }) => {
const url = new URL(request.url);
if (display && url.origin === f.base && url.pathname === '/display.js') { void b.call('Fetch.fulfillRequest', { requestId, responseCode: 200, responseHeaders: [{name:'Content-Type',value:'text/javascript'}], body:display }); return; }
const allowed = url.origin === f.base;
void b.call(allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest', allowed ? { requestId } : { requestId, errorReason: 'BlockedByClient' }).catch(e => errors.push(String(e)));
});
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
const ids = ${JSON.stringify(ids)}; window.__writes=[]; window.__unexpected=[]; window.__errors=[]; window.__scenario={};
const ids = ${JSON.stringify(ids)}; window.__writes=[]; window.__unexpected=[]; window.__errors=[]; window.__scenario=${JSON.stringify(scenario)};
addEventListener('error', e => __errors.push(e.message)); addEventListener('unhandledrejection', e => __errors.push(String(e.reason)));
const data = (x,status=200) => new Response(JSON.stringify({at:ids.observed,...x}), {status,headers:{'content-type':'application/json'}});
const real = fetch.bind(window);
@@ -31,15 +29,19 @@ export async function w15Fixture(t, { serveDisplay = false } = {}) {
const url=new URL(typeof input==='string' ? input : input.url, location.href), path=url.pathname+url.search, method=(init.method||'GET').toUpperCase();
if (url.origin!==location.origin) { __unexpected.push(method+' '+url.href); return new Response('refused',{status:403}); }
if (!url.pathname.startsWith('/api/')) return real(input,init);
if(method!=='GET') { __writes.push({method,path,body:init.body||null}); return data({error:'fixture-write-refused'},403); }
if(method!=='GET') __writes.push({method,path,body:init.body||null});
const key = method+' '+path;
if (Object.hasOwn(__scenario,key)) { const response=__scenario[key]; return data(response.body ?? {},response.status ?? 200); }
if(method!=='GET') { __unexpected.push(key); return data({error:'fixture-write-refused'},403); }
if (Object.hasOwn(__scenario,path)) return data(__scenario[path]);
if(path.startsWith('/api/library/status?') || path==='/api/processing' || path==='/api/bus-status') return new Response(JSON.stringify({error:'fixture-unavailable'}),{status:503,headers:{'content-type':'application/json'}});
if(path==='/api/config') return data({send:false,edit:false,decide:false});
if(/^\\/api\\/library\\/status\\?project=[^&]+$/.test(path) || path==='/api/processing' || path==='/api/bus-status') return new Response(JSON.stringify({error:'fixture-unavailable'}),{status:503,headers:{'content-type':'application/json'}});
if(path==='/api/config') return data({send:true,edit:false,decide:false});
if(path==='/api/business') return data({projects:[ids.repository]});
if(path==='/api/chat/projects') return data({rows:[{id:ids.project,name:'Active project',repo:ids.repository},{id:ids.empty,name:'Empty project',repo:null},{id:ids.archived,name:'Archived project',archived:true,repo:null}]});
if(path==='/api/chat/instances') return data({rows:[]});
if(path==='/api/chat/instances') return data({rows:[{instance:'assistant',name:'Assistant'}]});
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/shares$/.test(path)) return data({rows:[]});
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/sessions$/.test(path)) return data({rows:[{id:ids.session,name:'Default',default:true}]});
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/messages/.test(path)) return data({rows:[],after:null,hasMore:false});
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/messages(?:\\?.*)?$/.test(path)) return data({rows:[],after:null,hasMore:false});
if(path==='/api/chat/threads' || /^\\/api\\/chat\\/threads\\?(shared=true|project=)/.test(path)) return data({rows:[{id:ids.thread,project:ids.project,name:'Project conversation',instance:'assistant'}]});
if(/^\\/api\\/bus\\/(inbox|messages|agents|tasks|queue|trail)(\\?|$)/.test(path)) return data({rows:[],unread:0});
__unexpected.push(method+' '+path); return new Response(JSON.stringify({error:'fixture-unexpected-api'}),{status:403,headers:{'content-type':'application/json'}});
@@ -50,7 +52,7 @@ export async function w15Fixture(t, { serveDisplay = false } = {}) {
if (!process.env.MOSAIC_UX_SHOTS) return;
const dir = process.env.MOSAIC_UX_SHOTS; await mkdir(dir, { recursive: true });
await b.evaluate('document.fonts.ready');
const prefix = `shell-${state}-${width}`;
const prefix = `${slice}-${state}-${width}`;
await b.screenshot(join(dir, `${prefix}.png`));
const metrics = await b.call('Page.getLayoutMetrics');
const size = metrics.cssContentSize || metrics.contentSize;
+131 -5
View File
@@ -1,26 +1,38 @@
import test from 'node:test';
import test, { after } from 'node:test';
import assert from 'node:assert/strict';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { w15Fixture, ids } from './w15-fixture.mjs';
const captures = [];
after(async () => { if (process.env.MOSAIC_UX_SHOTS) await writeFile(join(process.env.MOSAIC_UX_SHOTS,'manifest.json'),JSON.stringify({candidate:'uncommitted; see report',command:'node --test --test-concurrency=1 packages/webui/tests/w15-shell-browser.test.mjs',result:'see saved test output',captures:captures.filter(Boolean)},null,2)+'\n'); });
test('shell fixture: project navigation and narrow drawer', { timeout: 90000 }, async t => {
const { f,b,wait,capture } = await w15Fixture(t); const captures=[];
const { f,b,wait,capture } = await w15Fixture(t, {scenario:{'/api/processing':{version:1,generation:'12345678-1234-4234-8234-123456789abc',halted:false,quiescent:true,remaining:0,stopped:0}}});
for (const width of [1440,390]) {
await b.viewport(width,1000); await b.navigate(f.base+'/#/projects');
await wait('document.querySelector(".chat-area h1")?.textContent==="Projects"');
await wait('!document.querySelector("#processing-halt").disabled');
assert.equal(await b.evaluate(`(() => { const s=document.querySelector('#processing-halt'), r=s.getBoundingClientRect(), bar=document.querySelector('.cmdbar').getBoundingClientRect(); s.focus(); return document.querySelectorAll('#processing-halt').length===1 && !document.body.classList.contains('nav-open') && r.top>=bar.bottom && r.bottom<=innerHeight && getComputedStyle(s).visibility==='visible' && getComputedStyle(s).order==='0' && document.activeElement===s; })()`), true);
assert.match(await b.evaluate('document.querySelector("#sections").textContent'), /Projects/);
assert.equal(await b.evaluate('document.querySelector("#sections [aria-current=page]").textContent.trim()'), 'Projects');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
if (width === 1440) assert.deepEqual(await b.evaluate('[Math.round(document.querySelector(".rail").getBoundingClientRect().width),Math.round(document.querySelector(".tree").getBoundingClientRect().x),Math.round(document.querySelector(".tree").getBoundingClientRect().width),Math.round(document.querySelector("#main").getBoundingClientRect().x)]'), [88,88,226,314]);
captures.push(await capture('projects-list',width,'#/projects'));
if(width===390) {
await b.evaluate('document.querySelector("#nav-open").focus();document.querySelector("#nav-open").click()');
assert.equal(await b.evaluate('document.activeElement.id'), 'nav-close');
assert.equal(await b.evaluate('document.querySelector("#main").inert'), true);
assert.equal(await b.evaluate('document.querySelector("#processing-mobile").inert'), true);
assert.equal(await b.evaluate('document.querySelector("#sections [aria-current]").getBoundingClientRect().top < innerHeight'), true);
captures.push(await capture('navigation-open',width,'#/projects'));
await b.key('Tab','Tab',8); assert.notEqual(await b.evaluate('document.activeElement.tagName'), 'BODY');
await b.key('Escape'); assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open');
assert.equal(await b.evaluate('document.querySelector("#main").inert'), false);
const point = await b.evaluate('(()=>{const r=document.querySelector("#nav-open").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()');
await b.call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',clickCount:1});
await b.call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',clickCount:1});
assert.equal(await b.evaluate('document.activeElement.id'), 'nav-close');
await b.key('Enter'); assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open');
} else captures.push(await capture('navigation-open',width,'#/projects'));
const sessionRoute = `#/projects/threads/${ids.thread}/sessions/${ids.session}`;
await b.evaluate(`location.hash=${JSON.stringify(sessionRoute)}`);
@@ -29,12 +41,15 @@ test('shell fixture: project navigation and narrow drawer', { timeout: 90000 },
await b.evaluate(`location.hash=${JSON.stringify(sessionRoute.replace('#/projects/','#/chat/'))}`);
await wait(`document.querySelector('.chat-conversation')?.dataset.session===${JSON.stringify(ids.session)}`);
assert.deepEqual(await b.evaluate('window.__writes'), []);
assert.deepEqual(await b.evaluate('window.__unexpected'), []);
}
if(process.env.MOSAIC_UX_SHOTS) await writeFile(join(process.env.MOSAIC_UX_SHOTS,'manifest.json'),JSON.stringify({candidate:'uncommitted; see report for committed SHA',command:'node --test --test-concurrency=1 packages/webui/tests/w15-shell-browser.test.mjs',result:'passed',captures:captures.filter(Boolean)},null,2)+'\n');
await b.viewport(400,1000); await b.navigate(f.base+'/#/projects');
await wait('document.querySelector(".chat-area h1")?.textContent==="Projects" && !document.querySelector("#processing-halt").disabled');
captures.push(await capture('projects-list',400,'#/projects'));
});
test('display module with simulated owner static mapping: save, failure and destination', {timeout:90000}, async t => {
const { f,b,wait } = await w15Fixture(t, { serveDisplay: true });
test('display module on the real fixture server: save, failure and destination', {timeout:90000}, async t => {
const { f,b,wait } = await w15Fixture(t);
await b.viewport(390,1000); await b.navigate(f.base+'/#/settings/display');
await wait('!!document.querySelector("#display-form")');
await b.evaluate('document.querySelector("[name=density][value=compact]").click()');
@@ -42,6 +57,7 @@ test('display module with simulated owner static mapping: save, failure and dest
await wait('document.querySelector(".display-dialog").open');
await b.evaluate('document.querySelector("[data-choice=save]").click()');
await wait('location.hash==="#/tasks"');
assert.match(await b.evaluate('document.querySelector("#display-announcement").textContent'), /Sidebar display saved/);
assert.equal(await b.evaluate('JSON.parse(localStorage.getItem("mosaic-sidebar-display-v1")).density'), 'compact');
await b.navigate(f.base+'/#/settings/display');
await wait('!!document.querySelector("#display-form")');
@@ -55,3 +71,113 @@ test('display module with simulated owner static mapping: save, failure and dest
await wait('location.hash==="#/inbox"');
assert.deepEqual(await b.evaluate('window.__writes'), []);
});
test('canonical routes survive reload and history without writes', { timeout: 90000 }, async t => {
const { f,b,wait } = await w15Fixture(t);
const paths = [
['#/projects', '.chat-projects'],
[`#/projects/${ids.project}`, '.chat-area h1'],
[`#/projects/threads/${ids.thread}/sessions/${ids.session}`, `.chat-conversation[data-session="${ids.session}"]`],
[`#/projects/rooms/${ids.project}`, '.chat-room'],
];
for (const [route,selector] of paths) {
await b.navigate(f.base + '/' + route);
await wait(`!!document.querySelector(${JSON.stringify(selector)})`);
await b.evaluate('window.__beforeReload=true');
await b.call('Page.reload');
await wait(`!window.__beforeReload && document.readyState==='complete' && !!document.querySelector(${JSON.stringify(selector)})`);
assert.equal(await b.evaluate('location.hash'), route);
try { await wait('document.activeElement.tagName !== "BODY"'); } catch (e) { throw new Error(route + ': ' + e.message); }
await b.evaluate('location.hash="#/tasks"');
await wait('location.hash==="#/tasks"');
await b.evaluate('history.back()');
await wait(`location.hash===${JSON.stringify(route)} && !!document.querySelector(${JSON.stringify(selector)})`);
await b.evaluate('history.forward()');
await wait('location.hash==="#/tasks"');
}
assert.deepEqual(await b.evaluate('window.__writes'), []);
});
test('drawer preserves conversation draft, displays known context and contains focus', { timeout: 90000 }, async t => {
const { f,b,wait,capture } = await w15Fixture(t);
for (const width of [1440,390]) {
await b.viewport(width,1000);
const route = `#/projects/threads/${ids.thread}/sessions/${ids.session}`;
await b.navigate(f.base + '/' + route);
await wait('!!document.querySelector("#chat-body") && !!document.querySelector("#project-context .context-conversation")');
await b.evaluate('document.querySelector("#chat-body").value="Unsent fixture draft";document.querySelector("#chat-body").dispatchEvent(new Event("input",{bubbles:true}))');
assert.match(await b.evaluate('document.querySelector("#project-context").textContent'), /Active project.*Project conversation/s);
if (width===390) {
await b.evaluate('document.querySelector("#nav-open").click()');
const ax = await b.call('Accessibility.getFullAXTree');
assert.ok(ax.nodes.some(n => n.role?.value==='navigation' && n.name?.value==='Console'));
assert.ok(ax.nodes.some(n => n.name?.value==='Current project and conversation'));
await b.key('Tab','Tab',8);
assert.equal(await b.evaluate('document.activeElement.closest("#console-nav") !== null'), true);
await b.key('Escape');
assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open');
}
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'Unsent fixture draft');
captures.push(await capture('navigation-closed-with-draft',width,route));
await b.evaluate('document.querySelector("#refresh").focus();document.querySelector("#refresh").click()');
await wait('document.querySelector("#chat-body")?.value==="Unsent fixture draft"');
assert.notEqual(await b.evaluate('document.activeElement.tagName'), 'BODY');
}
assert.deepEqual(await b.evaluate('window.__writes'), []);
assert.deepEqual(await b.evaluate('window.__unexpected'), []);
});
test('display screenshots and zoom, dark mode, hostile labels', { timeout: 90000 }, async t => {
const hostile = '<img src=x onerror=alert(1)> ' + 'Long project '.repeat(35) + '\u202eevil';
const { f,b,wait,capture } = await w15Fixture(t, {scenario:{'/api/chat/projects':{rows:[{id:ids.project,name:hostile,repo:ids.repository}]}}});
for (const width of [1440,390]) {
await b.viewport(width,1000); await b.navigate(f.base+'/#/settings/display');
await wait('!!document.querySelector("#display-form")');
assert.ok(await b.evaluate('document.querySelector("#display-form button[type=submit]").getBoundingClientRect().height >= 44'));
await b.evaluate('document.documentElement.style.zoom="";document.documentElement.removeAttribute("data-mode")');
await b.evaluate('document.querySelector("[name=density][value=compact]").click();document.querySelector("#display-form").requestSubmit()');
await wait('document.querySelector("#display-status").textContent.includes("saved")');
captures.push(await capture('display-saved',width,'#/settings/display'));
await b.evaluate('document.querySelector("[name=order][value=contact-first]").click();location.hash="#/projects"');
await wait('document.querySelector(".display-dialog").open');
assert.equal(await b.evaluate('document.querySelector("#display-leave-status").textContent'), '');
captures.push(await capture('display-unsaved',width,'#/settings/display'));
await b.evaluate('window.__setItem=Storage.prototype.setItem;Storage.prototype.setItem=function(){throw new Error("blocked")};document.querySelector("[data-choice=save]").click()');
await wait('document.querySelector("#display-leave-status").textContent.includes("local browser storage")');
captures.push(await capture('display-save-failed',width,'#/settings/display'));
await b.evaluate('Storage.prototype.setItem=window.__setItem;document.querySelector("[data-choice=discard]").click()');
await wait('location.hash==="#/projects" && !!document.querySelector(".chat-projects")');
assert.equal(await b.evaluate('!!document.querySelector(".chat-projects img")'),false);
await b.evaluate('document.documentElement.dataset.mode="dark"; document.documentElement.style.zoom="2"');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true);
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).colorScheme'), 'dark');
}
});
test('optional project tree mount, storage refusal and sign-out clear transient choices', {timeout:90000}, async t => {
const { f,b,wait } = await w15Fixture(t);
await b.call('Page.addScriptToEvaluateOnNewDocument', {source:`window.MosaicProjectTree={mount({container,path}){container.textContent='Stub tree: '+path}};`});
await b.navigate(f.base+'/#/settings/display');
await wait('!!document.querySelector("#display-form")');
assert.match(await b.evaluate('document.querySelector("#project-tree").textContent'), /Stub tree: \/settings\/display/);
await b.evaluate('document.querySelector("[name=order][value=contact-first]").click(); document.dispatchEvent(new Event("mosaic-signed-out")); location.hash="#/projects"');
await wait('location.hash==="#/projects"');
assert.equal(await b.evaluate('document.querySelector(".display-dialog").open'), false);
assert.equal(await b.evaluate('localStorage.getItem("mosaic-sidebar-display-v1")'), null);
assert.deepEqual(await b.evaluate('window.__writes'), []);
});
test('owner display asset is not exposed through the limited user route', async () => {
const {userRoute} = await import('../src/serve.mjs');
assert.equal(userRoute('/display.js','GET'), false);
assert.equal(userRoute('/display.js','HEAD'), false);
});
test('shared fixture refuses unknown APIs and permits only explicit scenario writes', {timeout:90000}, async t => {
const { f,b,wait } = await w15Fixture(t, {scenario:{'POST /api/fixture/allowed':{status:202,body:{accepted:true}}}});
await b.navigate(f.base+'/#/projects'); await wait('!!document.querySelector(".chat-projects")');
const result = await b.evaluate(`(async()=>{const a=await fetch('/api/unknown'),b=await fetch('/api/fixture/unknown',{method:'POST'}),c=await fetch('/api/fixture/allowed',{method:'POST'}),d=await fetch('https://example.invalid/');return [a.status,b.status,c.status,d.status]})()`);
assert.deepEqual(result,[403,403,202,403]);
assert.deepEqual(await b.evaluate('window.__writes.map(x=>x.path)'), ['/api/fixture/unknown','/api/fixture/allowed']);
assert.ok((await b.evaluate('window.__unexpected')).includes('POST /api/fixture/unknown'));
});