feat: one shell for every view, no header bar, and a search palette

The header bar is gone. Settings and Log out moved into the account card, so a
whole band of chrome had nothing left to hold. The brand now heads the menu
alongside the search and collapse controls, on one row rather than three stacked
full-width ones. btn-settings and btn-logout are retained hidden as the canonical
handlers other code already calls, rather than duplicating that logic.

One shell for every view. Each tab was a plain white page while the assistant and
the workspace launcher sat on a tiled card, so opening any menu item changed the
whole face of the app. The tiled ground and the card edge now belong to
.main-content, and only the contents differ. The assistant replaces the shell
card rather than nesting a second one inside it.

Search, in both views, from the same palette: the workspace searches the app
menu, the assistant searches saved chats. Both sources are already in memory, so
this needs no new endpoint. Cmd/Ctrl+K opens it, arrows and Enter work, and the
assistant publishes its chats rather than the palette reaching into its
internals.

The collapse icon is now directional angles that rotate when collapsed, so it
always states what the click will do; fa-table-columns said nothing.

Mobile: the sidebar slides in whole, so the in-sidebar collapse control is hidden
there, the account card sticks to the foot of a scrolling menu, and the palette
is full-bleed instead of a centred dialog on a small screen.

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:56:37 +02:00
parent 91699e45cb
commit c7820c5ae6
7 changed files with 301 additions and 79 deletions

View file

@ -4,7 +4,16 @@
<aside class="assistant-history">
<button id="btn-assistant-drawer-close" class="assistant-drawer-close" type="button" aria-label="Close saved chats"><i class="fas fa-xmark"></i></button>
<p class="assistant-preview-note"><i class="fas fa-circle-info"></i> You are previewing. Sign in to save chats, export and generate images.</p>
<button type="button" class="assistant-menu-toggle" data-menu-toggle aria-expanded="true" title="Hide menu" aria-label="Hide menu"><i class="fas fa-bars"></i></button>
<!-- Brand, collapse and search share one row, as in the reference UIs.
Three stacked full-width rows wasted the top of the menu. -->
<div class="menu-head">
<div class="menu-brand">
<i class="fas fa-stethoscope"></i>
<h1>Pediatric Clinical Tools</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>
</div>
<div class="assistant-mode-switch" role="tablist" aria-label="Assistant or workspace">
<button type="button" role="tab" class="active" aria-selected="true" data-assistant-mode="assistant"><i class="fas fa-brain"></i> Assistant</button>
<button type="button" role="tab" aria-selected="false" data-assistant-mode="workspace"><i class="fas fa-grip"></i> Workspace</button>

View file

@ -12,7 +12,7 @@
.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-main { display:flex; flex-direction:column; min-height:0; 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: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); }
@ -278,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 */
/* 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; }
/* No header bar anywhere now, so the layout owns the whole viewport. */
body.assistant-workspace .assistant-layout { height: 100vh; 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 - 52px); display:flex; flex-direction:column; grid-template-rows:none; overflow:hidden; }
body.assistant-workspace .assistant-layout { height:100dvh; display:flex; flex-direction:column; grid-template-rows:none; overflow:hidden; }
}
/* Desktop: the saved-chats rail collapses like ChatGPT's sidebar */
@ -490,7 +490,8 @@ body.assistant-mode-workspace .assistant-rail-workspace { display:flex; }
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; }
background-size:28px 28px; background-position:-1px -1px; background-color:white;
border:1px solid var(--g200); border-radius:14px; box-shadow:var(--shadow); overflow:hidden; }
/* The composer is a card floating on that ground, with room around it rather
than pinned to the panel edge. */

View file

@ -45,31 +45,25 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
/* HEADER */
/* 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:#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);}
/* 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. */
.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-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-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; }
.btn-menu-toggle { display:none; }
@media(max-width:768px){
.btn-menu-toggle { display:inline-flex; align-items:center; justify-content:center; position:fixed; top:10px; left:10px; z-index:150; width:38px; height:38px; border:1px solid var(--g200); border-radius:10px; background:white; color:var(--g700); font-size:15px; box-shadow:var(--shadow); }
}
/* APP BODY LAYOUT */
.app-body{display:flex;min-height:calc(100vh - 52px);}
.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:calc(100vh - 52px);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-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);}
@ -85,7 +79,22 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
/* Sidebar collapse/expand buttons (desktop only) */
/* MAIN */
.main-content{flex:1;min-width:0;padding:20px 20px;}
/* One shell for every view. Each tab used to be a plain white page while the
assistant and the workspace launcher sat on a tiled card, so opening any menu
item changed the whole face of the app. The tiled ground and the card edge
belong to the shell, so only the contents differ. */
.main-content{flex:1;min-width:0;min-height:0;padding:14px;display:flex;flex-direction:column;
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:var(--g50);}
.main-content > .tab-content.active{flex:1 1 auto;min-height:0;background:white;border:1px solid var(--g200);border-radius:14px;box-shadow:var(--shadow);padding:20px;overflow:auto;}
/* The assistant brings its own full-bleed layout, so it takes the shell's place
rather than nesting a second card inside it. */
.main-content > #assistant-tab.active{padding:0;border:none;box-shadow:none;background:transparent;overflow:visible;}
@media(max-width:768px){
.main-content{padding:8px;}
.main-content > .tab-content.active{padding:12px;border-radius:12px;}
.main-content > #assistant-tab.active{padding:0;}
}
.tab-content{display:none;}
.tab-content.active{display:block;animation:fadeIn 0.25s ease;}
@keyframes fadeIn{from{opacity:0;transform:translateY(8px);}to{opacity:1;transform:translateY(0);}}
@ -335,10 +344,21 @@ textarea.full-input{resize:vertical;}
.sidebar.open{left:0;box-shadow:4px 0 20px rgba(0,0,0,0.18);}
.sidebar.open~.sidebar-overlay{display:block;}
.sidebar-header{display:flex;}
/* Hide desktop collapse controls on mobile */
.sidebar-pin-btn{display:none;}
.sidebar-expand-btn{display:none !important;}
.main-content{padding:12px;}
/* The sidebar slides in as a whole on mobile, so a collapse control inside it
has nothing to collapse the overlay close is the way out. */
.assistant-menu-toggle{display:none !important;}
/* Give the drawer room to breathe and keep the account card reachable at the
foot of a scrolling menu. */
.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. */
.menu-search{padding:0;align-items:stretch;}
.menu-search-panel{width:100%;max-height:100%;border:none;border-radius:0;}
.main-content{padding:8px;}
.demographics-bar{flex-direction:column;}
.demo-field input,.demo-field select{min-width:100%;}
.note-meta{flex-direction:column;}
@ -1210,7 +1230,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 - 52px); }
body.assistant-workspace .app-body { min-height:100vh; }
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; }
@ -1246,8 +1266,7 @@ 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:76px; left:10px; z-index:120; margin:0; background:white; box-shadow:var(--shadow); }
body.assistant-workspace.menu-hidden .assistant-menu-toggle { top:10px; }
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; }
@ -1287,3 +1306,25 @@ body.assistant-preview .assistant-preview-note { display:flex; }
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; }
/* Search palette
One component in both views; the view decides what it searches. */
.menu-search { position:fixed; inset:0; z-index:200; background:rgba(15,23,42,.45); display:flex; align-items:flex-start; justify-content:center; padding:12vh 16px 16px; }
.menu-search[hidden] { display:none; }
.menu-search-panel { width:min(640px,100%); max-height:70vh; display:flex; flex-direction:column; background:white; border:1px solid var(--g200); border-radius:14px; box-shadow:0 24px 60px rgba(15,23,42,.28); overflow:hidden; }
.menu-search-head { display:flex; align-items:center; gap:10px; padding:12px 14px; border-bottom:1px solid var(--g100); }
.menu-search-head i { color:var(--g400); font-size:13px; }
.menu-search-head input { flex:1 1 auto; min-width:0; border:none; outline:none; background:none; font-family:inherit; font-size:15px; color:var(--g900); }
.menu-search-head button { border:none; background:none; color:var(--g400); font-size:14px; cursor:pointer; padding:4px; }
.menu-search-head button:hover { color:var(--g700); }
.menu-search-results { flex:1 1 auto; min-height:0; overflow-y:auto; padding:6px; display:flex; flex-direction:column; gap:2px; }
.menu-search-item { display:flex; align-items:center; gap:10px; width:100%; border:none; border-radius:9px; padding:9px 10px; background:none; color:var(--g800); font-family:inherit; font-size:13px; text-align:left; cursor:pointer; }
.menu-search-item i { width:16px; text-align:center; color:var(--g400); font-size:12px; flex:0 0 auto; }
.menu-search-item span { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.menu-search-item em { flex:0 0 auto; font-style:normal; font-size:11px; color:var(--g400); }
.menu-search-item:hover, .menu-search-item.is-active { background:var(--blue-light); color:var(--blue); }
.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; }

