From 91699e45cb4485b8827c782e5fe83a57b32d9ec8 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 04:45:51 +0200 Subject: [PATCH] feat: one menu for both views, a slim top bar, and an account card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe --- public/components/assistant.html | 21 ++++- public/css/assistant.css | 59 +++++++------- public/css/styles.css | 64 ++++++++++----- public/index.html | 19 +++++ public/js/app.js | 75 +++++++++++++++++ test/assistant-component-css.test.js | 6 +- test/assistant-workspace-layout.test.js | 102 +++++++++++++++++------- test/clinical-conversation.test.js | 2 +- 8 files changed, 268 insertions(+), 80 deletions(-) diff --git a/public/components/assistant.html b/public/components/assistant.html index 9ffdcb21..b69e6e93 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -11,7 +11,6 @@
-
@@ -27,6 +26,25 @@

Chats save automatically as you go.

+ +
@@ -62,6 +80,7 @@ + + diff --git a/public/js/app.js b/public/js/app.js index 0a81f392..8d3de73c 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -189,6 +189,81 @@ document.addEventListener('DOMContentLoaded', function() { } }); + // Account card. Rendered in both menus from the same markup, so whichever view + // you are in ends the same way. Settings and Log out used to be unlabelled + // icon buttons in the header, which the slim bar no longer explains. + function initials(name, email) { + var source = String(name || email || '').trim(); + var parts = source.split(/[\s@._-]+/).filter(Boolean); + return ((parts[0] || '?')[0] + (parts.length > 1 ? parts[1][0] : '')).toUpperCase(); + } + + function renderAccountCards() { + var user = window.CURRENT_USER; + if (!user) return; + var name = user.name || user.email || ''; + document.querySelectorAll('.account-card').forEach(function(card) { + var avatar = card.querySelector('[id^="account-avatar"]'); + var nameEl = card.querySelector('[id^="account-name"]'); + var mailEl = card.querySelector('[id^="account-email"]'); + if (avatar) avatar.textContent = initials(user.name, user.email); + if (nameEl) nameEl.textContent = name; + if (mailEl) mailEl.textContent = user.email || ''; + // Admin is only reachable by admins, so only they are offered it. + if (user.role === 'admin' && !card.querySelector('[data-account-tab="admin"]')) { + var menu = card.querySelector('.account-menu'); + var logout = menu && menu.querySelector('[data-account-logout]'); + if (menu && logout) { + var admin = document.createElement('button'); + admin.type = 'button'; + admin.className = 'account-menu-item'; + admin.setAttribute('role', 'menuitem'); + admin.setAttribute('data-account-tab', 'admin'); + admin.innerHTML = ' Admin'; + menu.insertBefore(admin, logout); + } + } + }); + } + window.renderAccountCards = renderAccountCards; + document.addEventListener('tabChanged', renderAccountCards); + renderAccountCards(); + + document.addEventListener('click', function(event) { + var toggle = event.target.closest && event.target.closest('.account-card-btn'); + var menus = document.querySelectorAll('.account-menu'); + if (toggle) { + var mine = toggle.parentElement.querySelector('.account-menu'); + var opening = mine && mine.hidden; + menus.forEach(function(m) { m.hidden = true; }); + document.querySelectorAll('.account-card-btn').forEach(function(b) { b.setAttribute('aria-expanded', 'false'); }); + if (mine && opening) { mine.hidden = false; toggle.setAttribute('aria-expanded', 'true'); } + return; + } + var item = event.target.closest && event.target.closest('.account-menu-item'); + menus.forEach(function(m) { m.hidden = true; }); + document.querySelectorAll('.account-card-btn').forEach(function(b) { b.setAttribute('aria-expanded', 'false'); }); + if (!item) return; + if (item.hasAttribute('data-account-logout')) { + var logoutBtn = document.getElementById('btn-logout'); + if (logoutBtn) logoutBtn.click(); + return; + } + var tab = item.getAttribute('data-account-tab'); + // Those tabs live in the app, so reaching one from the assistant leaves it. + if (tab && window.location.pathname === '/assistant') { + try { localStorage.setItem('ped_last_tab', tab); } catch (e) {} + window.location.href = '/'; + return; + } + if (tab) activateTab(tab); + }); + document.addEventListener('keydown', function(event) { + if (event.key !== 'Escape') return; + document.querySelectorAll('.account-menu').forEach(function(m) { m.hidden = true; }); + document.querySelectorAll('.account-card-btn').forEach(function(b) { b.setAttribute('aria-expanded', 'false'); }); + }); + // Expose activateTab globally so auth.js can call it after login window.activateTab = activateTab; diff --git a/test/assistant-component-css.test.js b/test/assistant-component-css.test.js index 6f9aa498..c903557a 100644 --- a/test/assistant-component-css.test.js +++ b/test/assistant-component-css.test.js @@ -56,7 +56,8 @@ test('actual component loader waits for versioned CSS before first initializatio 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')).maxHeight, '65vh'); + 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 = '
preserve\nline
'; @@ -79,7 +80,8 @@ test('actual component loader waits for versioned CSS before first initializatio fixture.release(); await reopened; assert.equal(tab.dataset.loaded, '1'); - assert.equal(dom.window.getComputedStyle(tab.querySelector('.assistant-messages')).maxHeight, '65vh'); + 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(); diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js index d586ae7b..9c256443 100644 --- a/test/assistant-workspace-layout.test.js +++ b/test/assistant-workspace-layout.test.js @@ -67,7 +67,8 @@ test('assistant area is an OWUI-style three-column workspace with a slim go-back // 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.ok(app.document.querySelector('.assistant-history #btn-assistant-create-image'), 'Create image entry sits at the rail top'); + 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. @@ -75,8 +76,8 @@ test('assistant area is an OWUI-style three-column workspace with a slim go-back 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-takehome', 'btn-assistant-export-pdf', 'btn-assistant-download-chat'], - 'Patient take home, Export PDF and Download transcript live in the + menu'); + 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]'); @@ -190,8 +191,8 @@ test('workspace mode does not override the mobile layout with a desktop header o // 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 - 66px);'); - const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: calc(100vh - 66px)'); + const override = css.indexOf('body.assistant-workspace .assistant-layout { height:calc(100dvh - 52px);'); + const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: calc(100vh - 52px)'); 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)); @@ -209,21 +210,6 @@ test('Go back is gone: Workspace is how you leave the assistant', () => { assert.doesNotMatch(js, /goBackToMainMenu/, 'and no dead handler left behind'); }); -test('Create image sits beside New chat instead of taking its own row', () => { - 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-rail-actions/, 'the two share a row'); - const button = html.split('\n').find(l => l.includes('btn-assistant-create-image')); - assert.match(button, /title="Create image"/, 'it is a tooltip now, not a label'); - assert.match(button, /aria-label="Create image"/, 'still named for screen readers'); - const css = fs.readFileSync(path.join(root, 'public/css/assistant.css'), 'utf8'); - const rule = css.split('\n').find(l => l.startsWith('.assistant-create-image {')); - assert.doesNotMatch(rule, /width:100%/, 'no longer a full-width banner'); - assert.match(rule, /width:36px/); -}); - test('Learning Hub sits with the content tools, not above the clinical ones', () => { const fs = require('node:fs'); const path = require('node:path'); @@ -340,12 +326,74 @@ test('nothing sits above the transcript, so switching cannot shift the page', () assert.doesNotMatch(html, /assistant-toolbar/, 'and no toolbar markup'); }); -test('the empty state puts the composer on the tiled ground, not against a seam', () => { +test('both modes share one surface, so switching changes only the content', () => { const css = read('public/css/assistant.css'); - // The tiles belong to the whole view so they run behind AND below the - // composer; previously they stopped at the transcript edge. - assert.match(css, /#assistant-chat-view:has\(\.assistant-messages:not\(:has\(\.assistant-msg\)\)\) \{\n\s*background-image:linear-gradient/); - assert.match(css, /\.assistant-messages:not\(:has\(\.assistant-msg\)\) \{ display:flex; flex-direction:column; justify-content:flex-end;/, - 'the empty state hugs the composer'); - assert.match(css, /margin:0 auto 12vh; max-width:760px/, 'and the composer sits above centre'); + // 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('')); + // 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('the top bar is slim and quiet, not a coloured band', () => { + const css = read('public/css/styles.css'); + const header = css.split('\n').find(l => l.startsWith('.app-header{')); + // A tall gradient made the header the loudest thing on screen, so any view + // without it read as a different product. + assert.doesNotMatch(header, /linear-gradient/, 'no colour band'); + assert.match(header, /height:52px/, 'and it is slim'); + // Everything measured against the header must follow it down. + assert.doesNotMatch(css, /calc\(100vh - 66px\)/, 'no stale 66px offsets'); + assert.doesNotMatch(read('public/css/assistant.css'), /calc\(100vh - 66px\)/); }); diff --git a/test/clinical-conversation.test.js b/test/clinical-conversation.test.js index b8553b94..85a3fab7 100644 --- a/test/clinical-conversation.test.js +++ b/test/clinical-conversation.test.js @@ -287,7 +287,7 @@ test('real save/reopen and UI renderer contain malicious legacy numbers without assert.equal(ui.document.querySelector('.assistant-cite').getAttribute('href'), '#assistant-source-1'); assert.equal(ui.document.getElementById('assistant-source-1'), cards[0]); await new Promise(resolve => ui.dom.window.addEventListener('load', resolve, { once: true })); - assert.equal(ui.dom.window.getComputedStyle(ui.document.getElementById('assistant-messages')).maxHeight, '65vh'); + assert.equal(ui.dom.window.getComputedStyle(ui.document.getElementById('assistant-messages')).overflowY, 'auto'); ui.dom.window.close(); });