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(); });