diff --git a/public/js/app.js b/public/js/app.js index 8d3de73c..0a90a877 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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 = '' + + (item.meta ? '' : ''); + 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; diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 1fd93436..97c196d0 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -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'); diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js index 9c256443..0b22148b 100644 --- a/test/assistant-workspace-layout.test.js +++ b/test/assistant-workspace-layout.test.js @@ -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, /
/, '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'); });