pediatric-ai-scribe-v3/test/assistant-workspace-layout.test.js
Daniel f3a2309536 fix: one menu shape in both views, measured; phone menu follows the view
Measured in real Chromium (1440x900 and 390x844) rather than inferred.

Desktop
- The assistant rail sat inside the content area's 14px padding, on the
  tiled ground. So its background differed from the workspace sidebar's,
  its name card sat 6px lower and further in, and the page was 28px taller
  than the window. It is now the same flush white sidebar with the same
  insets; the chat and sources panels carry the 14px. Measured: name card
  848-892 at x=8, brand, switch and 210px width identical in both views,
  expanded and collapsed, with no page scroll.

Phone
- The menu button opens the chat history in the assistant (its drawer) and
  the workspace list elsewhere. In workspace mode the drawer lists the
  workspace links. Choosing a page, switching mode or pressing « closes it.
- The drawer no longer scrolls as a whole: only the chat list does, so the
  name card is pinned at the foot, the same place as in the workspace menu
  (790-836 at x=12 in both).
- Both menus are one plain shape: same width, text rows, grey current row,
  no "Menu" header and one close control.
- The assistant page no longer overflows the phone by 16px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS
2026-09-10 12:07:26 +02:00

555 lines
35 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const { marked } = require('marked');
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
function workspace(t) {
// Real .tab-btn elements: the rail and cards mirror these, so a harness
// without them cannot show whether the mirroring works.
const tabs = '<button class="tab-btn" data-tab="notes"><i class="fas fa-note"></i><span>Notes</span></button>' +
'<button class="tab-btn" data-tab="calculators"><i class="fas fa-calculator"></i><span>Calculators</span></button>' +
'<button class="tab-btn hidden" data-tab="admin"><i class="fas fa-lock"></i><span>Admin</span></button>' +
'<button class="tab-btn" data-tab="settings"><i class="fas fa-gear"></i><span>Settings</span></button>';
const dom = new JSDOM(tabs + '<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
const window = dom.window;
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
window.marked = marked;
window.DOMPurify = require('dompurify')(window);
window.matchMedia = () => ({ matches: true });
const fetched = [];
const activated = [];
const events = [];
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
setTimeout() {}, clearTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]],
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
fetchAssistantStatus: async () => ({ success: true }),
fetchAssistantExamples: async () => ({ success: true, examples: [] }),
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
saveAssistantChat: async () => ({ success: true }),
fetch: async url => { fetched.push(url); return new Response(read('public/components/learning.html')); } };
vm.createContext(context);
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
}
context.bindEvents();
window.activateTab = tab => activated.push(tab);
window.document.addEventListener('tabChanged', e => events.push(e.detail.tab));
t.after(() => window.close());
return { context, document: window.document, window, fetched, activated, events };
}
test('assistant area is an OWUI-style three-column workspace with a slim go-back top bar', t => {
const app = workspace(t);
const layout = app.document.querySelector('.assistant-layout');
assert.ok(layout);
const columns = [...layout.children].map(el => el.className.split(' ')[0]);
assert.ok(columns.includes('assistant-history'), 'left history rail');
assert.ok(columns.includes('assistant-main'), 'center chat column');
assert.ok(columns.includes('assistant-side'), 'right image/sources column');
const left = app.document.querySelector('.assistant-history');
assert.ok(left.querySelector('#assistant-saved-chats'), 'saved chats live in the left rail');
assert.equal(left.querySelector('#btn-assistant-clear') !== null, true, 'New chat sits at the top of the rail');
assert.equal(left.firstElementChild.id, 'btn-assistant-drawer-close', 'the drawer leads with its close control');
const right = app.document.querySelector('.assistant-side');
assert.equal(right.querySelector('#assistant-visual-output'), null, 'no image display in the right column — sources only');
assert.ok(right.querySelector('#assistant-sources'), 'sources panel in the right column');
assert.equal(right.querySelector('#assistant-saved-chats'), null, 'saved chats moved out of the right column');
// No bar above the layout at all: the app page has only the blue header, so a
// second bar here made the assistant taller and left dead space on top.
assert.equal(app.document.querySelector('.assistant-topbar'), null, 'no assistant-only top bar');
// Go back is gone: the Assistant/Workspace switch is how you leave, so a
// second exit control in the topbar was one affordance too many.
assert.equal(app.document.querySelector('#btn-assistant-goback'), null, 'no Go back in the topbar');
assert.ok(app.document.querySelector('[data-assistant-mode="workspace"]'), 'the mode switch replaces it');
assert.equal(app.document.querySelector('.assistant-history #btn-assistant-create-image'), null,
'Create image moved into the + menu with the other conversation actions');
// The top bar is gone entirely: everything that acts on the conversation moved
// into the composer's + menu, so both views start at the same vertical
// position and switching cannot nudge the page up or down.
assert.equal(app.document.querySelector('.assistant-toolbar'), null, 'no top bar above the transcript');
const menu = app.document.querySelector('#assistant-plus-menu');
assert.ok(menu, 'the + menu exists');
const items = [...menu.querySelectorAll('button')].map(b => b.id);
assert.deepEqual(items, ['btn-assistant-create-image', 'btn-assistant-takehome', 'btn-assistant-export-pdf', 'btn-assistant-download-chat'],
'every action on the conversation lives in the + menu');
assert.ok(menu.querySelector('label[for="assistant-attach-input"]'), 'and attaching images with them');
assert.equal(menu.hidden, true, 'closed until asked for');
const examples = app.document.querySelectorAll('.assistant-empty .assistant-examples button, [data-assistant-example]');
assert.ok(examples.length >= 3, 'the generated example questions stay on the empty chat screen');
});
test('opening the assistant replaces the main menu with the saved chats', async t => {
const app = workspace(t);
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
assert.ok(app.document.body.classList.contains('assistant-workspace'), 'fullscreen workspace class applied');
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'encounter' } }));
assert.ok(!app.document.body.classList.contains('assistant-workspace'), 'leaving the assistant restores the app chrome');
});
test('Learning Hub is a top-level main-menu entry, not part of the assistant workspace', () => {
const indexHtml = read('public/index.html');
assert.match(indexHtml, /data-tab="learning">[\s\S]*?<span>Learning Hub<\/span>/, 'Learning Hub button in the sidebar');
assert.match(indexHtml, /<section id="learning-tab" class="tab-content" data-component="learning"><\/section>/, 'learning tab section exists');
assert.match(indexHtml, /<section id="assistant-tab" class="tab-content" data-component="assistant"><\/section>/, 'assistant tab is a clean standalone section');
});
test('the assistant tab keeps its clean structure next to the restored learning tab', () => {
const indexHtml = read('public/index.html');
assert.match(indexHtml, /<script src="\/vendor\/katex\/katex\.min\.js" defer><\/script>[\s\S]*<script src="\/vendor\/katex\/contrib\/mhchem\.min\.js" defer><\/script>/, 'mhchem loads right after katex');
assert.match(indexHtml, /<section id="assistant-tab" class="tab-content" data-component="assistant"><\/section>/);
assert.match(indexHtml, /<section id="learning-tab" class="tab-content" data-component="learning"><\/section>/);
});
test('one menu toggle serves both the app sidebar and the assistant rail', () => {
const index = read('public/index.html');
const rail = read('public/components/assistant.html');
for (const [markup, where] of [[index, 'app sidebar'], [rail, 'assistant rail']]) {
assert.match(markup, /data-menu-toggle/, where + ' has the toggle');
assert.match(markup, /title="Hide menu"/, where + ' names it Hide menu');
}
// The app used to carry a pin button AND a floating expand button, with a
// third control inside the assistant — three affordances for one idea.
assert.doesNotMatch(index, /btn-sidebar-pin|btn-sidebar-expand/, 'the old pin/expand pair is gone');
assert.doesNotMatch(rail, /btn-assistant-toggle-history/, 'and the assistant-only toggle with it');
const app = read('public/js/app.js');
assert.match(app, /classList\.toggle\('menu-hidden'\)/, 'one class drives both');
assert.match(app, /'Show menu' : 'Hide menu'/, 'the label flips so the collapsed state is escapable');
// Shared chrome lives in styles.css: the app page never loads assistant.css.
const css = read('public/css/styles.css');
assert.match(css, /body\.menu-hidden \.sidebar \{ width:52px; \}/);
// The toggle lives inside the box it collapses, so it must leave that box or
// there is no way to bring the menu back.
// The toggle is a head-row icon button, so it needs no position or margin of
// its own — styling it twice gave it a second size that fought the row.
assert.match(css, /\.menu-icon-btn \{[^}]*width:28px; height:28px/, 'one styling source');
assert.doesNotMatch(css, /^\.assistant-menu-toggle \{ display:flex/m, 'no duplicate box styling');
assert.match(css, /body\.menu-hidden \.sidebar \{ width:52px; \}/,
'and collapsed keeps it in the rail rather than floating over content');
// The collapsed rail keeps its icons; only the labelled lists go.
assert.match(css, /body\.menu-hidden \.sidebar-tabs,/, 'the tab list collapses');
assert.match(css, /body\.menu-hidden \.menu-brand h1,/, 'and the wordmark, leaving the mark');
assert.match(read('public/js/app.js'), /'Show menu' : 'Hide menu'/, 'and it says how to get back');
});
test('mobile drawer rows carry no chat icons and keep the options menu', () => {
const css = read('public/css/assistant.css');
assert.ok(/\.assistant-layout \{ display:flex; flex-direction:column; height:calc\(100dvh - var\(--mobile-bar, 0px\)\);/.test(css), 'mobile layout locks the viewport, below the menu row');
assert.ok(/\.assistant-side \{ display:none; \}/.test(css), 'no sources column under the chat on mobile');
});
test('collapsing the rail does not hand the chat column to the citations panel', () => {
const fs = require('node:fs');
const path = require('node:path');
const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/assistant.css'), 'utf8');
const collapsed = css.split('\n').find(l => l.startsWith('.assistant-layout.history-collapsed .assistant-history'));
assert.ok(collapsed, 'the collapsed rail rule exists');
// display:none removes the rail from grid flow, so .assistant-main inherits the
// 0 column and #assistant-sources takes the 1fr — the chat vanishes and the
// citations render in its place.
assert.doesNotMatch(collapsed, /display:\s*none/, 'the rail must stay a grid item');
assert.match(collapsed, /width:0/);
assert.match(collapsed, /visibility:hidden/);
const track = css.split('\n').find(l => l.startsWith('.assistant-layout.history-collapsed {'));
assert.match(track, /grid-template-columns:0 minmax\(0,1fr\) 330px/, 'three tracks for three items');
});
test('the assistant and the app start at the same top edge', () => {
const rail = read('public/components/assistant.html');
const css = read('public/css/assistant.css');
// The assistant used to carry its own bar on top of the shared blue header,
// so switching modes changed the height of everything below it.
assert.doesNotMatch(rail, /assistant-topbar/, 'no second bar in the markup');
assert.doesNotMatch(css, /assistant-topbar/, 'and none left in the stylesheet');
assert.match(css, /\.assistant-history \{ display:grid; gap:10px; align-content:start; position:sticky; top:0; \}/,
'the rail sticks to the top of the layout, not below a bar');
// The status moved into the composer, next to the work it describes.
assert.match(rail, /assistant-composer-footer[\s\S]*?id="assistant-status"/,
'the ready/busy indicator lives in the composer now');
});
test('the menu toggle uses an icon that reads as a menu', () => {
for (const file of ['public/index.html', 'public/components/assistant.html']) {
const markup = read(file);
const button = markup.split('\n').find(l => l.includes('data-menu-toggle'));
assert.ok(button, file + ' has the toggle');
// fa-table-columns draws a split-pane glyph that says nothing about what the
// click does. Angles point the way the panel moves.
assert.doesNotMatch(button, /fa-table-columns/, file + ' avoids the split-pane glyph');
assert.match(button, /fa-angles-left/, file);
}
assert.match(read('public/css/assistant.css'), /\.assistant-menu-toggle\.is-collapsed i \{ transform:rotate\(180deg\); \}/,
'and it turns to show the direction it will act in');
// Collapsed, the toggle is the only control left, so it must stay reachable.
assert.match(read('public/css/styles.css'), /body\.menu-hidden \.sidebar \{ width:52px; \}/,
'collapsed narrows to an icon rail, so the toggle stays reachable in place');
});
test('workspace mode does not override the mobile layout with a desktop header offset', () => {
const fs = require('node:fs');
const path = require('node:path');
const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/assistant.css'), 'utf8');
// body.assistant-workspace .assistant-layout is both later in the file and more
// specific than the .assistant-layout rule inside @media (max-width:640px), so
// the mobile rule needs a matching-specificity override to win at all.
// Both now subtract the app header, which the assistant keeps rather than hides.
const override = css.indexOf('body.assistant-workspace .assistant-layout { height:calc(100dvh - var(--mobile-bar, 0px));');
const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: calc(100vh - var(--mobile-bar, 0px))');
assert.ok(desktop > 0, 'the desktop workspace rule exists');
assert.ok(override > desktop, 'the mobile override comes after it, so it wins');
const rule = css.slice(override, css.indexOf('}', override));
assert.match(rule, /display:flex/, 'the mobile flex layout is restored');
assert.match(rule, /grid-template-rows:none/, 'and the grid rows do not linger on a flex box');
});
test('Go back is gone: Workspace is how you leave the assistant', () => {
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const html = fs.readFileSync(path.join(root, 'public/components/assistant.html'), 'utf8');
assert.doesNotMatch(html, /btn-assistant-goback/, 'no redundant Go back button');
const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8');
assert.doesNotMatch(js, /goBackToMainMenu/, 'and no dead handler left behind');
});
test('Learning Hub sits with the content tools, not above the clinical ones', () => {
const fs = require('node:fs');
const path = require('node:path');
const index = fs.readFileSync(path.join(__dirname, '..', 'public/index.html'), 'utf8');
const order = [...index.matchAll(/data-tab="([a-z]+)"/g)].map(m => m[1]);
assert.ok(order.indexOf('learning') > order.indexOf('encounter'), 'moved down past the clinical tabs');
assert.equal(order[order.indexOf('learning') + 1], 'cms', 'and sits immediately before Content Manager');
});
test('the Assistant/Workspace switch is the same control in both places', () => {
const root = path.join(__dirname, '..');
const index = read('public/index.html');
const rail = read('public/components/assistant.html');
// The app IS workspace mode. The switch appears in the app sidebar with
// Workspace highlighted and in the assistant rail with Assistant highlighted
// — the only difference between the two screens.
for (const [markup, where] of [[index, 'app sidebar'], [rail, 'assistant rail']]) {
assert.match(markup, /class="assistant-mode-switch"/, where + ' has the switch');
assert.match(markup, /data-assistant-mode="assistant"/, where);
assert.match(markup, /data-assistant-mode="workspace"/, where);
}
const appActive = index.match(/class="active"[^>]*data-assistant-mode="(\w+)"/);
assert.equal(appActive && appActive[1], 'workspace', 'the app highlights Workspace');
const railActive = rail.match(/class="active"[^>]*data-assistant-mode="(\w+)"/);
assert.equal(railActive && railActive[1], 'assistant', 'the assistant highlights Assistant');
assert.doesNotMatch(index, /data-tab="assistant"/, 'the old AI Assistant nav entry is gone');
});
test('the switch navigates from the app and opens the launcher inside the assistant', () => {
const app = read('public/js/app.js');
const handler = app.slice(app.indexOf('// Assistant / Workspace switch'), app.indexOf('window.activateTab = activateTab;'));
// In the app you ARE the workspace, so only the Assistant pill goes anywhere.
assert.match(handler, /if \(wantsAssistant\) window\.location\.href = '\/assistant';/);
assert.match(handler, /assistantShowWorkspaceLauncher\(!wantsAssistant\)/, 'in the assistant it swaps the view in place');
// The in-page mode machinery is gone, and with it the whole class of
// hidden-vs-display bugs that made the chat vanish and never come back.
const assistant = read('public/js/clinicalAssistant.js');
for (const dead of ['setAssistantMode', 'renderWorkspaceLinks', 'goBackToMainMenu']) {
assert.ok(!assistant.includes(dead), dead + ' is gone');
}
// Cards are clinical work surfaces only.
assert.match(assistant, /NON_WORK_TABS = \['settings', 'admin', 'docs', 'faq', 'cms'\]/,
'no account or content-management tabs among the cards');
const css = read('public/css/assistant.css');
assert.match(css, /body\.assistant-mode-workspace #assistant-chat-view \{ display:none; \}/,
'the launcher replaces the chat rather than stacking with it');
});
test('the assistant uses the app accent, so neither screen looks like a different product', () => {
const styles = read('public/css/styles.css');
assert.match(styles, /\.tab-btn\.active\{background:var\(--blue-light\);color:var\(--blue\)/,
'the app accent is blue');
assert.match(read('public/css/styles.css'), /\.assistant-mode-switch button\.active \{ background:white; color:var\(--blue\)/,
'the switch highlights in the app accent');
const css = read('public/css/assistant.css');
assert.match(css, /\.assistant-msg\.user \.assistant-bubble \{ white-space:pre-wrap; background:var\(--blue\)/);
// Citations keep their own purple chip so evidence stays distinct from
// interactive blue; nothing else may reintroduce a second accent.
const stray = css.split('\n').filter(l => l.includes('var(--purple') && !l.includes('assistant-cite'));
assert.deepEqual(stray, [], 'no second accent outside citation chips');
});
test('chrome rendered on the main app page is styled by the stylesheet that page loads', () => {
const index = read('public/index.html');
const styles = read('public/css/styles.css');
const assistantCss = read('public/css/assistant.css');
// assistant.css is pulled in by the assistant COMPONENT, so it is absent on
// "/". Anything the app page renders must be styled from styles.css or it
// shows up as an unstyled native control.
assert.doesNotMatch(index, /css\/assistant\.css/, 'the app page does not load assistant.css');
for (const cls of ['assistant-mode-switch', 'assistant-menu-toggle']) {
assert.match(index, new RegExp('class="[^"]*' + cls), cls + ' is rendered on the app page');
assert.match(styles, new RegExp('\\.' + cls + '[\\s{]'), cls + ' must be defined in styles.css');
// Defined in both files, the two copies drift and one silently wins.
assert.ok(!assistantCss.includes('.' + cls + ' {'), cls + ' must not be duplicated in assistant.css');
}
assert.match(styles, /body\.menu-hidden \.sidebar \{ width:52px; \}/, 'the collapse state is styled where the app can see it');
});
test('the model selector sits with send, and only when there is a choice', () => {
const html = read('public/components/assistant.html');
// ChatGPT/OWUI placement: the model belongs beside the send control in the
// composer, not in a bar above the transcript.
const right = html.slice(html.indexOf('assistant-composer-right'), html.indexOf('</form>'));
for (const id of ['assistant-model-pill', 'btn-assistant-send']) {
assert.ok(right.includes(id), id + ' is on the composer right');
}
assert.match(html, /id="assistant-model-pill"[^>]*hidden/, 'hidden until there is something to pick');
const js = read('public/js/clinicalAssistant.js');
assert.match(js, /One model means no choice: hide the selector entirely/);
assert.match(js, /var show = Array\.isArray\(allowed\) && allowed\.length > 1;/);
assert.match(js, /if \(pill\) pill\.hidden = !show;/, 'the pill follows the select');
assert.doesNotMatch(js, /assistant-model-label/, 'the old always-on model label is gone');
});
test('nothing sits above the transcript, so switching cannot shift the page', () => {
const css = read('public/css/assistant.css');
// Both views fill the same box from the same top edge. With a toolbar above
// one of them, switching moved everything down by its height.
assert.match(css, /#assistant-chat-view \{ display:grid; grid-template-rows:minmax\(0,1fr\) auto;/,
'chat is transcript + composer, nothing above');
assert.doesNotMatch(css, /^\.assistant-toolbar/m, 'no toolbar styling left');
assert.match(css, /\.assistant-workspace-view \{ flex:1 1 auto; min-height:0;/,
'the launcher fills the same box');
const html = read('public/components/assistant.html');
assert.doesNotMatch(html, /assistant-toolbar/, 'and no toolbar markup');
});
test('both modes share one surface, so switching changes only the content', () => {
const css = read('public/css/assistant.css');
// The tiled ground belongs to the panel itself, not to one view's empty state,
// so the chat and the workspace launcher sit on the same background.
assert.match(css, /\.assistant-main\.card \{\n\s*background-image:linear-gradient/,
'the tiles belong to the shared panel');
assert.doesNotMatch(css, /#assistant-chat-view:has[^\n]*\{\n\s*background-image/,
'not to one view only');
// The composer floats with room around it rather than pinned to the edge.
assert.match(css, /\.assistant-composer \{ margin:0 auto 20px; max-width:760px; width:calc\(100% - 40px\)/);
assert.match(css, /\.assistant-messages:not\(:has\(\.assistant-msg\)\) \{ display:flex; flex-direction:column; justify-content:center;/,
'an empty transcript centres rather than pinning the composer to the top');
});
test('one menu width, so the content does not shift sideways on switch', () => {
const styles = read('public/css/styles.css');
const css = read('public/css/assistant.css');
const sidebar = styles.split('\n').find(l => l.startsWith('.sidebar{'));
assert.match(sidebar, /width:210px/, 'the app sidebar is 210px');
// The assistant rail was 260px, so every switch moved the chat sideways.
assert.match(css, /grid-template-columns:210px minmax\(0,1fr\) 330px/, 'the rail matches it');
assert.doesNotMatch(css, /grid-template-columns:260px/, 'no 260px rail left');
});
test('Create image sits with the other conversation actions', () => {
const html = read('public/components/assistant.html');
const menu = html.slice(html.indexOf('assistant-plus-menu'), html.indexOf('</form>'));
// It acts on the conversation like take home and export, so it belongs in the
// + menu rather than as its own button on the rail.
assert.match(menu, /btn-assistant-create-image/, 'Create image is in the + menu');
const rail = html.slice(0, html.indexOf('assistant-main'));
assert.doesNotMatch(rail, /btn-assistant-create-image/, 'and not on the rail');
assert.doesNotMatch(read('public/css/assistant.css'), /^\.assistant-create-image \{/m, 'its button styling is gone');
});
test('one menu, ending the same way in both views', () => {
const index = read('public/index.html');
const rail = read('public/components/assistant.html');
// The view decides what the menu LISTS; everything structural about the menu
// itself is identical, so switching never redraws the frame.
for (const [markup, where] of [[index, 'app sidebar'], [rail, 'assistant rail']]) {
assert.match(markup, /class="account-card"/, where + ' ends with the account card');
assert.match(markup, /data-account-tab="settings"/, where + ' offers Settings');
assert.match(markup, /data-account-logout/, where + ' offers Log out');
assert.match(markup, /data-menu-toggle/, where + ' has the menu toggle');
assert.match(markup, /assistant-mode-switch/, where + ' has the view switch');
}
const app = read('public/js/app.js');
assert.match(app, /if \(user\.role === 'admin'/, 'Admin is offered only to admins');
assert.match(app, /logoutBtn = document\.getElementById\('btn-logout'\)/,
'log out reuses the existing flow rather than a second implementation');
// Reaching an app tab from the assistant has to leave the assistant.
assert.match(app, /if \(tab && window\.location\.pathname === '\/assistant'\)/);
const css = read('public/css/styles.css');
assert.match(css, /body\.assistant-preview \.account-card \{ display:none; \}/,
'a preview visitor has no account to show');
});
test('there is no header bar; the brand heads the menu instead', () => {
const index = read('public/index.html');
const css = read('public/css/styles.css');
// Settings and Log out moved to the account card, so a whole band of chrome
// had nothing left to hold and became content.
assert.doesNotMatch(index, /<header class="app-header">/, 'no header element');
assert.doesNotMatch(css, /^\.app-header\{/m, 'and no header styling');
for (const [markup, where] of [[index, 'app menu'], [read('public/components/assistant.html'), 'assistant rail']]) {
assert.match(markup, /class="menu-brand"/, where + ' carries the brand');
}
// The canonical handlers stay so nothing that calls them breaks.
assert.match(index, /id="btn-logout" hidden/, 'logout handler retained, hidden');
assert.match(index, /id="btn-settings" hidden/);
// Nothing sits above the content, so no layout should still subtract a header.
for (const stale of [/calc\(100vh - 66px\)/, /calc\(100vh - 52px\)/, /calc\(100dvh - \d+px\)/]) {
assert.doesNotMatch(css, stale, 'no stale header offset in styles.css');
assert.doesNotMatch(read('public/css/assistant.css'), stale, 'none in assistant.css');
}
// The only offset left is the phone's menu row, a variable that is 0px on desktop.
assert.match(read('public/css/assistant.css'), /body\.assistant-workspace \.assistant-layout \{ height: calc\(100vh - var\(--mobile-bar, 0px\)\);/,
'the layout owns the full viewport');
assert.match(css, /:root \{ --mobile-bar:0px; \}/, 'which is zero outside the phone breakpoint');
});
test('every view sits in the same shell, so opening a menu item is not a new app', () => {
const css = read('public/css/styles.css');
// Each tab used to be a plain white page while the assistant and the workspace
// launcher sat on a tiled card, so any menu click changed the whole face.
assert.match(css, /\.main-content\{[^}]*background-image:linear-gradient/,
'the tiled ground belongs to the shell');
assert.match(css, /\.main-content > \.tab-content\.active\{[^}]*border:1px solid var\(--g200\);border-radius:14px/,
'and every tab gets the same card edge');
// The assistant brings a full-bleed layout, so it replaces the shell card
// rather than nesting a second one inside it.
assert.match(css, /\.main-content > #assistant-tab\.active\{padding:0;border:none/);
assert.match(read('public/css/assistant.css'), /\.assistant-main\.card \{[\s\S]{0,220}?border:1px solid var\(--g200\); border-radius:14px/,
'the assistant panel carries the matching edge itself');
});
test('the new chrome is handled on phones', () => {
const css = read('public/css/styles.css');
const mobile = css.slice(css.indexOf('@media(max-width:768px)'));
// The sidebar slides in whole on mobile, so the collapse control closes it
// instead, and replaces the old "Menu" + X header as the one close control.
assert.doesNotMatch(mobile, /\.assistant-menu-toggle\{display:none/);
assert.match(mobile, /\.sidebar-header\{ display:none; \}/);
assert.match(mobile, /\.account-card\{position:sticky;bottom:0/, 'the account card stays reachable');
assert.match(mobile, /\.menu-search-panel\{width:100%/, 'the palette is full-bleed');
assert.match(mobile, /\.sidebar-nav\{flex:1 1 auto;min-height:0;\}/, 'the menu scrolls, the card does not float away');
});
test('the rail scrolls in both modes, not only after leaving for the app', () => {
const css = read('public/css/assistant.css');
// The app sidebar has always scrolled. The assistant rail had no overflow
// rule, so its list of every app tab ran off the bottom — and scrolling only
// appeared once you left for the app.
assert.match(css, /\.assistant-rail-workspace \{[^}]*flex:1 1 auto; min-height:0; overflow-y:auto/,
'the workspace list scrolls');
assert.match(css, /#assistant-saved-chats \{ flex:1 1 auto; min-height:0; overflow-y:auto/,
'and so does the saved-chat list');
// The rail itself is the flex column that makes those two scroll.
assert.match(css, /\.assistant-history \{ display:flex; flex-direction:column; height:100%; min-height:0; padding-bottom:8px; \}/,
'the rail is the flex column that makes those scroll, with the same bottom gap as the app');
});
test('the menu can always be reopened after it is hidden', () => {
const html = read('public/index.html');
const css = read('public/css/styles.css');
// This has broken twice by moving the toggle deeper and leaving a hide rule
// pointed at its old depth. So walk the real ancestor chain and prove no rule
// hides anything between the toggle and the sidebar root.
const toggleAt = html.indexOf('data-menu-toggle');
assert.ok(toggleAt > 0, 'the toggle exists');
const before = html.slice(0, toggleAt);
const ancestors = [];
for (const m of before.matchAll(/<(?:div|nav)\s+class="([^"]+)"/g)) ancestors.push(m[1].split(/\s+/));
// Innermost ancestors that are still open at the toggle.
const chain = ancestors.slice(-3).flat();
assert.ok(chain.includes('menu-head'), 'the toggle sits in the menu head');
assert.match(css, /body\.menu-hidden \.sidebar \{ width:52px; \}/, 'collapsed is a rail, not a disappearance');
const hideRules = css.split('\n').filter(l => l.startsWith('body.menu-hidden') && l.includes('display:none'));
for (const rule of hideRules) {
const selector = rule.slice(0, rule.indexOf('{')).trim();
// A rule that hides a class on the toggle's own ancestor chain must exempt
// the branch the toggle is on.
for (const cls of ['menu-head', 'sidebar-nav']) {
if (new RegExp('\\.' + cls + '\\s*\\{|\\.' + cls + '\\s*,|\\.' + cls + '$').test(selector)) {
assert.fail('rule hides the toggle\'s ancestor .' + cls + ': ' + selector);
}
}
}
// And the exemptions that keep it reachable are actually present.
// Everything that keeps the rail usable while collapsed stays visible.
for (const kept of ['.menu-head', '.menu-brand', '.account-card']) {
assert.ok(!hideRules.some(r => r.slice(0, r.indexOf('{')).trim().endsWith(kept)),
kept + ' stays visible in the collapsed rail');
}
});
test('search reaches the things inside a tab, not just the tab', () => {
const app = read('public/js/app.js');
// People look for "bili", not "Calculators". Sub-navigation is read
// generically from whatever a loaded component exposes, so this covers every
// tab with sub-navigation rather than one hard-coded list.
assert.match(app, /SUB_NAV_SELECTOR = '\[data-calc\], \[data-subtab\], \[data-section\], \.calc-nav-pill'/);
assert.match(app, /function subItemsFor\(/);
assert.match(app, /if \(!tabEl \|\| !tabEl\.dataset\.loaded\) return \[\];/,
'an unloaded component exposes nothing, so it is skipped rather than guessed at');
assert.match(app, /function warmSearchableTabs\(/, 'and those components are warmed when the palette opens');
// Opening one has to survive the component still loading.
assert.match(app, /function openSubItem\(sub, tabName, attempts\)/);
assert.match(app, /if \(attempts > 0\) setTimeout/, 'it retries rather than clicking into an empty tab');
// Reached from the assistant, it has to navigate first and open after.
assert.match(app, /localStorage\.setItem\('ped_pending_sub', sub\)/);
const calculators = read('public/components/calculators.html');
assert.match(calculators, /data-calc="bili"/, 'the labels search reads are real');
});
test('the menu head reads toggle, mark, search — and account tabs are not listed twice', () => {
for (const file of ['public/index.html', 'public/components/assistant.html']) {
const markup = read(file);
const head = markup.slice(markup.indexOf('<div class="menu-head">'), markup.indexOf('assistant-mode-switch'));
const order = [...head.matchAll(/assistant-menu-toggle|menu-brand|data-menu-search/g)].map(m => m[0]);
assert.deepEqual(order, ['assistant-menu-toggle', 'menu-brand', 'data-menu-search'],
file + ': show/hide menu first, then the mark, then search');
}
// Settings, FAQ and Admin live in the account card; listing them in the menu
// too only made the tab list longer.
const index = read('public/index.html');
for (const tab of ['settings', 'faq', 'admin']) {
assert.doesNotMatch(index, new RegExp('class="tab-btn[^"]*" data-tab="' + tab + '"'), tab + ' is not in the menu list');
assert.match(index, new RegExp('id="' + tab + '-tab"'), tab + ' still has its section');
assert.match(index, new RegExp('data-account-tab="' + tab + '"|role="menuitem"'), tab + ' is reachable from the account card');
}
});
test('desktop: the assistant rail is the app sidebar, flush and white', () => {
const css = read('public/css/assistant.css');
const styles = read('public/css/styles.css');
// Inside the content area's 14px padding the rail sat on the tiled ground, its
// name card 6px lower and further in, and the page 28px taller than the window.
const desk = css.slice(css.lastIndexOf('@media (min-width:961px) {'));
assert.match(desk, /body\.assistant-workspace \.main-content \{ padding:0; \}/);
assert.match(desk, /body\.assistant-workspace \.assistant-history \{ padding:0; gap:0; background:white; border-right:1\.5px solid var\(--g200\); \}/);
assert.match(desk, /body\.assistant-workspace \.assistant-side \{ margin-block:14px; height:auto; \}/, 'the panels carry the 14px');
// Same insets as the sidebar: .menu-head, .assistant-mode-switch, .account-card.
const rail = styles.slice(styles.lastIndexOf('@media (min-width:961px) {'));
assert.match(rail, /\.assistant-history \.menu-head \{ padding:10px 8px 8px 12px; \}/);
assert.match(styles, /^\.menu-head \{ display:flex; align-items:center; gap:4px; padding:10px 8px 8px 12px; \}/m);
assert.match(rail, /\.assistant-history \.assistant-mode-switch \{ margin:8px 12px 10px; \}/);
assert.match(styles, /\.assistant-mode-switch \{[^}]*margin:8px 12px 10px;/);
assert.match(rail, /\.assistant-history \.account-card \{ padding:8px; \}/);
assert.match(styles, /body\.assistant-workspace\.menu-hidden \.assistant-history \{[^}]*padding:0; \}/, 'collapsed adds no gap of its own');
});