fix: collapse to an icon rail, keep the menu scrollable, and brand as PedAI
Collapsing hid the sidebar entirely, and the toggle had moved into .menu-head while the hide rule still targeted direct children of .sidebar-nav — so the head went, taking the toggle with it, and there was no way to reopen the menu. Collapsed is now a 52px icon rail rather than a disappearance: the mark, the toggle, search and the account avatar stay reachable in place, so nothing has to be floated over the content and there is always a way back. The assistant rail collapses to the same strip. Scrolling. The workspace list in the assistant rail had no overflow rule, so its list of every app tab ran off the bottom and scrolling only appeared once you left for the app. In the app sidebar the opposite: the whole sidebar scrolled, so the account card scrolled away with the list and Settings and Log out became something to hunt for. Both now scroll only the list, with the head and the account card pinned. The toggle was also being styled twice — as a head-row icon button and by its own older rule with a different size and margin. It is now only the behavioural hook that flips the icon. Brand is PedAI, which is short enough to sit in the head row without squeezing the controls; the collapsed rail shows the mark alone. The reachability test now walks the toggle's real ancestor chain and fails if any collapse rule hides a branch it sits on, rather than asserting one rule's text — this class of bug has broken twice by moving the toggle deeper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
This commit is contained in:
parent
c7820c5ae6
commit
2d6eaccd96
5 changed files with 113 additions and 32 deletions
|
|
@ -9,7 +9,7 @@
|
|||
<div class="menu-head">
|
||||
<div class="menu-brand">
|
||||
<i class="fas fa-stethoscope"></i>
|
||||
<h1>Pediatric Clinical Tools</h1>
|
||||
<h1>PedAI</h1>
|
||||
</div>
|
||||
<button type="button" class="menu-icon-btn" data-menu-search title="Search" aria-label="Search"><i class="fas fa-magnifying-glass"></i></button>
|
||||
<button type="button" class="menu-icon-btn assistant-menu-toggle" data-menu-toggle aria-expanded="true" title="Hide menu" aria-label="Hide menu"><i class="fas fa-angles-left"></i></button>
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@
|
|||
/* Saved chats occupy the full left rail and scroll inside it */
|
||||
.assistant-history { display:flex; flex-direction:column; height:100%; min-height:0; }
|
||||
.assistant-history .card { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; overflow:hidden; }
|
||||
#assistant-saved-chats { flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain; }
|
||||
@media (max-width: 960px) { .assistant-layout { grid-template-columns:minmax(0,1fr) 300px; } .assistant-history { position:static; grid-column:1 / -1; grid-template-columns:auto 1fr; align-items:start; } .assistant-new-chat { grid-column:1; } .assistant-view-switch { grid-column:1; } .assistant-history .card { grid-column:2; grid-row:1 / span 2; } .assistant-main { min-height:auto; } #assistant-chat-view { min-height:auto; grid-template-rows:minmax(0,1fr) auto; } }
|
||||
@media (max-width: 640px) {
|
||||
/* ── ChatGPT/OWUI mobile: independent full-screen overlay; the page behind never scrolls ── */
|
||||
|
|
@ -446,7 +447,11 @@ body.assistant-mode-workspace.menu-hidden .assistant-layout { grid-template-colu
|
|||
|
||||
/* Rail menu in workspace mode: the app's own .tab-btn shape, so the assistant
|
||||
rail and the app sidebar are the same list in the same clothes. */
|
||||
.assistant-rail-workspace { display:none; flex-direction:column; gap:1px; }
|
||||
/* The rail lists every app tab, which overflows a short viewport. The app
|
||||
sidebar has always scrolled; this one had no overflow rule, so the list simply
|
||||
ran off the bottom until you left for the app and got scrolling "suddenly".
|
||||
The head and the account card stay put; only the list moves. */
|
||||
.assistant-rail-workspace { display:none; flex-direction:column; gap:1px; flex:1 1 auto; min-height:0; overflow-y:auto; overflow-x:hidden; overscroll-behavior:contain; }
|
||||
body.assistant-mode-workspace .assistant-rail-workspace { display:flex; }
|
||||
.assistant-rail-links { display:flex; flex-direction:column; gap:1px; }
|
||||
.assistant-rail-link { display:flex; align-items:center; gap:10px; width:100%; border:none; border-left:3px solid transparent; background:none; color:var(--g600); font-family:inherit; font-size:13px; font-weight:500; text-align:left; padding:10px 12px; cursor:pointer; }
|
||||
|
|
|
|||
|
|
@ -47,10 +47,13 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
|
|||
/* HEADER */
|
||||
/* No header bar at all. The brand heads the menu, Settings and Log out sit in
|
||||
the account card at its foot, and the content gets the full height. */
|
||||
/* Icon, search, collapse — nothing else earns a place in the head row. */
|
||||
.menu-head { display:flex; align-items:center; gap:4px; padding:10px 8px 8px 12px; }
|
||||
.menu-brand { display:flex; align-items:center; gap:8px; flex:1 1 auto; min-width:0; }
|
||||
.menu-head .menu-icon-btn:first-of-type { margin-left:auto; }
|
||||
.menu-brand { display:flex; align-items:center; gap:8px; flex:0 0 auto; min-width:0; }
|
||||
.menu-brand h1 { font-size:15px; font-weight:700; color:var(--g900); margin:0; line-height:1; letter-spacing:-.01em; }
|
||||
.menu-brand i { font-size:16px; color:var(--blue); flex:0 0 auto; }
|
||||
.menu-brand h1 { font-size:13.5px; font-weight:700; color:var(--g900); line-height:1.2; margin:0; overflow:hidden; text-overflow:ellipsis; }
|
||||
.menu-brand i { transition:font-size .15s ease; }
|
||||
.menu-icon-btn { flex:0 0 auto; display:inline-flex; align-items:center; justify-content:center; width:28px; height:28px; border:none; border-radius:8px; background:none; color:var(--g500); font-size:13px; cursor:pointer; }
|
||||
.menu-icon-btn:hover { background:var(--g100); color:var(--g800); }
|
||||
.assistant-history .menu-head { padding:0 0 8px; }
|
||||
|
|
@ -63,11 +66,13 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
|
|||
.app-body{display:flex;min-height:100vh;}
|
||||
|
||||
/* 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:100vh;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:100vh;overflow:hidden;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);}
|
||||
.sidebar-nav{display:flex;flex-direction:column;padding:8px 0;flex:1;}
|
||||
/* Head pinned, list scrolls, account card pinned. */
|
||||
.sidebar-nav{display:flex;flex-direction:column;padding:0 0 8px;flex:1 1 auto;min-height:0;overflow:hidden;}
|
||||
.sidebar-tabs{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:4px 0;}
|
||||
.sidebar-section-label{font-size:10px;font-weight:700;color:var(--g400);text-transform:uppercase;letter-spacing:0.7px;padding:10px 16px 4px;}
|
||||
.tab-btn{display:flex;align-items:center;gap:10px;padding:10px 16px;border:none;border-left:3px solid transparent;background:none;color:var(--g600);font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap;font-family:inherit;transition:all 0.15s;width:100%;text-align:left;}
|
||||
.tab-btn:hover{background:var(--g50);color:var(--g900);}
|
||||
|
|
@ -352,7 +357,6 @@ textarea.full-input{resize:vertical;}
|
|||
.sidebar{display:flex;flex-direction:column;overflow-y:auto;}
|
||||
.sidebar-nav{flex:1 1 auto;min-height:0;}
|
||||
.menu-head{padding:12px 10px 8px;}
|
||||
.menu-brand h1{font-size:13px;}
|
||||
.account-card{position:sticky;bottom:0;background:white;}
|
||||
.account-menu{left:8px;right:8px;}
|
||||
/* The palette is full-bleed on a phone, where a centred dialog wastes space. */
|
||||
|
|
@ -1253,24 +1257,43 @@ button, a, .btn-sm, .btn-generate, .btn-send, .tab-btn, input, textarea, select,
|
|||
.assistant-history .assistant-mode-switch { margin:0 0 8px; }
|
||||
|
||||
/* The one menu toggle, identical in the app sidebar and the assistant rail. */
|
||||
.assistant-menu-toggle { display:flex; align-items:center; justify-content:center; width:32px; height:32px; margin:10px 12px 0; border:1px solid var(--g200); border-radius:8px; background:white; color:var(--g500); font-size:12px; cursor:pointer; }
|
||||
.assistant-menu-toggle:hover { color:var(--blue); border-color:var(--blue-light); background:var(--blue-light); }
|
||||
.assistant-history .assistant-menu-toggle { margin:0 0 8px; }
|
||||
/* The toggle sits in the head row, so .menu-icon-btn styles it; this class is
|
||||
only the behavioural hook that flips the icon. Styling it here too gave it a
|
||||
second size and margin that fought the row. */
|
||||
.assistant-menu-toggle i { transition:transform .15s ease; }
|
||||
.assistant-menu-toggle.is-collapsed i { transform:rotate(180deg); }
|
||||
|
||||
/* Hidden menu: the rail collapses in both places, and the layout takes the
|
||||
space back rather than leaving a gutter. */
|
||||
/* Collapsing must not take the control away with it, or there is no way back.
|
||||
The toggle leaves the collapsing box and pins itself beside the content. */
|
||||
body.menu-hidden .sidebar { width:0; border:none; overflow:visible; }
|
||||
body.menu-hidden .sidebar > *:not(.sidebar-nav) { display:none; }
|
||||
body.menu-hidden .sidebar-nav > *:not(.assistant-menu-toggle) { display:none; }
|
||||
body.menu-hidden .assistant-menu-toggle,
|
||||
body.assistant-workspace.menu-hidden .assistant-history .assistant-menu-toggle {
|
||||
position:fixed; top:10px; left:10px; z-index:120; margin:0; width:34px; height:34px; border:1px solid var(--g200); background:white; box-shadow:var(--shadow); }
|
||||
body.assistant-workspace.menu-hidden .assistant-layout { grid-template-columns:0 minmax(0,1fr) 330px; }
|
||||
body.assistant-workspace.menu-hidden.assistant-no-citations .assistant-layout { grid-template-columns:0 minmax(0,1fr); }
|
||||
body.assistant-workspace.menu-hidden .assistant-history > *:not(.assistant-menu-toggle) { display:none; }
|
||||
body.assistant-workspace.menu-hidden .assistant-history { width:0; min-width:0; overflow:visible; padding:0; }
|
||||
/* Collapsed = a narrow icon rail, not a disappearance. The brand mark, the
|
||||
toggle, search and the account avatar stay reachable, so nothing has to be
|
||||
floated over the content and there is always a way back. */
|
||||
body.menu-hidden .sidebar { width:52px; }
|
||||
body.menu-hidden .sidebar-tabs,
|
||||
body.menu-hidden .assistant-mode-switch,
|
||||
body.menu-hidden .sidebar-section-label,
|
||||
body.menu-hidden .menu-brand h1,
|
||||
body.menu-hidden .account-id,
|
||||
body.menu-hidden .account-chevron { display:none; }
|
||||
body.menu-hidden .menu-head { flex-direction:column; gap:6px; padding:10px 0 8px; align-items:center; }
|
||||
body.menu-hidden .menu-brand { flex:0 0 auto; justify-content:center; }
|
||||
body.menu-hidden .menu-brand i { font-size:19px; }
|
||||
body.menu-hidden .account-card { padding:8px 0; }
|
||||
body.menu-hidden .account-card-btn { justify-content:center; padding:6px 0; }
|
||||
body.menu-hidden .account-menu { left:8px; right:auto; width:210px; }
|
||||
|
||||
/* The assistant rail collapses to the same strip rather than to nothing. */
|
||||
body.assistant-workspace.menu-hidden .assistant-layout { grid-template-columns:52px minmax(0,1fr) 330px; }
|
||||
body.assistant-workspace.menu-hidden.assistant-no-citations .assistant-layout,
|
||||
body.assistant-mode-workspace.menu-hidden .assistant-layout { grid-template-columns:52px minmax(0,1fr); }
|
||||
body.assistant-workspace.menu-hidden .assistant-history { width:auto; min-width:0; overflow:visible; padding:0; }
|
||||
body.assistant-workspace.menu-hidden .assistant-history .card,
|
||||
body.assistant-workspace.menu-hidden .assistant-rail-workspace,
|
||||
body.assistant-workspace.menu-hidden .assistant-rail-actions,
|
||||
body.assistant-workspace.menu-hidden .assistant-mode-switch { display:none; }
|
||||
|
||||
@media (max-width:640px) { .assistant-menu-toggle { display:none; } body.menu-hidden .sidebar { width:auto; } }
|
||||
|
||||
/* Signed-out preview: the assistant is usable, everything that needs an account
|
||||
|
|
@ -1303,7 +1326,6 @@ body.assistant-preview .assistant-preview-note { display:flex; }
|
|||
/* 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; }
|
||||
|
||||
|
|
@ -1327,4 +1349,3 @@ body.assistant-preview .account-card { display:none; }
|
|||
.menu-search-item:hover i, .menu-search-item.is-active i { color:var(--blue); }
|
||||
.menu-search-empty { margin:0; padding:18px 12px; text-align:center; font-size:13px; color:var(--g400); }
|
||||
/* Collapsed: only the toggle survives, so there is a way back. */
|
||||
body.menu-hidden .menu-brand, body.menu-hidden [data-menu-search] { display:none; }
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@
|
|||
<div class="auth-box">
|
||||
<div class="auth-logo">
|
||||
<i class="fas fa-stethoscope"></i>
|
||||
<h1>Pediatric Clinical Tools</h1>
|
||||
<p>AI-Powered Clinical Documentation</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -181,7 +180,7 @@
|
|||
<div class="menu-head">
|
||||
<div class="menu-brand">
|
||||
<i class="fas fa-stethoscope"></i>
|
||||
<h1>Pediatric Clinical Tools</h1>
|
||||
<h1>PedAI</h1>
|
||||
</div>
|
||||
<button type="button" class="menu-icon-btn" data-menu-search title="Search" aria-label="Search"><i class="fas fa-magnifying-glass"></i></button>
|
||||
<button type="button" class="menu-icon-btn assistant-menu-toggle" data-menu-toggle aria-expanded="true" title="Hide menu" aria-label="Hide menu"><i class="fas fa-angles-left"></i></button>
|
||||
|
|
@ -193,6 +192,7 @@
|
|||
<button type="button" role="tab" aria-selected="false" data-assistant-mode="assistant"><i class="fas fa-brain"></i> Assistant</button>
|
||||
<button type="button" role="tab" class="active" aria-selected="true" data-assistant-mode="workspace"><i class="fas fa-grip"></i> Workspace</button>
|
||||
</div>
|
||||
<div class="sidebar-tabs">
|
||||
<span class="sidebar-section-label">Encounters</span>
|
||||
<button class="tab-btn active" data-tab="encounter">
|
||||
<i class="fas fa-comments"></i>
|
||||
|
|
@ -286,6 +286,7 @@
|
|||
<span>Admin</span>
|
||||
</button>
|
||||
</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
|
||||
|
|
|
|||
|
|
@ -124,13 +124,18 @@ test('one menu toggle serves both the app sidebar and the assistant rail', () =>
|
|||
|
||||
// Shared chrome lives in styles.css: the app page never loads assistant.css.
|
||||
const css = read('public/css/styles.css');
|
||||
assert.match(css, /body\.menu-hidden \.sidebar \{ width:0/);
|
||||
assert.match(css, /body\.menu-hidden \.sidebar \{ width:52px; \}/);
|
||||
// The toggle lives inside the box it collapses, so it must leave that box or
|
||||
// there is no way to bring the menu back.
|
||||
assert.match(css, /body\.menu-hidden \.assistant-menu-toggle,\n[^\n]*assistant-menu-toggle \{\n\s*position:fixed/,
|
||||
'the toggle pins itself outside the collapsed menu');
|
||||
assert.match(css, /body\.menu-hidden \.sidebar-nav > \*:not\(\.assistant-menu-toggle\) \{ display:none; \}/,
|
||||
'everything else in the collapsed sidebar is hidden');
|
||||
// The toggle is a head-row icon button, so it needs no position or margin of
|
||||
// its own — styling it twice gave it a second size that fought the row.
|
||||
assert.match(css, /\.menu-icon-btn \{[^}]*width:28px; height:28px/, 'one styling source');
|
||||
assert.doesNotMatch(css, /^\.assistant-menu-toggle \{ display:flex/m, 'no duplicate box styling');
|
||||
assert.match(css, /body\.menu-hidden \.sidebar \{ width:52px; \}/,
|
||||
'and collapsed keeps it in the rail rather than floating over content');
|
||||
// The collapsed rail keeps its icons; only the labelled lists go.
|
||||
assert.match(css, /body\.menu-hidden \.sidebar-tabs,/, 'the tab list collapses');
|
||||
assert.match(css, /body\.menu-hidden \.menu-brand h1,/, 'and the wordmark, leaving the mark');
|
||||
assert.match(read('public/js/app.js'), /'Show menu' : 'Hide menu'/, 'and it says how to get back');
|
||||
});
|
||||
|
||||
|
|
@ -183,8 +188,8 @@ test('the menu toggle uses an icon that reads as a menu', () => {
|
|||
assert.match(read('public/css/assistant.css'), /\.assistant-menu-toggle\.is-collapsed i \{ transform:rotate\(180deg\); \}/,
|
||||
'and it turns to show the direction it will act in');
|
||||
// Collapsed, the toggle is the only control left, so it must stay reachable.
|
||||
assert.match(read('public/css/styles.css'), /body\.menu-hidden \.menu-brand, body\.menu-hidden \[data-menu-search\] \{ display:none; \}/,
|
||||
'brand and search go, the toggle stays');
|
||||
assert.match(read('public/css/styles.css'), /body\.menu-hidden \.sidebar \{ width:52px; \}/,
|
||||
'collapsed narrows to an icon rail, so the toggle stays reachable in place');
|
||||
});
|
||||
|
||||
test('workspace mode does not override the mobile layout with a desktop header offset', () => {
|
||||
|
|
@ -296,7 +301,7 @@ test('chrome rendered on the main app page is styled by the stylesheet that page
|
|||
// Defined in both files, the two copies drift and one silently wins.
|
||||
assert.ok(!assistantCss.includes('.' + cls + ' {'), cls + ' must not be duplicated in assistant.css');
|
||||
}
|
||||
assert.match(styles, /body\.menu-hidden \.sidebar \{ width:0/, 'the collapse state is styled where the app can see it');
|
||||
assert.match(styles, /body\.menu-hidden \.sidebar \{ width:52px; \}/, 'the collapse state is styled where the app can see it');
|
||||
});
|
||||
|
||||
test('the model selector sits with send, and only when there is a choice', () => {
|
||||
|
|
@ -437,3 +442,52 @@ test('the new chrome is handled on phones', () => {
|
|||
assert.match(mobile, /\.menu-search-panel\{width:100%/, 'the palette is full-bleed');
|
||||
assert.match(mobile, /\.sidebar-nav\{flex:1 1 auto;min-height:0;\}/, 'the menu scrolls, the card does not float away');
|
||||
});
|
||||
|
||||
test('the rail scrolls in both modes, not only after leaving for the app', () => {
|
||||
const css = read('public/css/assistant.css');
|
||||
// The app sidebar has always scrolled. The assistant rail had no overflow
|
||||
// rule, so its list of every app tab ran off the bottom — and scrolling only
|
||||
// appeared once you left for the app.
|
||||
assert.match(css, /\.assistant-rail-workspace \{[^}]*flex:1 1 auto; min-height:0; overflow-y:auto/,
|
||||
'the workspace list scrolls');
|
||||
assert.match(css, /#assistant-saved-chats \{ flex:1 1 auto; min-height:0; overflow-y:auto/,
|
||||
'and so does the saved-chat list');
|
||||
// The rail itself is the flex column that makes those two scroll.
|
||||
assert.match(css, /\.assistant-history \{ display:flex; flex-direction:column; height:100%; min-height:0; \}/);
|
||||
});
|
||||
|
||||
test('the menu can always be reopened after it is hidden', () => {
|
||||
const html = read('public/index.html');
|
||||
const css = read('public/css/styles.css');
|
||||
|
||||
// This has broken twice by moving the toggle deeper and leaving a hide rule
|
||||
// pointed at its old depth. So walk the real ancestor chain and prove no rule
|
||||
// hides anything between the toggle and the sidebar root.
|
||||
const toggleAt = html.indexOf('data-menu-toggle');
|
||||
assert.ok(toggleAt > 0, 'the toggle exists');
|
||||
const before = html.slice(0, toggleAt);
|
||||
const ancestors = [];
|
||||
for (const m of before.matchAll(/<(?:div|nav)\s+class="([^"]+)"/g)) ancestors.push(m[1].split(/\s+/));
|
||||
// Innermost ancestors that are still open at the toggle.
|
||||
const chain = ancestors.slice(-3).flat();
|
||||
assert.ok(chain.includes('menu-head'), 'the toggle sits in the menu head');
|
||||
assert.match(css, /body\.menu-hidden \.sidebar \{ width:52px; \}/, 'collapsed is a rail, not a disappearance');
|
||||
|
||||
const hideRules = css.split('\n').filter(l => l.startsWith('body.menu-hidden') && l.includes('display:none'));
|
||||
for (const rule of hideRules) {
|
||||
const selector = rule.slice(0, rule.indexOf('{')).trim();
|
||||
// A rule that hides a class on the toggle's own ancestor chain must exempt
|
||||
// the branch the toggle is on.
|
||||
for (const cls of ['menu-head', 'sidebar-nav']) {
|
||||
if (new RegExp('\\.' + cls + '\\s*\\{|\\.' + cls + '\\s*,|\\.' + cls + '$').test(selector)) {
|
||||
assert.fail('rule hides the toggle\'s ancestor .' + cls + ': ' + selector);
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the exemptions that keep it reachable are actually present.
|
||||
// Everything that keeps the rail usable while collapsed stays visible.
|
||||
for (const kept of ['.menu-head', '.menu-brand', '.account-card']) {
|
||||
assert.ok(!hideRules.some(r => r.slice(0, r.indexOf('{')).trim().endsWith(kept)),
|
||||
kept + ' stays visible in the collapsed rail');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue