feat(ui): collapsible sidebar + rail, off-canvas mobile drawers, responsive layout
- Left sidebar collapses (topbar menu button); rail collapses (topbar button + strip). - <=860px / portrait: sidebar + rail become off-canvas drawers over main with a scrim, closed by default; main goes full-width single-column. - Smooth transitions; crossing the breakpoint resets to a sane default. - New components/chrome.js owns toggle + drawer state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { renderRightrail } from './components/rightrail.js';
|
|||||||
import { emit, state } from './state.js';
|
import { emit, state } from './state.js';
|
||||||
import { el, mount } from './dom.js';
|
import { el, mount } from './dom.js';
|
||||||
import { attachDropzone } from './components/dropzone.js';
|
import { attachDropzone } from './components/dropzone.js';
|
||||||
|
import { initChrome } from './components/chrome.js';
|
||||||
|
|
||||||
const VIEWS = {
|
const VIEWS = {
|
||||||
home: () => import('./views/home.js'),
|
home: () => import('./views/home.js'),
|
||||||
@@ -72,6 +73,7 @@ async function init() {
|
|||||||
renderTopbar(document.getElementById('topbar'));
|
renderTopbar(document.getElementById('topbar'));
|
||||||
renderSidebar(document.getElementById('sidebar'));
|
renderSidebar(document.getElementById('sidebar'));
|
||||||
renderRightrail(document.getElementById('rightrail'));
|
renderRightrail(document.getElementById('rightrail'));
|
||||||
|
initChrome();
|
||||||
attachDropzone(document.getElementById('main'));
|
attachDropzone(document.getElementById('main'));
|
||||||
route(renderView);
|
route(renderView);
|
||||||
pollPending();
|
pollPending();
|
||||||
|
|||||||
76
public/components/chrome.js
Normal file
76
public/components/chrome.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// Collapsible chrome controller: left sidebar + right companion rail.
|
||||||
|
// Desktop → panels shrink the grid (state persisted). Narrow/portrait → panels
|
||||||
|
// become off-canvas drawers over the main area with a scrim; closed by default.
|
||||||
|
//
|
||||||
|
// State lives as classes on #shell: `sidebar-collapsed` / `rail-collapsed`.
|
||||||
|
// rightrail.js also toggles `rail-collapsed` (its own strip) + the same key, so
|
||||||
|
// a MutationObserver keeps the scrim in sync no matter who toggles.
|
||||||
|
|
||||||
|
const SB_KEY = 'void_sidebar_collapsed';
|
||||||
|
const RAIL_KEY = 'void_rail_collapsed';
|
||||||
|
|
||||||
|
const shell = () => document.getElementById('shell');
|
||||||
|
const isNarrow = () => window.matchMedia('(max-width: 860px)').matches;
|
||||||
|
|
||||||
|
function syncScrim() {
|
||||||
|
const s = shell();
|
||||||
|
if (!s) return;
|
||||||
|
const anyOpen = isNarrow() &&
|
||||||
|
(!s.classList.contains('sidebar-collapsed') || !s.classList.contains('rail-collapsed'));
|
||||||
|
document.body.classList.toggle('drawer-open', anyOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleSidebar() {
|
||||||
|
const s = shell();
|
||||||
|
const opening = s.classList.contains('sidebar-collapsed');
|
||||||
|
s.classList.toggle('sidebar-collapsed');
|
||||||
|
if (opening && isNarrow()) s.classList.add('rail-collapsed'); // one drawer at a time
|
||||||
|
if (!isNarrow()) localStorage.setItem(SB_KEY, String(!opening));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleRail() {
|
||||||
|
const s = shell();
|
||||||
|
const opening = s.classList.contains('rail-collapsed');
|
||||||
|
s.classList.toggle('rail-collapsed');
|
||||||
|
if (opening && isNarrow()) s.classList.add('sidebar-collapsed');
|
||||||
|
localStorage.setItem(RAIL_KEY, String(!opening));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDrawers() {
|
||||||
|
shell().classList.add('sidebar-collapsed', 'rail-collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initChrome() {
|
||||||
|
const s = shell();
|
||||||
|
if (!s) return;
|
||||||
|
|
||||||
|
if (!document.getElementById('scrim')) {
|
||||||
|
const scrim = document.createElement('div');
|
||||||
|
scrim.id = 'scrim';
|
||||||
|
scrim.addEventListener('click', closeDrawers);
|
||||||
|
document.body.appendChild(scrim);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial state.
|
||||||
|
if (isNarrow()) {
|
||||||
|
s.classList.add('sidebar-collapsed', 'rail-collapsed'); // drawers closed on phones/portrait
|
||||||
|
} else if (localStorage.getItem(SB_KEY) === 'true') {
|
||||||
|
s.classList.add('sidebar-collapsed'); // restore desktop preference (rail handled by rightrail.js)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the scrim correct whenever shell classes change (covers rightrail's own toggle).
|
||||||
|
new MutationObserver(syncScrim).observe(s, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
|
||||||
|
// Crossing the breakpoint: collapse to a sane default for the new mode.
|
||||||
|
let wasNarrow = isNarrow();
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
const narrow = isNarrow();
|
||||||
|
if (narrow === wasNarrow) return;
|
||||||
|
wasNarrow = narrow;
|
||||||
|
if (narrow) closeDrawers();
|
||||||
|
else s.classList.remove('sidebar-collapsed'); // show the sidebar again on desktop
|
||||||
|
syncScrim();
|
||||||
|
});
|
||||||
|
|
||||||
|
syncScrim();
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import { el, mount, clear } from '../dom.js';
|
import { el, mount, clear } from '../dom.js';
|
||||||
import { navigate } from '../router.js';
|
import { navigate } from '../router.js';
|
||||||
import { on } from '../state.js';
|
import { on } from '../state.js';
|
||||||
|
import { toggleSidebar, toggleRail } from './chrome.js';
|
||||||
|
|
||||||
function captureModal() {
|
function captureModal() {
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
@@ -37,11 +38,13 @@ export function renderTopbar(root) {
|
|||||||
const bell = el('button', { class: 'icon-btn', onclick: () => navigate('/inbox') }, 'Inbox');
|
const bell = el('button', { class: 'icon-btn', onclick: () => navigate('/inbox') }, 'Inbox');
|
||||||
|
|
||||||
mount(root,
|
mount(root,
|
||||||
|
el('button', { class: 'chrome-toggle', title: 'Toggle menu', onclick: toggleSidebar }, '☰'),
|
||||||
el('div', { class: 'brand' }, 'VOID'),
|
el('div', { class: 'brand' }, 'VOID'),
|
||||||
el('button', { class: 'icon-btn', onclick: captureModal }, '+ Capture'),
|
el('button', { class: 'icon-btn', onclick: captureModal }, '+ Capture'),
|
||||||
el('div', { class: 'topbar-search' }, searchInput),
|
el('div', { class: 'topbar-search' }, searchInput),
|
||||||
el('div', { class: 'topbar-spacer' }),
|
el('div', { class: 'topbar-spacer' }),
|
||||||
bell,
|
bell,
|
||||||
|
el('button', { class: 'chrome-toggle', title: 'Toggle companion chat', onclick: toggleRail }, '◆'),
|
||||||
el('button', { class: 'icon-btn', onclick: () => alert('Agent-switching ships post-Plan-2.') }, 'Owner')
|
el('button', { class: 'icon-btn', onclick: () => alert('Agent-switching ships post-Plan-2.') }, 'Owner')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -240,3 +240,65 @@ ul.plain li:last-child { border-bottom: none; }
|
|||||||
.tile.status-unknown .dot { background: var(--muted); }
|
.tile.status-unknown .dot { background: var(--muted); }
|
||||||
.tile-go { color: var(--lb); font-size: 11px; opacity: 0; transition: opacity .25s; }
|
.tile-go { color: var(--lb); font-size: 11px; opacity: 0; transition: opacity .25s; }
|
||||||
.tile:hover .tile-go { opacity: 1; }
|
.tile:hover .tile-go { opacity: 1; }
|
||||||
|
|
||||||
|
/* ===== Collapsible chrome + responsive layout (Plan 6 polish) ===== */
|
||||||
|
:root { --sidebar-w-min: 0px; }
|
||||||
|
#shell { transition: grid-template-columns .22s ease; }
|
||||||
|
#sidebar, #rightrail { transition: transform .22s ease; }
|
||||||
|
|
||||||
|
/* Desktop collapse — shrink the grid columns */
|
||||||
|
#shell.sidebar-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr var(--rail-w); }
|
||||||
|
#shell.sidebar-collapsed.rail-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr var(--rail-w-min); }
|
||||||
|
#shell.rail-collapsed { grid-template-columns: var(--sidebar-w) 1fr var(--rail-w-min); }
|
||||||
|
#shell.sidebar-collapsed #sidebar { overflow: hidden; border-right: none; }
|
||||||
|
/* Hide chat body when the rail is collapsed so the thin strip stays clean */
|
||||||
|
#shell.rail-collapsed .rail-chat { display: none; }
|
||||||
|
|
||||||
|
/* Topbar toggle buttons */
|
||||||
|
.chrome-toggle {
|
||||||
|
background: transparent; border: 1px solid var(--border); color: var(--text);
|
||||||
|
width: 34px; height: 30px; border-radius: 4px; cursor: pointer; font-size: 15px; line-height: 1; flex: none;
|
||||||
|
}
|
||||||
|
.chrome-toggle:hover { border-color: var(--accent-dim); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Scrim behind mobile drawers */
|
||||||
|
#scrim {
|
||||||
|
position: fixed; inset: var(--topbar-h) 0 0 0; background: rgba(0,0,0,.5);
|
||||||
|
z-index: 40; opacity: 0; pointer-events: none; transition: opacity .22s;
|
||||||
|
}
|
||||||
|
body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||||
|
|
||||||
|
/* Hide the chat-only toggle button on wide screens (rail has its own strip);
|
||||||
|
show it on narrow screens where the rail is an off-canvas drawer. */
|
||||||
|
.rail-toggle-btn { display: none; }
|
||||||
|
|
||||||
|
/* ---- Narrow / mobile / vertical: off-canvas drawers, single-column main ---- */
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
#shell,
|
||||||
|
#shell.sidebar-collapsed,
|
||||||
|
#shell.rail-collapsed,
|
||||||
|
#shell.sidebar-collapsed.rail-collapsed {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-areas: "topbar" "main";
|
||||||
|
}
|
||||||
|
#sidebar, #rightrail {
|
||||||
|
position: fixed; top: var(--topbar-h); bottom: 0; z-index: 50;
|
||||||
|
}
|
||||||
|
#sidebar { left: 0; width: min(82vw, 300px); transform: translateX(-100%); border-right: 1px solid var(--border); }
|
||||||
|
#rightrail{ right: 0; width: min(90vw, 360px); transform: translateX(100%); }
|
||||||
|
/* A drawer is OPEN when its collapse class is absent */
|
||||||
|
#shell:not(.sidebar-collapsed) #sidebar { transform: translateX(0); box-shadow: 6px 0 28px rgba(0,0,0,.6); }
|
||||||
|
#shell:not(.rail-collapsed) #rightrail{ transform: translateX(0); box-shadow: -6px 0 28px rgba(0,0,0,.6); }
|
||||||
|
#shell:not(.rail-collapsed) .rail-chat { display: flex; flex-direction: column; height: 100%; }
|
||||||
|
#main { padding: 16px 18px; }
|
||||||
|
.topbar-search { max-width: none; }
|
||||||
|
.rail-toggle-btn { display: inline-block; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Very narrow phones — tighten the topbar so toggles + search fit */
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
#topbar { padding: 0 8px; gap: 8px; }
|
||||||
|
.brand { display: none; }
|
||||||
|
#main { padding: 14px 12px; }
|
||||||
|
/* Sacred Valley cards already collapse to 1 column at <=900px (see grid rules) */
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user