feat: one menu for both views, a slim top bar, and an account card

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
This commit is contained in:
Daniel 2026-09-10 04:45:51 +02:00
parent 5cfd47ea66
commit 91699e45cb
8 changed files with 268 additions and 80 deletions

View file

@ -11,7 +11,6 @@
</div>
<div class="assistant-rail-actions">
<button id="btn-assistant-clear" class="assistant-new-chat" type="button"><i class="fas fa-plus"></i> <span>New chat</span><kbd class="assistant-kbd">Ctrl+Shift+O</kbd></button>
<button id="btn-assistant-create-image" class="assistant-create-image" type="button" title="Create image" aria-label="Create image"><i class="fas fa-wand-magic-sparkles"></i></button>
</div>
<!-- Workspace mode replaces the saved chats here with the app's own
menu, so the rail is never left empty. Same source as the cards. -->
@ -27,6 +26,25 @@
<p class="assistant-muted">Chats save automatically as you go.</p>
</div>
</div>
<!-- Account card. The same markup renders in the assistant rail, so the
two menus end the same way. Settings and Log out were only reachable
from icon buttons in the header, which the slim bar no longer has
room to explain. -->
<div class="account-card">
<button type="button" class="account-card-btn" id="btn-account-menu-rail" aria-haspopup="true" aria-expanded="false" aria-controls="account-menu-rail">
<span class="account-avatar" id="account-avatar-rail" aria-hidden="true"></span>
<span class="account-id">
<span class="account-name" id="account-name-rail"></span>
<span class="account-email" id="account-email-rail"></span>
</span>
<i class="fas fa-chevron-right account-chevron"></i>
</button>
<div class="account-menu" id="account-menu-rail" role="menu" hidden>
<button type="button" class="account-menu-item" role="menuitem" data-account-tab="settings"><i class="fas fa-gear"></i> Settings</button>
<button type="button" class="account-menu-item" role="menuitem" data-account-tab="faq"><i class="fas fa-circle-question"></i> FAQ</button>
<button type="button" class="account-menu-item" role="menuitem" data-account-logout><i class="fas fa-right-from-bracket"></i> Log out</button>
</div>
</div>
</aside>
<section class="assistant-main card">
@ -62,6 +80,7 @@
<button id="btn-assistant-plus" class="assistant-plus-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="assistant-plus-menu" title="Attach and export" aria-label="Attach and export"><i class="fas fa-plus"></i></button>
<div id="assistant-plus-menu" class="assistant-plus-menu" role="menu" hidden>
<label class="assistant-plus-item" for="assistant-attach-input" role="menuitem"><i class="fas fa-paperclip"></i> Attach images</label>
<button id="btn-assistant-create-image" class="assistant-plus-item" type="button" role="menuitem"><i class="fas fa-wand-magic-sparkles"></i> Create image</button>
<button id="btn-assistant-takehome" class="assistant-plus-item" type="button" role="menuitem"><i class="fas fa-heart"></i> Patient take home</button>
<button id="btn-assistant-export-pdf" class="assistant-plus-item" type="button" role="menuitem"><i class="fas fa-file-pdf"></i> Export PDF</button>
<button id="btn-assistant-download-chat" class="assistant-plus-item" type="button" role="menuitem"><i class="fas fa-download"></i> Download transcript</button>

View file

@ -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; }
}

View file

@ -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; }

View file

@ -298,6 +298,25 @@
<span>Admin</span>
</button>
</div>
<!-- Account card. The same markup renders in the assistant rail, so the
two menus end the same way. Settings and Log out were only reachable
from icon buttons in the header, which the slim bar no longer has
room to explain. -->
<div class="account-card">
<button type="button" class="account-card-btn" id="btn-account-menu" aria-haspopup="true" aria-expanded="false" aria-controls="account-menu">
<span class="account-avatar" id="account-avatar" aria-hidden="true"></span>
<span class="account-id">
<span class="account-name" id="account-name"></span>
<span class="account-email" id="account-email"></span>
</span>
<i class="fas fa-chevron-right account-chevron"></i>
</button>
<div class="account-menu" id="account-menu" role="menu" hidden>
<button type="button" class="account-menu-item" role="menuitem" data-account-tab="settings"><i class="fas fa-gear"></i> Settings</button>
<button type="button" class="account-menu-item" role="menuitem" data-account-tab="faq"><i class="fas fa-circle-question"></i> FAQ</button>
<button type="button" class="account-menu-item" role="menuitem" data-account-logout><i class="fas fa-right-from-bracket"></i> Log out</button>
</div>
</div>
</nav>
<div class="sidebar-overlay" id="sidebar-overlay"></div>

View file

@ -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 = '<i class="fas fa-user-shield"></i> 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;

View file

@ -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 = '<div class="assistant-bubble"> preserve\nline</div>';
@ -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();

View file

@ -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('</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('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\)/);
});

View file

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