fix: stop iOS zoom, compact model control, quieter composer, better search

iOS zoom (and the menu button scrolling away with it)
- Safari zooms the whole page when a text field under 16px takes focus and
  never zooms back; while zoomed, fixed chrome sits outside the visual
  viewport, which is why the menu button had to be scrolled to. All 144
  visible text controls were 13-15px. They are 16px on phones now.
  !important is deliberate: per-component rules like `.demo-field select`
  outrank any element selector, and a new component would slip through.
- The composer set 15px AFTER its own mobile 16px rule, so at equal
  specificity the base rule won; the size is per breakpoint now.
- Pinch-zoom is untouched: no user-scalable=no. Desktop unchanged (13/15px).

Composer
- "Ready" / "Looking up sources..." / "saved" repeated what the send button
  already shows by turning into Stop, and on a phone they wrapped under the
  model control. Kept in the DOM as live regions; nothing is drawn.
- The model control shows the word "Model" (76px) instead of ids as long as
  "openrouter-gemini-3.1-flash-image-preview", and opens the full list on
  click. The select stays as the state holder, so choosing still saves
  through the same change event.

Search
- Nothing typed lists the 19 destinations only; sub-items appear once there
  is something to match.
- Listing, matching and opening now read one attribute list instead of three
  that could drift, and bedside's emergency sections are searchable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-10 15:26:43 +02:00
parent 609305538a
commit cf7a3161a8
7 changed files with 188 additions and 10 deletions

View file

@ -95,8 +95,16 @@
<span class="assistant-status" id="assistant-status"><span class="assistant-dot"></span><span id="assistant-status-text">Ready</span></span>
</div>
<div class="assistant-composer-right">
<!-- The select stays as the state holder (it persists the
choice and holds the allowed list); the button is its
compact face, so the composer is not as wide as the
longest model id. -->
<div class="assistant-model-pill" id="assistant-model-pill" hidden>
<select id="assistant-chat-model-select" class="assistant-model-control" aria-label="Chat model" hidden></select>
<select id="assistant-chat-model-select" class="assistant-model-control assistant-model-state" aria-label="Chat model" tabindex="-1" aria-hidden="true"></select>
<button type="button" id="btn-assistant-model" class="assistant-model-btn" aria-haspopup="listbox" aria-expanded="false" aria-controls="assistant-model-menu" title="Choose the chat model">
<i class="fas fa-microchip"></i> <span>Model</span>
</button>
<div id="assistant-model-menu" class="assistant-model-menu" role="listbox" aria-label="Chat model" hidden></div>
</div>
<button id="btn-assistant-voice" class="assistant-voice-btn" type="button" title="Voice conversation"><i class="fas fa-headset"></i></button>
<button id="btn-assistant-mic" class="assistant-mic" type="button" title="Dictate your question"><i class="fas fa-microphone"></i></button>

View file

@ -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; }

View file

@ -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

View file

@ -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) {

View file

@ -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;

View file

@ -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; \} \}/);
});

View file

@ -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, /<select id="assistant-chat-model-select"[^>]*assistant-model-state/);
assert.match(html, /<button type="button" id="btn-assistant-model"[^>]*aria-haspopup="listbox"/);
assert.match(html, /<span>Model<\/span>/);
assert.match(css, /\.assistant-model-state \{ position:absolute; width:1px;/);
assert.match(css, /\.assistant-model-menu \{ position:absolute; right:0; bottom:calc\(100% \+ 6px\)/, 'opens upward, above the composer');
assert.match(js, /function syncModelMenu\(\)/);
assert.match(js, /changed\.initEvent\('change', true, false\);/, 'choosing goes through the select, so it saves the usual way');
// "Ready" / "Looking up sources..." / "saved" repeated what the send button
// already shows, and wrapped under the model control on a phone.
assert.match(css, /\.assistant-status, \.assistant-autosave-state \{ position:absolute; width:1px;/);
assert.match(html, /id="assistant-status"/, 'kept in the DOM so screen readers still hear progress');
});
test('the search palette lists destinations first, sub-items only once typing', () => {
const app = read('public/js/app.js');
const fn = app.slice(app.indexOf('function renderSearch(term)'), app.indexOf('function openSearch()'));
assert.match(fn, /if \(!needle\) return !item\.sub;/, 'nothing typed: main destinations only');
assert.match(fn, /return String\(item\.title \|\| ''\)\.toLowerCase\(\)\.indexOf\(needle\) !== -1;/);
});