feat: search finds what is inside a tab, and the menu head reads toggle, mark, search

Search now reaches sub-navigation, because people look for "bili" rather than
"Calculators". It reads whatever a loaded component exposes — data-calc,
data-subtab, data-section — so it covers every tab with sub-navigation instead of
one hard-coded list, and the relevant components are warmed when the palette
opens. Opening a result survives the component still loading, and reaching one
from the assistant navigates first and opens it after.

Menu head order is now show/hide menu, then the mark, then search. In the
collapsed rail every item is the same centred 52px box, so the icons finally
share one axis — the brand was a flex row with a gap and sat off-centre from the
buttons beneath it.

Settings, FAQ and Admin are no longer listed in the menu: they already have a
place in the account card, and listing them twice only made the tab list longer.
Their sections remain, and activateTab already tolerates a tab whose section
exists without a sidebar button.

The retry loops are named functions rather than IIFEs, which the module
entrypoint rules forbid.

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 05:16:05 +02:00
parent 2d6eaccd96
commit 531996de1e
5 changed files with 121 additions and 27 deletions

View file

@ -7,12 +7,12 @@
<!-- 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">
<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 class="menu-brand">
<i class="fas fa-stethoscope"></i>
<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>
</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>

View file

@ -49,7 +49,8 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
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-head .menu-icon-btn:first-of-type { margin-left:auto; }
/* Toggle leads, the mark takes the slack, search closes the row. */
.menu-brand { margin-right: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; }
@ -1277,9 +1278,12 @@ 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 .menu-head { flex-direction:column; gap:4px; padding:10px 0 8px; align-items:stretch; }
/* Every item is the same 52px-wide centred box, so the icons share one axis. */
body.menu-hidden .menu-head > * { width:100%; margin:0; justify-content:center; }
body.menu-hidden .menu-brand { display:flex; align-items:center; justify-content:center; height:32px; gap:0; }
body.menu-hidden .menu-brand i { font-size:19px; width:auto; }
body.menu-hidden .menu-icon-btn { width:32px; height:32px; margin:0 auto; }
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; }

View file

@ -178,12 +178,12 @@
<!-- 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">
<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 class="menu-brand">
<i class="fas fa-stethoscope"></i>
<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>
</div>
<!-- The app IS workspace mode. One switch, shown in both places and
styled identically, so moving between them is a highlight change
@ -272,19 +272,6 @@
<i class="fas fa-book"></i>
<span>Docs</span>
</button>
<span class="sidebar-section-label">Account</span>
<button class="tab-btn" data-tab="settings">
<i class="fas fa-cog"></i>
<span>Settings</span>
</button>
<button class="tab-btn" data-tab="faq">
<i class="fas fa-circle-question"></i>
<span>FAQ</span>
</button>
<button class="tab-btn hidden" data-tab="admin" id="admin-tab-btn">
<i class="fas fa-user-shield"></i>
<span>Admin</span>
</button>
</div>
</div>
<!-- Account card. The same markup renders in the assistant rail, so the

View file