View file

@ -149,35 +149,14 @@
<!-- MAIN APP (hidden until authenticated) -->
<div id="main-app" class="hidden">
<!-- HEADER -->
<header class="app-header">
<div class="header-content">
<div class="header-top">
<div style="display:flex;align-items:center;gap:10px;">
<button id="btn-menu-toggle" class="btn-header btn-menu-toggle" title="Menu" aria-label="Toggle menu">
<i class="fas fa-bars"></i>
</button>
<div class="logo">
<i class="fas fa-stethoscope"></i>
<div>
<h1>Pediatric Clinical Tools</h1>
<p class="tagline">Welcome, <span id="user-name">Doctor</span></p>
</div>
</div>
</div>
<div class="header-right">
<div class="header-buttons">
<button id="btn-settings" class="btn-header" title="Settings">
<i class="fas fa-cog"></i>
</button>
<button id="btn-logout" class="btn-header" title="Logout">
<i class="fas fa-right-from-bracket"></i>
</button>
</div>
</div>
</div>
</div>
</header>
<!-- No header bar. Settings and Log out live in the account card at the foot
of the menu, the brand sits at its head, and the mobile menu button
floats — so a whole band of chrome earned its place back as content.
btn-settings and btn-logout stay as the canonical handlers other code
already calls; they are hidden, not duplicated. -->
<button id="btn-menu-toggle" class="btn-menu-toggle" title="Menu" aria-label="Toggle menu"><i class="fas fa-bars"></i></button>
<button id="btn-settings" hidden></button>
<button id="btn-logout" hidden></button>
<!-- ANNOUNCEMENT BANNER -->
<div id="announcement-banner" class="announcement-banner hidden" role="alert">
@ -197,7 +176,16 @@
</button>
</div>
<div class="sidebar-nav">
<button type="button" class="assistant-menu-toggle" data-menu-toggle aria-expanded="true" title="Hide menu" aria-label="Hide menu"><i class="fas fa-bars"></i></button>
<!-- Brand, collapse and search share one row, as in the reference UIs.
Three stacked full-width rows wasted the top of the menu. -->
<div class="menu-head">
<div class="menu-brand">
<i class="fas fa-stethoscope"></i>
<h1>Pediatric Clinical Tools</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>
</div>
<!-- The app IS workspace mode. One switch, shown in both places and
styled identically, so moving between them is a highlight change
rather than a different-looking screen. -->
@ -319,6 +307,19 @@
</div>
</nav>
<!-- One palette, both views. The view decides what it searches: the app menu
here, saved chats in the assistant. -->
<div class="menu-search" id="menu-search" hidden role="dialog" aria-modal="true" aria-label="Search">
<div class="menu-search-panel">
<div class="menu-search-head">
<i class="fas fa-magnifying-glass"></i>
<input type="search" id="menu-search-input" autocomplete="off" placeholder="Search...">
<button type="button" id="menu-search-close" aria-label="Close"><i class="fas fa-xmark"></i></button>
</div>
<div class="menu-search-results" id="menu-search-results" role="listbox"></div>
</div>
</div>
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<main class="main-content">

View file

