diff --git a/public/components/assistant.html b/public/components/assistant.html index 05eb8731..611d3340 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -95,8 +95,16 @@ Ready
+ diff --git a/public/css/assistant.css b/public/css/assistant.css index d1339ce2..43294b6a 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -195,7 +195,7 @@ .assistant-mobile-menu { display:inline-flex; } #btn-assistant-goback span, #btn-assistant-goback { font-size:0; } #btn-assistant-goback { padding:8px; } - .assistant-status { margin-left:auto; font-size:11px; } + .assistant-status, .assistant-autosave-state { position:absolute; width:1px; height:1px; margin:-1px; padding:0; border:0; overflow:hidden; clip-path:inset(50%); white-space:nowrap; } /* The assistant fills the viewport exactly; nothing behind can scroll */ .assistant-layout { display:flex; flex-direction:column; height:calc(100dvh - var(--mobile-bar, 0px)); min-height:0; overflow:hidden; } .assistant-main { flex:1 1 auto; min-height:0; display:flex; } @@ -361,7 +361,11 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; } /* Open WebUI-style composer: a prominent input box with a slim action row below */ .assistant-composer { border:1px solid var(--g200); border-radius:20px; box-shadow:0 8px 30px rgba(0,0,0,.08); padding:10px 14px 6px; display:flex; flex-direction:column; gap:2px; } .assistant-composer > label { display:none; } -#assistant-input { flex:1 1 auto; border:none; outline:none; resize:none; background:transparent; font-size:15px; line-height:1.5; min-height:56px; max-height:180px; padding:6px 0; } +/* 15px is the desktop size. The phone override lives earlier in this file, so + at equal specificity this rule used to win and iOS zoomed on focus; the size + is set per breakpoint here instead. */ +#assistant-input { flex:1 1 auto; border:none; outline:none; resize:none; background:transparent; line-height:1.5; min-height:56px; max-height:180px; padding:6px 0; } +@media (min-width:641px) { #assistant-input { font-size:15px; } } .assistant-composer-footer { display:flex; align-items:center; gap:2px; border-top:1px solid var(--g100); padding-top:4px; margin-top:2px; } .assistant-composer-footer .assistant-tools { display:flex; align-items:center; gap:2px; margin-right:auto; } .assistant-composer-footer .assistant-attach, .assistant-composer-footer .assistant-voice-btn, .assistant-composer-footer .assistant-mic { width:30px; height:30px; font-size:14px; } @@ -403,8 +407,20 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; } .create-image-history { display:grid; grid-template-columns:repeat(auto-fill,minmax(72px,1fr)); gap:8px; max-height:42vh; overflow-y:auto; margin-top:8px; } .assistant-gallery-running { display:flex; align-items:center; justify-content:center; min-height:72px; border:1px dashed var(--g300); border-radius:8px; color:var(--g500); } /* Composer model pill (Open WebUI-style, its own line above the actions) */ -.assistant-model-pill { display:flex; align-items:center; } +.assistant-model-pill { position:relative; display:flex; align-items:center; } .assistant-model-pill[hidden] { display:none; } +/* The select is the state holder only; the button is what people see. */ +.assistant-model-state { position:absolute; width:1px; height:1px; margin:-1px; padding:0; border:0; overflow:hidden; clip-path:inset(50%); } +.assistant-model-btn { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--g200); background:white; border-radius:999px; padding:5px 11px; font-family:inherit; font-size:11.5px; font-weight:600; color:var(--g600); cursor:pointer; white-space:nowrap; box-shadow:var(--shadow); } +.assistant-model-btn:hover, .assistant-model-btn[aria-expanded="true"] { border-color:var(--blue-light); background:var(--blue-light); color:var(--blue); } +.assistant-model-btn i { font-size:11px; } +/* Opens upward: the composer sits at the foot of the view. */ +.assistant-model-menu { position:absolute; right:0; bottom:calc(100% + 6px); z-index:40; min-width:220px; max-width:min(320px,80vw); max-height:min(320px,50vh); overflow-y:auto; 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); } +.assistant-model-menu[hidden] { display:none; } +.assistant-model-option { display:flex; align-items:center; gap:8px; width:100%; border:0; border-radius:8px; padding:8px 10px; background:none; color:var(--g700); font-family:inherit; font-size:12.5px; text-align:left; cursor:pointer; overflow-wrap:anywhere; } +.assistant-model-option:hover { background:var(--blue-light); color:var(--blue); } +.assistant-model-option i { width:12px; flex:0 0 auto; font-size:10px; color:var(--blue); } +.assistant-model-option[aria-selected="false"] i { visibility:hidden; } /* Model choice reads as a control, not a raw form element: the native chevron is replaced with one that matches the composer, and the whole pill responds. */ .assistant-model-control { appearance:none; -webkit-appearance:none; border:1px solid var(--g200); background:white; border-radius:999px; padding:5px 26px 5px 11px; font-family:inherit; font-size:11.5px; font-weight:600; color:var(--g600); cursor:pointer; max-width:240px; text-overflow:ellipsis; box-shadow:var(--shadow); @@ -584,3 +600,8 @@ body.assistant-mode-workspace .assistant-rail-workspace { display:flex; } /* Workspace links: the app's .tab-btn, full-bleed with the same inset. */ body.assistant-workspace .assistant-rail-link { padding:10px 16px; } } + +/* The composer says it is working through the send button, which becomes Stop. + The status pill and the autosave word repeated that beside the model control, + so they are announced but not drawn. */ +.assistant-status, .assistant-autosave-state { position:absolute; width:1px; height:1px; margin:-1px; padding:0; border:0; overflow:hidden; clip-path:inset(50%); white-space:nowrap; } diff --git a/public/css/styles.css b/public/css/styles.css index 7f9af4e2..cca27541 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1276,6 +1276,21 @@ 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; } +/* iOS Safari zooms the whole page when a text field smaller than 16px takes + focus, and never zooms back out — which is why the menu button ended up off + screen after typing. 16px is the threshold, so every text control gets it on + a phone. This is what keeps other chat apps from zooming; no user-scalable=no, + which would also block deliberate pinch-zoom. */ +/* !important is deliberate: roughly 144 controls across twenty components set + their own font-size through class rules such as `.demo-field select`, which + outrank any plain element selector. This is one platform workaround, not a + style choice, so it overrides them all rather than being restated per + component (and per new component added later). */ +@media (max-width:768px) { + input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]), + textarea, select { font-size:16px !important; } +} + /* ── Shared app chrome: the Assistant/Workspace switch and the menu toggle ──── These render in BOTH the app sidebar and the assistant rail. They used to live in assistant.css, which only the assistant component loads — so on the main diff --git a/public/js/app.js b/public/js/app.js index 408f18d6..7fdfc8f0 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -289,7 +289,7 @@ document.addEventListener('DOMContentLoaded', function() { // 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 selector = SUB_NAV_ATTRS.map(function(attr) { return '[' + attr + '="' + sub + '"]'; }).join(', '); var target = host && host.querySelector(selector); if (target) { target.click(); return; } if (attempts > 0) setTimeout(function() { openSubItem(sub, tabName, attempts - 1); }, 110); @@ -299,13 +299,16 @@ document.addEventListener('DOMContentLoaded', function() { // 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'; + // One list, so what is listed, what is searched and what a result opens can + // never drift apart. data-em is the bedside emergency sections. + var SUB_NAV_ATTRS = ['data-calc', 'data-subtab', 'data-section', 'data-em']; + var SUB_NAV_SELECTOR = SUB_NAV_ATTRS.map(function(attr) { return '[' + attr + ']'; }).join(', ') + ', .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 key = SUB_NAV_ATTRS.reduce(function(found, attr) { return found || node.getAttribute(attr); }, null); var title = (node.textContent || '').trim(); if (!key || !title || title.length > 48 || seen[key + title]) return null; seen[key + title] = true; @@ -338,7 +341,7 @@ document.addEventListener('DOMContentLoaded', function() { // 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) { + ['calculators', 'wellvisit', 'peguide', 'vaxschedule', 'bedside'].forEach(function(name) { var el = document.getElementById(name + '-tab'); if (el && el.hasAttribute('data-component') && !el.dataset.loaded) loadComponent(el); }); @@ -348,8 +351,12 @@ document.addEventListener('DOMContentLoaded', function() { if (!searchResults) return; var source = searchSources(); var needle = String(term || '').trim().toLowerCase(); + // With nothing typed the palette lists the main destinations only; the + // sub-items (individual calculators, exam sections, schedule tabs) would + // bury them, so they appear once there is something to match. var matches = source.items.filter(function(item) { - return !needle || String(item.title || '').toLowerCase().indexOf(needle) !== -1; + if (!needle) return !item.sub; + return String(item.title || '').toLowerCase().indexOf(needle) !== -1; }).slice(0, 40); searchResults.innerHTML = ''; if (!matches.length) { diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 1575256f..f2d67206 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -1560,8 +1560,83 @@ import { select.hidden = !show; var pill = document.getElementById('assistant-model-pill'); if (pill) pill.hidden = !show; + if (select.id === 'assistant-chat-model-select') syncModelMenu(); + } + + // The composer shows the word "Model", not the model id, which can be as long + // as "openrouter-gemini-3.1-flash-image-preview". The list behind it is built + // from the select above, which stays the state holder, so choosing here goes + // through the same change event that persists every other selection. + function syncModelMenu() { + var select = document.getElementById('assistant-chat-model-select'); + var menu = document.getElementById('assistant-model-menu'); + var button = document.getElementById('btn-assistant-model'); + if (!select || !menu || !button) return; + menu.innerHTML = ''; + Array.prototype.forEach.call(select.options, function(option) { + var row = document.createElement('button'); + row.type = 'button'; + row.className = 'assistant-model-option'; + row.setAttribute('role', 'option'); + row.setAttribute('data-model-value', option.value); + row.setAttribute('aria-selected', option.value === select.value ? 'true' : 'false'); + var tick = document.createElement('i'); + tick.className = 'fas fa-check'; + var text = document.createElement('span'); + text.textContent = option.textContent; + row.appendChild(tick); + row.appendChild(text); + menu.appendChild(row); + }); + var current = select.options[select.selectedIndex]; + button.title = current ? 'Chat model: ' + current.textContent : 'Choose the chat model'; + } + + function closeModelMenu() { + var menu = document.getElementById('assistant-model-menu'); + var button = document.getElementById('btn-assistant-model'); + if (menu) menu.hidden = true; + if (button) button.setAttribute('aria-expanded', 'false'); + } + + function bindModelMenu() { + var docEl = typeof document !== 'undefined' ? document.documentElement : null; + if (docEl && docEl.dataset && docEl.dataset.modelMenuBound) return; + if (docEl && docEl.dataset) docEl.dataset.modelMenuBound = '1'; + document.addEventListener('click', function(event) { + var target = event.target; + if (!target || !target.closest) return; + var option = target.closest('.assistant-model-option'); + if (option) { + var select = document.getElementById('assistant-chat-model-select'); + if (select) { + select.value = option.getAttribute('data-model-value'); + // The delegated [data-model-select-kind] listener does the saving. + var changed = document.createEvent('Event'); + changed.initEvent('change', true, false); + select.dispatchEvent(changed); + syncModelMenu(); + } + closeModelMenu(); + return; + } + if (target.closest('#btn-assistant-model')) { + var menu = document.getElementById('assistant-model-menu'); + var button = document.getElementById('btn-assistant-model'); + if (!menu || !button) return; + var opening = menu.hidden; + menu.hidden = !opening; + button.setAttribute('aria-expanded', opening ? 'true' : 'false'); + return; + } + if (!target.closest('.assistant-model-pill')) closeModelMenu(); + }); + document.addEventListener('keydown', function(event) { + if (event.key === 'Escape') closeModelMenu(); + }); } function bindModelSelects() { + bindModelMenu(); // Delegated persistence: any chat/image model select saves immediately, // even when the popup recreates its element. var docEl = typeof document !== 'undefined' ? document.documentElement : null; diff --git a/test/assistant-mobile.test.js b/test/assistant-mobile.test.js index 7f130ec7..0342715a 100644 --- a/test/assistant-mobile.test.js +++ b/test/assistant-mobile.test.js @@ -169,3 +169,20 @@ test('the phone top bar is solid behind the status bar too', () => { assert.match(css, /\.sidebar\{ width:min\(85vw,320px\); left:calc\(-1 \* min\(85vw,320px\)\); padding:env\(safe-area-inset-top, 0px\) 0 0; \}/); assert.match(read('public/css/assistant.css'), /\.assistant-sources-fab \{[^}]*top:calc\(8px \+ env\(safe-area-inset-top, 0px\)\)/); }); + +test('no text control is small enough to make iOS zoom the page', () => { + const css = read('public/css/styles.css'); + // iOS Safari zooms the whole page when a field under 16px takes focus and + // never zooms back, which is why fixed chrome (the menu button) ended up off + // screen after typing. 16px is the threshold. + const rule = css.slice(css.indexOf('/* iOS Safari zooms the whole page')); + assert.match(rule, /@media \(max-width:768px\) \{\s*\n\s*input:not\(\[type="checkbox"\]\)/); + assert.match(rule, /textarea, select \{ font-size:16px !important; \}/, + 'it must beat per-component rules such as .demo-field select'); + // Pinch-zoom stays available: the fix is the font size, not a locked viewport. + assert.doesNotMatch(read('public/index.html'), /user-scalable=no|maximum-scale=1/); + // The composer set 15px AFTER its own mobile override, so the base rule won. + const assistant = read('public/css/assistant.css'); + assert.doesNotMatch(assistant, /#assistant-input \{ flex:1 1 auto;[^}]*font-size:15px/); + assert.match(assistant, /@media \(min-width:641px\) \{ #assistant-input \{ font-size:15px; \} \}/); +}); diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js index 6e791fa1..9d01d13c 100644 --- a/test/assistant-workspace-layout.test.js +++ b/test/assistant-workspace-layout.test.js @@ -515,7 +515,11 @@ test('search reaches the things inside a tab, not just the tab', () => { // 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'/); + // Listing, matching and opening all read one attribute list, so they cannot + // drift apart when a component's sub-navigation is added. + assert.match(app, /var SUB_NAV_ATTRS = \['data-calc', 'data-subtab', 'data-section', 'data-em'\];/); + assert.match(app, /var SUB_NAV_SELECTOR = SUB_NAV_ATTRS\.map\(function\(attr\) \{ return '\[' \+ attr \+ '\]'; \}\)\.join\(', '\) \+ ', \.calc-nav-pill';/); + assert.match(app, /var selector = SUB_NAV_ATTRS\.map\(function\(attr\) \{ return '\[' \+ attr \+ '="' \+ sub \+ '"\]'; \}\)\.join\(', '\);/); 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'); @@ -529,6 +533,9 @@ test('search reaches the things inside a tab, not just the tab', () => { const calculators = read('public/components/calculators.html'); assert.match(calculators, /data-calc="bili"/, 'the labels search reads are real'); + // Bedside marks its emergency sections with data-em, so they are searchable too. + assert.match(read('public/components/bedside.html'), /data-em="sepsis"/); + assert.match(app, /\['calculators', 'wellvisit', 'peguide', 'vaxschedule', 'bedside'\]/); }); test('the menu head reads toggle, mark, search — and account tabs are not listed twice', () => { @@ -610,3 +617,31 @@ test('the create-image chat list never grows wider than its dialog', () => { assert.match(fn, /words\[i\]/, 'cuts on whole words'); assert.match(js, /window\.addEventListener\('resize', function \(\) \{ fitSelectOptions\(document\.getElementById\('create-image-chat'\)\); \}\);/); }); + +test('the composer shows "Model", not the model id, and hides the status chatter', () => { + const html = read('public/components/assistant.html'); + const css = read('public/css/assistant.css'); + const js = read('public/js/clinicalAssistant.js'); + + // The select stays as the state holder, so the existing change listener still + // persists the choice; the button is only its compact face. + assert.match(html, /