pediatric-ai-scribe-v3/test/assistant-workspace-layout.test.js
Daniel 2d6eaccd96 fix: collapse to an icon rail, keep the menu scrollable, and brand as PedAI
Collapsing hid the sidebar entirely, and the toggle had moved into .menu-head
while the hide rule still targeted direct children of .sidebar-nav — so the head
went, taking the toggle with it, and there was no way to reopen the menu.

Collapsed is now a 52px icon rail rather than a disappearance: the mark, the
toggle, search and the account avatar stay reachable in place, so nothing has to
be floated over the content and there is always a way back. The assistant rail
collapses to the same strip.

Scrolling. The workspace list in 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. In the app sidebar the opposite: the whole sidebar scrolled, so
the account card scrolled away with the list and Settings and Log out became
something to hunt for. Both now scroll only the list, with the head and the
account card pinned.

The toggle was also being styled twice — as a head-row icon button and by its own
older rule with a different size and margin. It is now only the behavioural hook
that flips the icon.

Brand is PedAI, which is short enough to sit in the head row without squeezing
the controls; the collapsed rail shows the mark alone.

The reachability test now walks the toggle's real ancestor chain and fails if any
collapse rule hides a branch it sits on, rather than asserting one rule's text —
this class of bug has broken twice by moving the toggle deeper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
2026-09-10 05:06:23 +02:00

493 lines
30 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:100dvh;/.test(css), 'mobile layout locks the viewport');
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:100dvh;');
const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: 100vh');
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.ok(styles.includes('.' + cls + ' {'), 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');
}
assert.match(read('public/css/assistant.css'), /body\.assistant-workspace \.assistant-layout \{ height: 100vh;/,
'the layout owns the full viewport');
});
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 an in-sidebar collapse control has
// nothing to collapse.
assert.match(mobile, /\.assistant-menu-toggle\{display:none !important;\}/);
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; \}/);
});
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');
}
});