@ -264,6 +264,122 @@ document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.account-card-btn').forEach(function(b) { b.setAttribute('aria-expanded', 'false'); });
});
// ── Search ──────────────────────────────────────────────────────────────────
// One palette; the view decides what it searches. In the workspace that is the
// app menu, in the assistant it is the saved chats. Both sources are already
// in memory, so this needs no new endpoint.
var searchModal = document.getElementById('menu-search');
var searchInput = document.getElementById('menu-search-input');
var searchResults = document.getElementById('menu-search-results');
function searchSources() {
// The assistant publishes its chats; anything else searches the menu.
if (window.location.pathname === '/assistant' && typeof window.assistantSearchableChats === 'function') {
return { kind: 'chats', items: window.assistantSearchableChats() };
}
return { kind: 'tabs', items: Array.prototype.map.call(document.querySelectorAll('.tab-btn'), function(tab) {
var label = tab.querySelector('span');
var icon = tab.querySelector('i');
return {
id: tab.getAttribute('data-tab'),
title: label ? label.textContent : tab.getAttribute('data-tab'),
icon: icon ? icon.className : 'fas fa-circle',
hidden: tab.classList.contains('hidden')
};
}).filter(function(t) { return t.id && !t.hidden; }) };
}
function renderSearch(term) {
if (!searchResults) return;
var source = searchSources();
var needle = String(term || '').trim().toLowerCase();
var matches = source.items.filter(function(item) {
return !needle || String(item.title || '').toLowerCase().indexOf(needle) !== -1;
}).slice(0, 40);
searchResults.innerHTML = '';
if (!matches.length) {
var empty = document.createElement('p');
empty.className = 'menu-search-empty';
empty.textContent = needle
? 'Nothing matches “' + term + '”.'
: (source.kind === 'chats' ? 'No saved chats yet.' : 'Nothing to search.');
searchResults.appendChild(empty);
return;
}
matches.forEach(function(item, index) {
var row = document.createElement('button');
row.type = 'button';
row.className = 'menu-search-item' + (index === 0 ? ' is-active' : '');
row.setAttribute('role', 'option');
row.setAttribute('data-search-kind', source.kind);
row.setAttribute('data-search-id', item.id);
row.innerHTML = '<i class="' + (item.icon || 'fas fa-message') + '"></i><span></span>' +
(item.meta ? '<em></em>' : '');
row.querySelector('span').textContent = item.title || '';
if (item.meta) row.querySelector('em').textContent = item.meta;
searchResults.appendChild(row);
});
}
function openSearch() {
if (!searchModal) return;
searchModal.hidden = false;
if (searchInput) {
searchInput.value = '';
searchInput.placeholder = searchSources().kind === 'chats' ? 'Search chats...' : 'Search the workspace...';
searchInput.focus();
}
renderSearch('');
}
function closeSearch() { if (searchModal) searchModal.hidden = true; }
function runSearchItem(row) {
if (!row) return;
var kind = row.getAttribute('data-search-kind');
var id = row.getAttribute('data-search-id');
closeSearch();
if (kind === 'chats') {
if (typeof window.assistantOpenChat === 'function') window.assistantOpenChat(id);
return;
}
// A workspace result lives in the app, so reaching one from the assistant
// leaves it — the same rule the account menu follows.
if (window.location.pathname === '/assistant') {
try { localStorage.setItem('ped_last_tab', id); } catch (e) {}
window.location.href = '/';
return;
}
activateTab(id);
}
document.addEventListener('click', function(event) {
if (event.target.closest && event.target.closest('[data-menu-search]')) { openSearch(); return; }
if (event.target.closest && event.target.closest('#menu-search-close')) { closeSearch(); return; }
var row = event.target.closest && event.target.closest('.menu-search-item');
if (row) { runSearchItem(row); return; }
if (searchModal && !searchModal.hidden && event.target === searchModal) closeSearch();
});
if (searchInput) searchInput.addEventListener('input', function() { renderSearch(searchInput.value); });
document.addEventListener('keydown', function(event) {
var key = String(event.key || '').toLowerCase();
if ((event.metaKey || event.ctrlKey) && key === 'k') { event.preventDefault(); openSearch(); return; }
if (!searchModal || searchModal.hidden) return;
if (event.key === 'Escape') { closeSearch(); return; }
var rows = Array.prototype.slice.call(searchResults ? searchResults.querySelectorAll('.menu-search-item') : []);
if (!rows.length) return;
var at = rows.findIndex(function(r) { return r.classList.contains('is-active'); });
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
var next = event.key === 'ArrowDown' ? Math.min(at + 1, rows.length - 1) : Math.max(at - 1, 0);
rows.forEach(function(r) { r.classList.remove('is-active'); });
rows[next].classList.add('is-active');
rows[next].scrollIntoView({ block: 'nearest' });
} else if (event.key === 'Enter') {
event.preventDefault();
runSearchItem(rows[at === -1 ? 0 : at]);
}
});
// Expose activateTab globally so auth.js can call it after login
window.activateTab = activateTab;

View file

