The view now decides only what the menu LISTS. Everything structural is shared, so switching redraws the content and nothing else: - One width. The app sidebar is 210px; the assistant rail was 260px, so every switch shifted the content sideways. - One surface. The tiled ground belongs to the shared panel rather than to one view's empty state, so the transcript and the workspace launcher sit on the same background. - One menu ending. An account card closes both menus with Settings, FAQ, Log out and — for admins only — Admin. These were unlabelled icon buttons in the old header; log out reuses the existing flow rather than a second implementation. The blue gradient header is now a slim white bar. Being the loudest element on screen, it made any view without it look like a different product. Everything measured against it follows from 66px to 52px. The composer has room around it and centres on an empty transcript instead of sitting at the very top, and Create image joins take home, export and attach in the + menu — it acts on the conversation, so it belongs with them rather than as its own button on the rail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
203 lines
10 KiB
JavaScript
203 lines
10 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { JSDOM, requestInterceptor, VirtualConsole } = require('jsdom');
|
|
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
|
const css = read('public/css/assistant.css');
|
|
|
|
function loaderFixture() {
|
|
let requested;
|
|
const pending = [];
|
|
const requests = [];
|
|
const dom = new JSDOM('<script src="/js/app.js?v=fixture-build"></script><button class="tab-btn" data-tab="assistant"></button><section id="assistant-tab" class="tab-content" data-component="assistant"></section><button class="tab-btn" data-tab="notes"></button><section id="notes-tab" class="tab-content"></section>', {
|
|
url: 'https://example.test/', runScripts: 'outside-only', virtualConsole: new VirtualConsole(),
|
|
resources: { interceptors: [requestInterceptor(request => {
|
|
assert.equal(request.url, 'https://example.test/css/assistant.css?v=fixture-build');
|
|
return new Promise(resolve => { pending.push(resolve); if (requested) requested(); });
|
|
})] }
|
|
});
|
|
dom.window.fetch = async url => {
|
|
requests.push(url);
|
|
if (url === '/components/assistant.html?v=fixture-build') return new Response(read('public/components/assistant.html'));
|
|
assert.equal(url, '/api/models');
|
|
return new Response(JSON.stringify({ models: [] }));
|
|
};
|
|
dom.window.eval(read('public/js/app.js'));
|
|
return {
|
|
dom, requests,
|
|
async stylesheetRequested() { if (!pending.length) await new Promise(resolve => { requested = resolve; }); requested = null; },
|
|
release(status = 200) { pending.shift()(new Response(css, { status, headers: { 'Content-Type': 'text/css' } })); }
|
|
};
|
|
}
|
|
|
|
function nextTab(dom) {
|
|
return new Promise(resolve => dom.window.document.addEventListener('tabChanged', resolve, { once: true }));
|
|
}
|
|
|
|
test('actual component loader waits for versioned CSS before first initialization and cached reopen', async () => {
|
|
const fixture = loaderFixture();
|
|
const { dom } = fixture;
|
|
const tab = dom.window.document.getElementById('assistant-tab');
|
|
let events = 0;
|
|
dom.window.document.addEventListener('tabChanged', () => { events++; });
|
|
await fixture.stylesheetRequested();
|
|
dom.window.activateTab('assistant');
|
|
assert.equal(events, 0);
|
|
assert.equal(tab.dataset.loaded, undefined);
|
|
assert.equal(tab.lastElementChild.tagName, 'LINK', 'Stylesheet stays at the previous style block position');
|
|
assert.equal(tab.querySelector('style'), null);
|
|
assert.equal(tab.getAttribute('aria-busy'), 'true');
|
|
assert.match(tab.querySelector('[role="status"]').textContent, /Loading/);
|
|
assert.ok(tab.querySelector('#assistant-input').closest('[inert]'), 'Native inert prevents entering a draft before handlers exist');
|
|
const ready = nextTab(dom);
|
|
fixture.release();
|
|
await ready;
|
|
assert.equal(tab.dataset.loaded, '1');
|
|
assert.equal(tab.querySelector('#assistant-input').closest('[inert]'), null);
|
|
assert.equal(tab.hasAttribute('aria-busy'), false);
|
|
assert.equal(dom.window.getComputedStyle(tab.querySelector('.assistant-messages')).overflowY, 'auto',
|
|
'the component stylesheet applied');
|
|
const bubble = dom.window.document.createElement('div');
|
|
bubble.className = 'assistant-msg user';
|
|
bubble.innerHTML = '<div class="assistant-bubble"> preserve\nline</div>';
|
|
tab.appendChild(bubble);
|
|
assert.equal(dom.window.getComputedStyle(bubble.firstChild).whiteSpace, 'pre-wrap');
|
|
const unchangedLink = tab.querySelector('link');
|
|
const reactivated = nextTab(dom);
|
|
dom.window.activateTab('assistant');
|
|
await reactivated;
|
|
assert.equal(tab.querySelector('link'), unchangedLink, 'Normal reopen keeps the loaded CSS');
|
|
|
|
// Exercise the loader's HTML-cache path too, not only the already-loaded fast path.
|
|
tab.replaceChildren();
|
|
delete tab.dataset.loaded;
|
|
dom.window.activateTab('assistant');
|
|
await fixture.stylesheetRequested();
|
|
assert.equal(events, 2);
|
|
assert.equal(tab.dataset.loaded, undefined);
|
|
const reopened = nextTab(dom);
|
|
fixture.release();
|
|
await reopened;
|
|
assert.equal(tab.dataset.loaded, '1');
|
|
assert.equal(dom.window.getComputedStyle(tab.querySelector('.assistant-messages')).overflowY, 'auto',
|
|
'the component stylesheet applied');
|
|
assert.equal(fixture.requests.filter(url => url.includes('/components/')).length, 1);
|
|
assert.ok(css.indexOf('@media (max-width: 960px)') < css.indexOf('@media (max-width: 640px)'));
|
|
dom.window.close();
|
|
});
|
|
|
|
test('stylesheet failure never marks an unstyled assistant ready; reactivation retries', async () => {
|
|
const fixture = loaderFixture();
|
|
const { dom } = fixture;
|
|
const tab = dom.window.document.getElementById('assistant-tab');
|
|
await fixture.stylesheetRequested();
|
|
dom.window.activateTab('assistant');
|
|
assert.ok(tab.querySelector('#assistant-input').closest('[inert]'));
|
|
let readyEvents = 0;
|
|
dom.window.document.addEventListener('tabChanged', () => { readyEvents++; });
|
|
const failed = new Promise(resolve => {
|
|
const observer = new dom.window.MutationObserver(() => {
|
|
if (tab.querySelector('[role="alert"]')) { observer.disconnect(); resolve(); }
|
|
});
|
|
observer.observe(tab, { childList: true });
|
|
});
|
|
fixture.release(503);
|
|
await failed;
|
|
assert.equal(readyEvents, 0, 'Failure must not initialize an absent component');
|
|
assert.equal(tab.hasAttribute('aria-busy'), false);
|
|
assert.equal(tab.dataset.loaded, undefined);
|
|
assert.equal(tab.querySelector('#assistant-form'), null);
|
|
assert.match(tab.textContent, /Failed to load/);
|
|
dom.window.activateTab('assistant');
|
|
await fixture.stylesheetRequested();
|
|
const recovered = nextTab(dom);
|
|
fixture.release();
|
|
await recovered;
|
|
assert.equal(tab.dataset.loaded, '1');
|
|
assert.ok(tab.querySelector('#assistant-form'));
|
|
assert.equal(tab.querySelector('#assistant-input').closest('[inert]'), null);
|
|
dom.window.close();
|
|
});
|
|
|
|
test('out-of-order CSS completion does not initialize an inactive tab; returning binds before enabling', async () => {
|
|
const fixture = loaderFixture();
|
|
const { dom } = fixture;
|
|
const tab = dom.window.document.getElementById('assistant-tab');
|
|
const events = [];
|
|
dom.window.document.addEventListener('tabChanged', event => {
|
|
events.push(event.detail.tab);
|
|
if (event.detail.tab === 'assistant') {
|
|
assert.ok(tab.querySelector('#assistant-input').closest('[inert]'), 'Controls remain inert while initialization listeners run');
|
|
}
|
|
});
|
|
await fixture.stylesheetRequested();
|
|
dom.window.activateTab('assistant');
|
|
const notesReady = nextTab(dom);
|
|
dom.window.activateTab('notes');
|
|
await notesReady;
|
|
const cssReady = new Promise(resolve => tab.querySelector('link').addEventListener('load', resolve, { once: true }));
|
|
fixture.release();
|
|
await cssReady;
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.deepEqual(events, ['notes']);
|
|
assert.equal(tab.dataset.loaded, '1');
|
|
assert.equal(tab.classList.contains('active'), false);
|
|
assert.ok(tab.querySelector('#assistant-input').closest('[inert]'));
|
|
const assistantReady = nextTab(dom);
|
|
dom.window.activateTab('assistant');
|
|
await assistantReady;
|
|
assert.deepEqual(events, ['notes', 'assistant']);
|
|
assert.equal(tab.querySelector('#assistant-input').closest('[inert]'), null);
|
|
assert.equal(tab.querySelector('[role="status"]'), null);
|
|
dom.window.close();
|
|
});
|
|
|
|
test('the empty state is composed, not a stack of competing blocks', () => {
|
|
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.match(html, /assistant-empty-badge/, 'one status pill under the title');
|
|
assert.doesNotMatch(html, /<i class="fas fa-book-medical"><\/i>\s*<h3>/, 'no oversized icon above the title');
|
|
|
|
const css = fs.readFileSync(path.join(root, 'public/css/assistant.css'), 'utf8');
|
|
// The graph-paper ground reads as a surface only while the transcript is
|
|
// empty; once there are messages it would fight the text.
|
|
assert.match(css, /\.assistant-messages:has\(\.assistant-msg\) \{ background-image:none; \}/);
|
|
assert.match(css, /\.assistant-kbd/, 'New chat advertises its shortcut');
|
|
assert.match(css, /\.assistant-chats-header\[aria-expanded="false"\] \.assistant-chats-caret/, 'the chats group collapses');
|
|
});
|
|
|
|
test('the advertised New chat shortcut actually works, and only inside the assistant', () => {
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const js = fs.readFileSync(path.join(__dirname, '..', 'public/js/clinicalAssistant.js'), 'utf8');
|
|
const handler = js.slice(js.indexOf('// Ctrl+Shift+O starts a new chat'), js.indexOf('var closeDrawer'));
|
|
assert.match(handler, /event\.ctrlKey && event\.shiftKey|!event\.ctrlKey \|\| !event\.shiftKey/);
|
|
assert.match(handler, /toLowerCase\(\) !== 'o'/);
|
|
// Must not hijack Ctrl+Shift+O for the rest of the app.
|
|
assert.match(handler, /classList\.contains\('assistant-workspace'\)/);
|
|
assert.match(handler, /btn-assistant-clear/);
|
|
});
|
|
|
|
test('the admin panel stays usable on a phone', () => {
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const root = path.join(__dirname, '..');
|
|
const css = fs.readFileSync(path.join(root, 'public/css/styles.css'), 'utf8');
|
|
// Admin rows are a 3-column grid on desktop; on a phone they must stack or the
|
|
// label column squeezes every control into an unusable sliver.
|
|
const mobile = css.slice(css.indexOf('@media (max-width:640px) {', css.indexOf('.admin-row {')));
|
|
assert.match(mobile, /\.admin-row \{ grid-template-columns:1fr; \}/, 'rows stack');
|
|
assert.match(mobile, /\.admin-control \{ max-width:none; \}/, 'controls use the full width');
|
|
|
|
// Every block added inside a row must be able to shrink, or it forces the
|
|
// grid cell wider than the screen and the page scrolls sideways.
|
|
const html = fs.readFileSync(path.join(root, 'public/components/admin.html'), 'utf8');
|
|
const rowChildren = html.split('\n').filter(l => l.includes('style="flex:1;display:flex'));
|
|
assert.ok(rowChildren.length > 0, 'found the flex blocks inside admin rows');
|
|
for (const line of rowChildren) {
|
|
assert.match(line, /min-width:0/, 'a flex child inside an admin row must be shrinkable: ' + line.trim().slice(0, 60));
|
|
}
|
|
});
|