@ -272,21 +272,59 @@ document.addEventListener('DOMContentLoaded', function() {
var searchInput = document.getElementById('menu-search-input');
var searchResults = document.getElementById('menu-search-results');
// Sub-navigation is only present once its component has loaded, so give it a
// moment rather than clicking into an empty tab.
function openSubItem(sub, tabName, attempts) {
var host = tabName ? document.getElementById(tabName + '-tab') : document;
var selector = '[data-calc="' + sub + '"], [data-subtab="' + sub + '"], [data-section="' + sub + '"]';
var target = host && host.querySelector(selector);
if (target) { target.click(); return; }
if (attempts > 0) setTimeout(function() { openSubItem(sub, tabName, attempts - 1); }, 110);
}
// Sub-navigation inside a tab — the calculator pills, the well-visit ages, the
// exam regions — is what people actually look for ("bili", not "Calculators").
// Read generically from whatever a loaded component exposes, so this covers
// every tab that has sub-navigation rather than one hard-coded list.
var SUB_NAV_SELECTOR = '[data-calc], [data-subtab], [data-section], .calc-nav-pill';
function subItemsFor(tabEl, tabName, tabTitle) {
if (!tabEl || !tabEl.dataset.loaded) return []; // not loaded yet; nothing to read
var seen = {};
return Array.prototype.map.call(tabEl.querySelectorAll(SUB_NAV_SELECTOR), function(node) {
var key = node.getAttribute('data-calc') || node.getAttribute('data-subtab') || node.getAttribute('data-section');
var title = (node.textContent || '').trim();
if (!key || !title || title.length > 48 || seen[key + title]) return null;
seen[key + title] = true;
return { id: tabName, sub: key, title: title, meta: tabTitle, icon: 'fas fa-arrow-turn-up fa-rotate-90' };
}).filter(Boolean);
}
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 items = [];
Array.prototype.forEach.call(document.querySelectorAll('.tab-btn'), function(tab) {
var name = tab.getAttribute('data-tab');
if (!name || tab.classList.contains('hidden')) return;
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; }) };
var title = label ? label.textContent : name;
items.push({ id: name, title: title, icon: icon ? icon.className : 'fas fa-circle' });
items = items.concat(subItemsFor(document.getElementById(name + '-tab'), name, title));
});
return { kind: 'tabs', items: items };
}
// A component only exposes its sub-navigation once loaded, so warm the tabs
// that have any. Cheap: each is fetched once and cached by the loader.
function warmSearchableTabs() {
['calculators', 'wellvisit', 'peguide', 'vaxschedule'].forEach(function(name) {
var el = document.getElementById(name + '-tab');
if (el && el.hasAttribute('data-component') && !el.dataset.loaded) loadComponent(el);
});
}
function renderSearch(term) {
@ -313,6 +351,7 @@ document.addEventListener('DOMContentLoaded', function() {
row.setAttribute('role', 'option');
row.setAttribute('data-search-kind', source.kind);
row.setAttribute('data-search-id', item.id);
if (item.sub) row.setAttribute('data-search-sub', item.sub);
row.innerHTML = '<i class="' + (item.icon || 'fas fa-message') + '"></i><span></span>' +
(item.meta ? '<em></em>' : '');
row.querySelector('span').textContent = item.title || '';
@ -324,6 +363,7 @@ document.addEventListener('DOMContentLoaded', function() {
function openSearch() {
if (!searchModal) return;
searchModal.hidden = false;
warmSearchableTabs();
if (searchInput) {
searchInput.value = '';
searchInput.placeholder = searchSources().kind === 'chats' ? 'Search chats...' : 'Search the workspace...';
@ -342,6 +382,20 @@ document.addEventListener('DOMContentLoaded', function() {
if (typeof window.assistantOpenChat === 'function') window.assistantOpenChat(id);
return;
}
// A sub-item opens its tab and then activates the entry inside it.
var sub = row.getAttribute('data-search-sub');
if (sub) {
if (window.location.pathname === '/assistant') {
try { localStorage.setItem('ped_last_tab', id); localStorage.setItem('ped_pending_sub', sub); } catch (e) {}
window.location.href = '/';
return;
}
activateTab(id);
// The component may still be loading, so retry briefly rather than
// clicking into an empty tab.
openSubItem(sub, id, 20);
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') {
@ -380,6 +434,16 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// A sub-item chosen from the assistant navigates here first; open it once the
// component it lives in is ready.
try {
var pendingSub = localStorage.getItem('ped_pending_sub');
if (pendingSub) {
localStorage.removeItem('ped_pending_sub');
openSubItem(pendingSub, null, 30);
}
} catch (e) {}
// Expose activateTab globally so auth.js can call it after login
window.activateTab = activateTab;

View file

@ -491,3 +491,42 @@ test('the menu can always be reopened after it is hidden', () => {
kept + ' stays visible in the collapsed rail');
}
});
test('search reaches the things inside a tab, not just the tab', () => {
const app = read('public/js/app.js');
// People look for "bili", not "Calculators". Sub-navigation is read
// generically from whatever a loaded component exposes, so this covers every
// tab with sub-navigation rather than one hard-coded list.
assert.match(app, /SUB_NAV_SELECTOR = '\[data-calc\], \[data-subtab\], \[data-section\], \.calc-nav-pill'/);
assert.match(app, /function subItemsFor\(/);
assert.match(app, /if \(!tabEl \|\| !tabEl\.dataset\.loaded\) return \[\];/,
'an unloaded component exposes nothing, so it is skipped rather than guessed at');
assert.match(app, /function warmSearchableTabs\(/, 'and those components are warmed when the palette opens');
// Opening one has to survive the component still loading.
assert.match(app, /function openSubItem\(sub, tabName, attempts\)/);
assert.match(app, /if \(attempts > 0\) setTimeout/, 'it retries rather than clicking into an empty tab');
// Reached from the assistant, it has to navigate first and open after.
assert.match(app, /localStorage\.setItem\('ped_pending_sub', sub\)/);
const calculators = read('public/components/calculators.html');
assert.match(calculators, /data-calc="bili"/, 'the labels search reads are real');
});
test('the menu head reads toggle, mark, search — and account tabs are not listed twice', () => {
for (const file of ['public/index.html', 'public/components/assistant.html']) {
const markup = read(file);
const head = markup.slice(markup.indexOf('<div class="menu-head">'), markup.indexOf('assistant-mode-switch'));
const order = [...head.matchAll(/assistant-menu-toggle|menu-brand|data-menu-search/g)].map(m => m[0]);
assert.deepEqual(order, ['assistant-menu-toggle', 'menu-brand', 'data-menu-search'],
file + ': show/hide menu first, then the mark, then search');
}
// Settings, FAQ and Admin live in the account card; listing them in the menu
// too only made the tab list longer.
const index = read('public/index.html');
for (const tab of ['settings', 'faq', 'admin']) {
assert.doesNotMatch(index, new RegExp('class="tab-btn[^"]*" data-tab="' + tab + '"'), tab + ' is not in the menu list');
assert.match(index, new RegExp('id="' + tab + '-tab"'), tab + ' still has its section');
assert.match(index, new RegExp('data-account-tab="' + tab + '"|role="menuitem"'), tab + ' is reachable from the account card');
}
});