+
diff --git a/public/css/assistant.css b/public/css/assistant.css
index 40ba0c19..4cdedce9 100644
--- a/public/css/assistant.css
+++ b/public/css/assistant.css
@@ -7,14 +7,14 @@
.assistant-dot { width:8px; height:8px; border-radius:50%; background:var(--green); display:inline-block; }
.assistant-status.busy .assistant-dot { background:var(--amber); animation:pulse 1.5s infinite; }
.assistant-status.error .assistant-dot { background:var(--red); }
-.assistant-layout { display:grid; grid-template-columns:260px minmax(0,1fr) 330px; gap:14px; align-items:stretch; }
+.assistant-layout { display:grid; grid-template-columns:210px minmax(0,1fr) 330px; gap:14px; align-items:stretch; }
.assistant-layout > * { min-width:0; }
.assistant-history { display:grid; gap:10px; align-content:start; position:sticky; top:0; }
.assistant-new-chat { display:flex; align-items:center; justify-content:center; gap:8px; width:100%; border:1px solid var(--blue-light); background:var(--blue-light); color:var(--blue); border-radius:10px; padding:10px 12px; font-size:13px; font-weight:700; cursor:pointer; }
.assistant-new-chat:hover { border-color:var(--blue); background:var(--blue-light); }
.assistant-main { display:flex; flex-direction:column; min-height:calc(100vh - 200px); min-width:0; }
#assistant-chat-view { display:grid; grid-template-rows:minmax(0,1fr) auto; min-height:0; min-width:0; }
-.assistant-messages { padding:16px; max-height:65vh; overflow-y:auto; overflow-x:hidden; background:linear-gradient(180deg,#fff,var(--g50)); min-width:0; }
+.assistant-messages { padding:16px; max-height:none; overflow-y:auto; overflow-x:hidden; background:transparent; min-width:0; }
.assistant-empty { max-width:680px; margin:50px auto; text-align:center; color:var(--g500); }
.assistant-empty i { font-size:34px; color:var(--blue); margin-bottom:10px; }
.assistant-empty h3 { color:var(--g800); font-size:18px; margin-bottom:6px; }
@@ -219,8 +219,6 @@
.assistant-layout .assistant-saved-chat-menu { position:absolute; right:6px; top:50%; transform:translateY(-50%); opacity:1; pointer-events:auto; }
.assistant-new-chat { display:flex; align-items:center; gap:10px; width:100%; border:none; background:none; color:var(--g700); font-size:14px; padding:10px 12px; border-radius:8px; text-align:left; }
.assistant-new-chat:hover { background:var(--g100); }
- .assistant-create-image { flex:0 0 auto; width:40px; padding:0; justify-content:center; background:var(--blue-light); color:var(--blue); border-radius:8px; }
- .assistant-create-image:hover { background:var(--blue-light); }
.assistant-view-switch { margin:6px 8px; }
.assistant-empty { margin:28px auto; max-width:100%; }
.assistant-empty i { font-size:26px; }
@@ -280,12 +278,12 @@
@keyframes assistant-pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,.45); } 50% { box-shadow: 0 0 0 14px rgba(220,38,38,0); } }
/* Full-screen Open WebUI workspace: the assistant replaces the app chrome */
-/* 66px is the app header, which the assistant now keeps (see styles.css). */
-body.assistant-workspace .assistant-layout { height: calc(100vh - 66px); min-height: 0; grid-template-rows: minmax(0, 1fr); overflow: hidden; }
+/* 52px is the slim app header, which the assistant shares (see styles.css). */
+body.assistant-workspace .assistant-layout { height: calc(100vh - 52px); min-height: 0; grid-template-rows: minmax(0, 1fr); overflow: hidden; }
/* Phones: workspace mode hides .app-header, so there is no 64px to subtract and
the mobile flex layout must survive the more specific workspace selectors. */
@media (max-width: 640px) {
- body.assistant-workspace .assistant-layout { height:calc(100dvh - 66px); display:flex; flex-direction:column; grid-template-rows:none; overflow:hidden; }
+ body.assistant-workspace .assistant-layout { height:calc(100dvh - 52px); display:flex; flex-direction:column; grid-template-rows:none; overflow:hidden; }
}
/* Desktop: the saved-chats rail collapses like ChatGPT's sidebar */
@@ -295,7 +293,7 @@ body.assistant-workspace .assistant-layout { height: calc(100vh - 66px); min-hei
.assistant-layout { transition:grid-template-columns .18s ease; }
/* Citations off: the Sources column is gone, so its track goes with it rather
than leaving a 330px blank. Both collapse states are spelled out. */
-body.assistant-no-citations .assistant-layout { grid-template-columns:260px minmax(0,1fr); }
+body.assistant-no-citations .assistant-layout { grid-template-columns:210px minmax(0,1fr); }
body.assistant-no-citations .assistant-layout.history-collapsed { grid-template-columns:0 minmax(0,1fr); }
.assistant-layout.history-collapsed { grid-template-columns:0 minmax(0,1fr) 330px; }
/* NOT display:none — that removes the rail from grid flow, so the chat inherits
@@ -317,10 +315,6 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; }
/* Create image entry + Images gallery (ChatGPT-style left rail) */
than a second full-width banner competing with it. */
-.assistant-rail-actions { display:flex; align-items:stretch; gap:6px; }
-.assistant-rail-actions .assistant-new-chat { flex:1 1 auto; min-width:0; }
-.assistant-create-image { flex:0 0 auto; display:inline-flex; align-items:center; justify-content:center; width:36px; border:1px solid var(--blue-light); border-radius:10px; padding:0; background:var(--blue-light); color:var(--blue); font-size:13px; cursor:pointer; }
-.assistant-create-image:hover { border-color:var(--blue); }
.assistant-gallery-card { flex:0 1 auto; }
.assistant-image-gallery { display:grid; grid-template-columns:repeat(auto-fill,minmax(56px,1fr)); gap:6px; padding:10px 12px; max-height:220px; overflow-y:auto; }
.assistant-gallery-item { border:none; background:none; padding:0; border-radius:8px; overflow:hidden; cursor:pointer; border:1px solid var(--g200); }
@@ -429,9 +423,7 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; }
/* Workspace launcher: same ground, card language and spacing as the empty state,
so the two views are one interface. Desktop only. */
-.assistant-workspace-view { flex:1 1 auto; min-height:0; padding:24px 18px; overflow-y:auto;
- background-image:linear-gradient(var(--g100) 1px, transparent 1px), linear-gradient(90deg, var(--g100) 1px, transparent 1px);
- background-size:28px 28px; background-position:-1px -1px; }
+.assistant-workspace-view { flex:1 1 auto; min-height:0; padding:24px 18px; overflow-y:auto; }
.assistant-workspace-cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:10px; max-width:860px; margin:0 auto; }
.assistant-workspace-card { display:flex; align-items:center; gap:10px; border:1px solid var(--g200); background:white; border-radius:10px; padding:12px 14px; font-size:12.5px; color:var(--g700); text-align:left; cursor:pointer; box-shadow:var(--shadow); }
.assistant-workspace-card:hover { border-color:var(--blue-light); background:var(--blue-light); color:var(--blue); }
@@ -445,7 +437,7 @@ body.assistant-mode-workspace #assistant-chat-view { display:none; }
body.assistant-mode-workspace .assistant-history .card,
body.assistant-mode-workspace .assistant-rail-actions,
body.assistant-mode-workspace .assistant-side { display:none; }
-body.assistant-mode-workspace .assistant-layout { grid-template-columns:260px minmax(0,1fr); }
+body.assistant-mode-workspace .assistant-layout { grid-template-columns:210px minmax(0,1fr); }
body.assistant-mode-workspace.menu-hidden .assistant-layout { grid-template-columns:0 minmax(0,1fr); }
@media (max-width:640px) {
body.assistant-mode-workspace .assistant-workspace-view { display:none; }
@@ -479,21 +471,6 @@ body.assistant-mode-workspace .assistant-rail-workspace { display:flex; }
.assistant-plus-item i { width:16px; text-align:center; color:var(--g400); font-size:12px; }
.assistant-plus-item:hover i { color:var(--blue); }
-/* Empty state: the tiled ground belongs to the whole view, so it runs behind
- AND below the composer instead of stopping at the transcript's edge. The
- composer floats on it as a raised card, sitting a little above centre. */
-#assistant-chat-view:has(.assistant-messages:not(:has(.assistant-msg))) {
- background-image:linear-gradient(var(--g100) 1px, transparent 1px), linear-gradient(90deg, var(--g100) 1px, transparent 1px);
- background-size:28px 28px; background-position:-1px -1px; }
-.assistant-messages:not(:has(.assistant-msg)) { display:flex; flex-direction:column; justify-content:flex-end;
- background:none; background-image:none; padding-bottom:0; }
-.assistant-empty { max-width:680px; margin:auto auto 22px; text-align:center; color:var(--g500); }
-#assistant-chat-view:has(.assistant-messages:not(:has(.assistant-msg))) .assistant-composer {
- border-top:none; background:white; margin:0 auto 12vh; max-width:760px; width:calc(100% - 32px); }
-@media (max-width:640px) {
- #assistant-chat-view:has(.assistant-messages:not(:has(.assistant-msg))) .assistant-composer {
- margin:0 10px 10px; width:auto; max-width:none; }
-}
/* The drawer button floats on phones instead of occupying a bar the desktop
layout does not have — that bar was the dead strip at the top and the reason
@@ -506,3 +483,23 @@ body.assistant-mode-workspace .assistant-rail-workspace { display:flex; }
/* Both toggles point the way they act, and flip when collapsed. */
.assistant-menu-toggle i { transition:transform .15s ease; }
.assistant-menu-toggle.is-collapsed i { transform:rotate(180deg); }
+
+/* ── One surface for both modes ──────────────────────────────────────────────
+ The tiled ground, the padding and the box are identical whether you are
+ reading the transcript or the workspace launcher, so switching changes the
+ content and nothing else. */
+.assistant-main.card {
+ background-image:linear-gradient(var(--g100) 1px, transparent 1px), linear-gradient(90deg, var(--g100) 1px, transparent 1px);
+ background-size:28px 28px; background-position:-1px -1px; background-color:white; }
+
+/* The composer is a card floating on that ground, with room around it rather
+ than pinned to the panel edge. */
+.assistant-composer { margin:0 auto 20px; max-width:760px; width:calc(100% - 40px); border-top:1px solid var(--g200); }
+/* Nothing sits above the transcript, so an empty one would leave the composer at
+ the very top. Push it to a comfortable reading position instead. */
+.assistant-messages:not(:has(.assistant-msg)) { display:flex; flex-direction:column; justify-content:center; padding-bottom:0; }
+.assistant-empty { max-width:680px; margin:0 auto 26px; text-align:center; color:var(--g500); }
+#assistant-chat-view:has(.assistant-messages:not(:has(.assistant-msg))) { grid-template-rows:minmax(0,1.15fr) auto; }
+@media (max-width:640px) {
+ .assistant-composer { margin:0 10px 10px; width:auto; max-width:none; }
+}
diff --git a/public/css/styles.css b/public/css/styles.css
index 8ea8c6a7..34338663 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -45,28 +45,31 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
/* HEADER */
-.app-header{background:linear-gradient(135deg,var(--blue) 0%,var(--purple) 100%);color:white;padding:14px 24px;}
-.header-content{max-width:1200px;margin:0 auto;}
-.header-top{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;}
-.logo{display:flex;align-items:center;gap:12px;}
-.logo i{font-size:26px;}
-.logo h1{font-size:20px;font-weight:700;}
-.tagline{font-size:12px;opacity:0.8;}
-.header-right{display:flex;align-items:center;gap:12px;}
-.model-selector{display:flex;align-items:center;gap:6px;background:rgba(255,255,255,0.15);padding:6px 12px;border-radius:8px;}
-.model-selector label{font-size:12px;}
-.model-selector select{padding:4px 8px;border:1px solid rgba(255,255,255,0.3);border-radius:6px;background:rgba(255,255,255,0.2);color:white;font-size:12px;font-family:inherit;max-width:220px;}
+/* A slim, quiet top bar. The tall blue gradient dominated every screen and, by
+ being the app's loudest element, made any view without it look like a
+ different product. Content is what should carry the colour. */
+.app-header{background:white;color:var(--g800);padding:0 16px;border-bottom:1px solid var(--g200);height:52px;display:flex;align-items:center;}
+.header-content{width:100%;margin:0;}
+.header-top{display:flex;justify-content:space-between;align-items:center;gap:12px;}
+.logo{display:flex;align-items:center;gap:9px;}
+.logo i{font-size:18px;color:var(--blue);}
+.logo h1{font-size:15px;font-weight:700;color:var(--g900);white-space:nowrap;}
+.tagline{display:none;}
+.header-right{display:flex;align-items:center;gap:8px;}
+.model-selector{display:flex;align-items:center;gap:6px;background:var(--g50);border:1px solid var(--g200);padding:4px 8px;border-radius:8px;}
+.model-selector label{font-size:11px;color:var(--g500);}
+.model-selector select{padding:3px 6px;border:1px solid var(--g200);border-radius:6px;background:white;color:var(--g700);font-size:12px;font-family:inherit;max-width:200px;}
.model-selector select option{background:white;color:var(--g800);}
-.cost-badge{font-size:10px;padding:2px 8px;background:rgba(16,185,129,0.3);border:1px solid rgba(16,185,129,0.5);border-radius:10px;color:#6ee7b7;font-weight:600;white-space:nowrap;}
-.header-buttons{display:flex;gap:6px;}
-.btn-header{background:rgba(255,255,255,0.15);border:none;color:white;width:36px;height:36px;border-radius:8px;cursor:pointer;font-size:14px;transition:background 0.2s;}
-.btn-header:hover{background:rgba(255,255,255,0.3);}
+.cost-badge{font-size:10px;padding:2px 8px;background:#ecfdf5;border:1px solid #a7f3d0;border-radius:10px;color:#047857;font-weight:600;white-space:nowrap;}
+.header-buttons{display:flex;gap:4px;}
+.btn-header{background:none;border:1px solid transparent;color:var(--g500);width:32px;height:32px;border-radius:8px;cursor:pointer;font-size:13px;transition:all 0.15s;}
+.btn-header:hover{background:var(--g100);color:var(--g800);}
/* APP BODY LAYOUT */
-.app-body{display:flex;min-height:calc(100vh - 66px);}
+.app-body{display:flex;min-height:calc(100vh - 52px);}
/* SIDEBAR */
-.sidebar{width:210px;flex-shrink:0;background:white;border-right:1.5px solid var(--g200);display:flex;flex-direction:column;position:sticky;top:0;height:calc(100vh - 66px);overflow-y:auto;z-index:100;transition:width 0.22s ease;}
+.sidebar{width:210px;flex-shrink:0;background:white;border-right:1.5px solid var(--g200);display:flex;flex-direction:column;position:sticky;top:0;height:calc(100vh - 52px);overflow-y:auto;z-index:100;transition:width 0.22s ease;}
.sidebar.collapsed{width:0;border:none;overflow:hidden;}
.sidebar-header{display:none;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--g200);}
.sidebar-title{font-size:13px;font-weight:700;color:var(--g700);}
@@ -1207,7 +1210,7 @@ textarea.full-input{resize:vertical;}
because the assistant rail takes its place. */
body.assistant-workspace .sidebar,
body.assistant-workspace .announcement-banner { display:none; }
-body.assistant-workspace .app-body { min-height:calc(100vh - 66px); }
+body.assistant-workspace .app-body { min-height:calc(100vh - 52px); }
body.assistant-workspace #assistant-tab.active { width:100%; }
/* iOS: taps on controls must not trigger double-tap zoom */
button, a, .btn-sm, .btn-generate, .btn-send, .tab-btn, input, textarea, select, label { touch-action: manipulation; }
@@ -1259,3 +1262,28 @@ body.assistant-preview .assistant-rail-actions,
body.assistant-preview .assistant-chats-header { display:none !important; }
body.assistant-preview .assistant-preview-note { display:flex; }
.assistant-preview-note { display:none; align-items:center; gap:8px; margin:0 0 10px; padding:8px 12px; border:1px solid var(--blue-light); background:var(--blue-light); color:var(--blue); border-radius:10px; font-size:12px; }
+
+/* ── Account card ────────────────────────────────────────────────────────────
+ Ends both menus identically. Settings, FAQ, Admin and Log out were unlabelled
+ icon buttons in the old header; the slim bar has no room to explain them. */
+.sidebar-nav { padding-bottom:0; }
+.account-card { position:relative; margin-top:auto; border-top:1px solid var(--g200); padding:8px; }
+.account-card-btn { display:flex; align-items:center; gap:9px; width:100%; padding:7px 8px; border:none; border-radius:10px; background:none; cursor:pointer; text-align:left; font-family:inherit; }
+.account-card-btn:hover, .account-card-btn[aria-expanded="true"] { background:var(--g100); }
+.account-avatar { flex:0 0 auto; display:inline-flex; align-items:center; justify-content:center; width:30px; height:30px; border-radius:999px; background:var(--blue); color:white; font-size:11px; font-weight:700; letter-spacing:.02em; }
+.account-id { flex:1 1 auto; min-width:0; display:flex; flex-direction:column; }
+.account-name { font-size:12.5px; font-weight:600; color:var(--g800); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.account-email { font-size:11px; color:var(--g500); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.account-chevron { flex:0 0 auto; font-size:10px; color:var(--g400); }
+.account-menu { position:absolute; bottom:calc(100% - 4px); left:8px; right:8px; z-index:130; display:flex; flex-direction:column; gap:2px; padding:6px; background:white; border:1px solid var(--g200); border-radius:12px; box-shadow:0 12px 32px rgba(15,23,42,.16); }
+.account-menu[hidden] { display:none; }
+.account-menu-item { display:flex; align-items:center; gap:10px; width:100%; border:0; border-radius:8px; padding:8px 10px; background:none; color:var(--g700); font-family:inherit; font-size:13px; text-align:left; cursor:pointer; }
+.account-menu-item:hover { background:var(--blue-light); color:var(--blue); }
+.account-menu-item i { width:16px; text-align:center; color:var(--g400); font-size:12px; }
+.account-menu-item:hover i { color:var(--blue); }
+/* The rail is a flex column, so the card needs the same push to the bottom. */
+.assistant-history .account-card { margin-top:auto; padding:8px 0 0; }
+/* Collapsed menus hide their contents; the card goes with them. */
+body.menu-hidden .account-card, body.assistant-workspace.menu-hidden .account-card { display:none; }
+/* A preview visitor has no account to show. */
+body.assistant-preview .account-card { display:none; }
diff --git a/public/index.html b/public/index.html
index 6fbf5382..7790fbc4 100644
--- a/public/index.html
+++ b/public/index.html
@@ -298,6 +298,25 @@
Admin
+
+
+
+
+
+
+
+
+
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();
});