fix: one mobile menu, and sources reachable on a phone
Three faults, all visible on a phone at once. Two hamburgers, neither useful. The assistant added a drawer button of its own AND the app's button was still there, stacked on top of it. Worse, the assistant hid the app sidebar unconditionally, so the app's button opened something invisible. A phone now has exactly one menu — the app sidebar — and the assistant only replaces it with its own rail on desktop. The collapse control had nothing to collapse on a phone, where the menu is a sheet rather than a rail. It closes the sheet instead. Sources were `display:none` below 640px with no alternative, so every SRC chip in an answer pointed at nothing. They now slide up as a dismissible sheet, opened by a button that appears only when an answer actually has citations, or by tapping a citation itself. Tapping away or pressing Escape closes it — a sheet with no way out is a trap. The menu itself is sized for a thumb, borrowing the reference layout: a wide sheet, close and search leading, tappable rows, and padding clear of the home indicator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0161xNW1z4vPusrXKGWcQdQu
This commit is contained in:
parent
500fe2c12a
commit
2c9c2d12c0
6 changed files with 152 additions and 8 deletions
|
|
@ -1,5 +1,4 @@
|
|||
<div class="assistant-layout" id="assistant-layout">
|
||||
<button id="btn-assistant-mobile-menu" class="assistant-mobile-menu" type="button" title="Saved chats" aria-label="Saved chats"><i class="fas fa-bars"></i></button>
|
||||
<div class="assistant-drawer-backdrop" id="assistant-drawer-backdrop"></div>
|
||||
<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>
|
||||
|
|
@ -113,6 +112,9 @@
|
|||
|
||||
</section>
|
||||
|
||||
<button type="button" id="btn-assistant-sources" class="assistant-sources-fab" hidden aria-expanded="false" aria-controls="assistant-sources">
|
||||
<i class="fas fa-quote-right"></i> <span id="assistant-sources-count">Sources</span>
|
||||
</button>
|
||||
<aside class="assistant-side">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-quote-right"></i> Sources</h3></div>
|
||||
|
|
|
|||
|
|
@ -198,7 +198,15 @@
|
|||
.assistant-main.card { box-shadow:none; border:none; margin:0; }
|
||||
#assistant-chat-view { flex:1 1 auto; min-height:0; height:auto; display:flex; flex-direction:column; }
|
||||
.assistant-messages { flex:1 1 auto; min-height:0; max-height:none; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:8px 14px 12px; }
|
||||
.assistant-side { display:none; }
|
||||
/* Sources are not deleted on a phone; they slide up as a sheet, because a
|
||||
citation with nowhere to lead is worse than a narrow column. */
|
||||
.assistant-side { position:fixed; left:0; right:0; bottom:0; z-index:140; display:flex;
|
||||
max-height:76vh; margin:0; padding:0; background:white; border-top:1px solid var(--g200);
|
||||
border-radius:16px 16px 0 0; box-shadow:0 -12px 40px rgba(15,23,42,.18);
|
||||
transform:translateY(102%); transition:transform .2s ease; }
|
||||
body.assistant-sources-open .assistant-side { transform:translateY(0); }
|
||||
.assistant-side .card { flex:1 1 auto; min-height:0; border:none; box-shadow:none; border-radius:0; }
|
||||
.assistant-sources { padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
/* Slim toolbar: icon-only actions in one row */
|
||||
/* Saved chats: a fixed drawer overlaying the chat */
|
||||
.assistant-history { position:fixed; left:0; top:0; bottom:0; width:min(85vw,320px); z-index:130; background:white; border-right:1px solid var(--g100); box-shadow:0 0 40px rgba(15,23,42,.14); transform:translateX(-105%); transition:transform .18s ease; display:flex; flex-direction:column; align-items:stretch; padding:10px 8px; overflow-y:auto; overflow-x:hidden; overscroll-behavior:contain; -webkit-overflow-scrolling:touch; gap:2px; border-radius:0; }
|
||||
|
|
@ -514,3 +522,15 @@ body.assistant-mode-workspace .assistant-rail-workspace { display:flex; }
|
|||
@media (max-width:640px) {
|
||||
.assistant-composer { margin:0 10px 10px; width:auto; max-width:none; }
|
||||
}
|
||||
|
||||
/* The button that opens that sheet. Desktop keeps the column, so it is only
|
||||
ever shown on a phone, and only when the answer actually has citations. */
|
||||
.assistant-sources-fab { display:none; }
|
||||
@media (max-width:640px) {
|
||||
.assistant-sources-fab { position:fixed; right:12px; bottom:calc(84px + env(safe-area-inset-bottom)); z-index:120;
|
||||
display:inline-flex; align-items:center; gap:7px; padding:9px 14px; border:1px solid var(--g200);
|
||||
border-radius:999px; background:white; color:var(--blue); font-family:inherit; font-size:12.5px;
|
||||
font-weight:600; box-shadow:0 6px 20px rgba(15,23,42,.16); cursor:pointer; }
|
||||
.assistant-sources-fab[hidden] { display:none; }
|
||||
body.assistant-sources-open .assistant-sources-fab { opacity:0; pointer-events:none; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1255,7 +1255,7 @@ textarea.full-input{resize:vertical;}
|
|||
/* The blue app header stays: it is the same product, and hiding it was the last
|
||||
thing making the assistant look like a separate one. Only the app SIDEBAR goes,
|
||||
because the assistant rail takes its place. */
|
||||
body.assistant-workspace .sidebar,
|
||||
@media(min-width:769px){ body.assistant-workspace .sidebar{ display:none; } }
|
||||
body.assistant-workspace .announcement-banner { display:none; }
|
||||
body.assistant-workspace .app-body { min-height:100vh; }
|
||||
body.assistant-workspace #assistant-tab.active { width:100%; }
|
||||
|
|
@ -1377,3 +1377,27 @@ body.assistant-preview .account-card { display:none; }
|
|||
.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. */
|
||||
|
||||
/* ── Mobile menu ─────────────────────────────────────────────────────────────
|
||||
A phone gets ONE menu: the app sidebar, opened as a full-height sheet. The
|
||||
assistant used to hide it and add a second drawer of its own, which is why two
|
||||
hamburgers appeared and neither did anything useful. */
|
||||
@media(max-width:768px){
|
||||
.sidebar{ width:min(88vw,360px); left:calc(-1 * min(88vw,360px)); padding:0; }
|
||||
.sidebar.open{ left:0; }
|
||||
/* Close and search lead, as in the reference. */
|
||||
.menu-head{ padding:14px 14px 8px; gap:10px; }
|
||||
.menu-head .assistant-menu-toggle{ order:-1; }
|
||||
.menu-brand h1{ font-size:19px; }
|
||||
.menu-icon-btn{ width:34px; height:34px; font-size:15px; }
|
||||
.menu-search-btn{ display:flex; }
|
||||
.assistant-mode-switch{ margin:6px 14px 10px; padding:5px; }
|
||||
.assistant-mode-switch button{ padding:9px 6px; font-size:13px; }
|
||||
.sidebar-tabs{ padding:2px 8px 8px; }
|
||||
.sidebar-section-label{ padding:12px 8px 4px; }
|
||||
.tab-btn{ padding:12px 10px; font-size:14.5px; border-radius:10px; border-left:none; }
|
||||
.tab-btn.active{ border-left:none; }
|
||||
.account-card{ padding:10px 12px calc(10px + env(safe-area-inset-bottom)); }
|
||||
.account-name{ font-size:14px; }
|
||||
.account-email{ font-size:12px; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -495,6 +495,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
|
||||
document.addEventListener('click', function(event) {
|
||||
if (!(event.target.closest && event.target.closest('[data-menu-toggle]'))) return;
|
||||
// On a phone the menu is a sheet, not a rail, so there is nothing to
|
||||
// collapse — the same control closes it instead.
|
||||
if (window.innerWidth <= 768) {
|
||||
if (sidebar) sidebar.classList.remove('open');
|
||||
return;
|
||||
}
|
||||
var nowHidden = document.body.classList.toggle('menu-hidden');
|
||||
syncMenuToggles(nowHidden);
|
||||
try { localStorage.setItem('ped_sidebar_collapsed', nowHidden ? '1' : '0'); } catch (e) {}
|
||||
|
|
|
|||
|
|
@ -150,7 +150,6 @@ import {
|
|||
if (layout) layout.classList.remove('mobile-chats-open');
|
||||
openCreateImageDialog();
|
||||
});
|
||||
var mobileMenuBtn = document.getElementById('btn-assistant-mobile-menu');
|
||||
// The button is the only affordance once the rail is gone, so it must always
|
||||
// say which way it goes.
|
||||
var syncHistoryToggle = function(collapsed) {
|
||||
|
|
@ -250,6 +249,33 @@ import {
|
|||
});
|
||||
}
|
||||
|
||||
var sourcesBtn = document.getElementById('btn-assistant-sources');
|
||||
if (sourcesBtn) sourcesBtn.addEventListener('click', function() {
|
||||
var open = !document.body.classList.contains('assistant-sources-open');
|
||||
document.body.classList.toggle('assistant-sources-open', open);
|
||||
sourcesBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
});
|
||||
// A sheet with no way out is a trap: tapping away or pressing Escape closes it.
|
||||
onceOnDocument('click', function(event) {
|
||||
if (!document.body.classList.contains('assistant-sources-open')) return;
|
||||
if (event.target.closest && (event.target.closest('.assistant-side') ||
|
||||
event.target.closest('#btn-assistant-sources') || event.target.closest('.assistant-cite'))) return;
|
||||
document.body.classList.remove('assistant-sources-open');
|
||||
if (sourcesBtn) sourcesBtn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
onceOnDocument('keydown', function(event) {
|
||||
if (event.key !== 'Escape') return;
|
||||
document.body.classList.remove('assistant-sources-open');
|
||||
if (sourcesBtn) sourcesBtn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
// Tapping a citation opens the sheet, so the number leads somewhere.
|
||||
onceOnDocument('click', function(event) {
|
||||
if (window.innerWidth > 640) return;
|
||||
if (!(event.target.closest && event.target.closest('.assistant-cite'))) return;
|
||||
document.body.classList.add('assistant-sources-open');
|
||||
if (sourcesBtn) sourcesBtn.setAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
var chatsToggle = document.getElementById('btn-assistant-chats-toggle');
|
||||
if (chatsToggle) chatsToggle.addEventListener('click', function() {
|
||||
var list = document.getElementById('assistant-saved-chats');
|
||||
|
|
@ -276,10 +302,6 @@ import {
|
|||
var layout = document.getElementById('assistant-layout');
|
||||
if (layout) layout.classList.remove('mobile-chats-open');
|
||||
};
|
||||
if (mobileMenuBtn) mobileMenuBtn.addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
document.getElementById('assistant-layout')?.classList.toggle('mobile-chats-open');
|
||||
});
|
||||
var drawerCloseBtn = document.getElementById('btn-assistant-drawer-close');
|
||||
if (drawerCloseBtn) drawerCloseBtn.addEventListener('click', closeDrawer);
|
||||
var backdrop = document.getElementById('assistant-drawer-backdrop');
|
||||
|
|
@ -817,10 +839,21 @@ import {
|
|||
if (side) side.hidden = !citationsOn;
|
||||
}
|
||||
|
||||
// On a phone the third column has no room, so sources open as a sheet. The
|
||||
// button appears only when an answer actually has citations.
|
||||
function syncSourcesButton(count) {
|
||||
var button = document.getElementById('btn-assistant-sources');
|
||||
if (!button) return;
|
||||
button.hidden = !(citationsOn && count > 0);
|
||||
var label = document.getElementById('assistant-sources-count');
|
||||
if (label) label.textContent = count === 1 ? '1 source' : count + ' sources';
|
||||
}
|
||||
|
||||
function renderSources(sources) {
|
||||
var wrap = document.getElementById('assistant-sources');
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = renderSourcesList(sources);
|
||||
syncSourcesButton(Array.isArray(sources) ? sources.length : 0);
|
||||
}
|
||||
|
||||
// ── OWUI-style code blocks with a Copy button ───────────────────────
|
||||
|
|
|
|||
59
test/assistant-mobile.test.js
Normal file
59
test/assistant-mobile.test.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
|
||||
|
||||
// On a phone the assistant showed TWO hamburgers, stacked, and neither did
|
||||
// anything useful: the app's opened a sidebar the assistant had hidden, and the
|
||||
// assistant's opened a rail that duplicated it.
|
||||
test('a phone has exactly one menu button', () => {
|
||||
const assistant = read('public/components/assistant.html');
|
||||
const index = read('public/index.html');
|
||||
assert.doesNotMatch(assistant, /btn-assistant-mobile-menu/,
|
||||
'the assistant no longer adds a drawer button of its own');
|
||||
assert.equal((index.match(/id="btn-menu-toggle"/g) || []).length, 1,
|
||||
'the app sidebar button is the only one');
|
||||
});
|
||||
|
||||
test('the app sidebar stays reachable inside the assistant on a phone', () => {
|
||||
const css = read('public/css/styles.css');
|
||||
// Hiding it unconditionally is what made the app hamburger open nothing.
|
||||
assert.match(css, /@media\(min-width:769px\)\{ body\.assistant-workspace \.sidebar\{ display:none; \} \}/,
|
||||
'the sidebar is only replaced by the assistant rail on desktop');
|
||||
const hideBlock = css.slice(css.indexOf('body.assistant-workspace .announcement-banner'), css.indexOf('body.assistant-workspace .app-body'));
|
||||
assert.doesNotMatch(hideBlock, /\.sidebar/, 'nothing hides it unconditionally');
|
||||
});
|
||||
|
||||
test('the collapse control closes the sheet on a phone rather than collapsing a rail', () => {
|
||||
const app = read('public/js/app.js');
|
||||
const handler = app.slice(app.indexOf("event.target.closest('[data-menu-toggle]')"), app.indexOf("localStorage.setItem('ped_sidebar_collapsed'"));
|
||||
assert.match(handler, /if \(window\.innerWidth <= 768\)/);
|
||||
assert.match(handler, /sidebar\.classList\.remove\('open'\)/, 'it closes the sheet');
|
||||
});
|
||||
|
||||
test('sources are reachable on a phone instead of deleted', () => {
|
||||
const css = read('public/css/assistant.css');
|
||||
const mobile = css.slice(css.indexOf('@media (max-width: 640px)'));
|
||||
// display:none left every citation pointing at nothing.
|
||||
assert.doesNotMatch(mobile.slice(0, 2000), /\.assistant-side \{ display:none; \}/);
|
||||
assert.match(mobile, /body\.assistant-sources-open \.assistant-side \{ transform:translateY\(0\); \}/,
|
||||
'they slide up as a sheet');
|
||||
|
||||
const js = read('public/js/clinicalAssistant.js');
|
||||
assert.match(js, /button\.hidden = !\(citationsOn && count > 0\);/,
|
||||
'the opener appears only when an answer has citations');
|
||||
assert.match(js, /event\.target\.closest\('\.assistant-cite'\)/, 'and tapping a citation opens it');
|
||||
// A sheet with no way out is a trap.
|
||||
assert.match(js, /if \(event\.key !== 'Escape'\) return;[\s\S]{0,160}assistant-sources-open/);
|
||||
assert.match(js, /if \(!document\.body\.classList\.contains\('assistant-sources-open'\)\) return;/,
|
||||
'tapping away closes it');
|
||||
});
|
||||
|
||||
test('the mobile menu is sized for a thumb', () => {
|
||||
const css = read('public/css/styles.css');
|
||||
const mobile = css.slice(css.indexOf('/* ── Mobile menu ─'));
|
||||
assert.match(mobile, /\.sidebar\{ width:min\(88vw,360px\)/, 'a sheet, not a narrow rail');
|
||||
assert.match(mobile, /\.tab-btn\{ padding:12px 10px; font-size:14\.5px/, 'rows are tappable');
|
||||
assert.match(mobile, /env\(safe-area-inset-bottom\)/, 'and clear of the home indicator');
|
||||
});
|
||||
Loading…
Reference in a new issue