@ -2177,6 +2177,20 @@ import {
return groups.sort(function (a, b) { return a.order - b.order; });
}
// Published for the shared search palette: the assistant owns its chats, so it
// exposes them rather than having the palette reach into its internals.
if (typeof window !== 'undefined') window.assistantSearchableChats = function() {
return (savedChatCache || []).map(function(chat) {
return {
id: chat.id,
title: chat.title || 'Saved chat',
icon: isChatPinned(chat.id) ? 'fas fa-thumbtack' : 'fas fa-message',
meta: formatSavedDate(chat.updated_at || chat.created_at)
};
});
};
if (typeof window !== 'undefined') window.assistantOpenChat = function(id) { loadSavedChat(id); };
function renderSavedChats(chats) {
savedChatCache = chats || [];
var wrap = document.getElementById('assistant-saved-chats');

View file

@ -175,12 +175,16 @@ test('the menu toggle uses an icon that reads as a menu', () => {
const markup = read(file);
const button = markup.split('\n').find(l => l.includes('data-menu-toggle'));
assert.ok(button, file + ' has the toggle');
// fa-table-columns draws a split-pane glyph, which does not say "menu".
assert.doesNotMatch(button, /fa-table-columns/, file + ' uses a menu icon');
assert.match(button, /fa-bars/, file);
// fa-table-columns draws a split-pane glyph that says nothing about what the
// click does. Angles point the way the panel moves.
assert.doesNotMatch(button, /fa-table-columns/, file + ' avoids the split-pane glyph');
assert.match(button, /fa-angles-left/, file);
}
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');
});
test('workspace mode does not override the mobile layout with a desktop header offset', () => {
@ -191,8 +195,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 - 52px);');
const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: calc(100vh - 52px)');
const override = css.indexOf('body.assistant-workspace .assistant-layout { height:100dvh;');
const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: 100vh');
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));
@ -386,14 +390,50 @@ test('one menu, ending the same way in both views', () => {
'a preview visitor has no account to show');
});
test('the top bar is slim and quiet, not a coloured band', () => {
test('there is no header bar; the brand heads the menu instead', () => {
const index = read('public/index.html');
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\)/);
// Settings and Log out moved to the account card, so a whole band of chrome
// had nothing left to hold and became content.
assert.doesNotMatch(index, /<header class="app-header">/, 'no header element');
assert.doesNotMatch(css, /^\.app-header\{/m, 'and no header styling');
for (const [markup, where] of [[index, 'app menu'], [read('public/components/assistant.html'), 'assistant rail']]) {
assert.match(markup, /class="menu-brand"/, where + ' carries the brand');
}
// The canonical handlers stay so nothing that calls them breaks.
assert.match(index, /id="btn-logout" hidden/, 'logout handler retained, hidden');
assert.match(index, /id="btn-settings" hidden/);
// Nothing sits above the content, so no layout should still subtract a header.
for (const stale of [/calc\(100vh - 66px\)/, /calc\(100vh - 52px\)/, /calc\(100dvh - \d+px\)/]) {
assert.doesNotMatch(css, stale, 'no stale header offset in styles.css');
assert.doesNotMatch(read('public/css/assistant.css'), stale, 'none in assistant.css');
}
assert.match(read('public/css/assistant.css'), /body\.assistant-workspace \.assistant-layout \{ height: 100vh;/,
'the layout owns the full viewport');
});
test('every view sits in the same shell, so opening a menu item is not a new app', () => {
const css = read('public/css/styles.css');
// Each tab used to be a plain white page while the assistant and the workspace
// launcher sat on a tiled card, so any menu click changed the whole face.
assert.match(css, /\.main-content\{[^}]*background-image:linear-gradient/,
'the tiled ground belongs to the shell');
assert.match(css, /\.main-content > \.tab-content\.active\{[^}]*border:1px solid var\(--g200\);border-radius:14px/,
'and every tab gets the same card edge');
// The assistant brings a full-bleed layout, so it replaces the shell card
// rather than nesting a second one inside it.
assert.match(css, /\.main-content > #assistant-tab\.active\{padding:0;border:none/);
assert.match(read('public/css/assistant.css'), /\.assistant-main\.card \{[\s\S]{0,220}?border:1px solid var\(--g200\); border-radius:14px/,
'the assistant panel carries the matching edge itself');
});
test('the new chrome is handled on phones', () => {
const css = read('public/css/styles.css');
const mobile = css.slice(css.indexOf('@media(max-width:768px)'));
// The sidebar slides in whole on mobile, so an in-sidebar collapse control has
// nothing to collapse.
assert.match(mobile, /\.assistant-menu-toggle\{display:none !important;\}/);
assert.match(mobile, /\.account-card\{position:sticky;bottom:0/, 'the account card stays reachable');
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');
});