diff --git a/public/app.js b/public/app.js index 71722d1..70ca9ff 100644 --- a/public/app.js +++ b/public/app.js @@ -10,6 +10,7 @@ import { renderRightrail } from './components/rightrail.js'; import { emit, state } from './state.js'; import { el, mount } from './dom.js'; import { attachDropzone } from './components/dropzone.js'; +import { initChrome } from './components/chrome.js'; const VIEWS = { home: () => import('./views/home.js'), @@ -72,6 +73,7 @@ async function init() { renderTopbar(document.getElementById('topbar')); renderSidebar(document.getElementById('sidebar')); renderRightrail(document.getElementById('rightrail')); + initChrome(); attachDropzone(document.getElementById('main')); route(renderView); pollPending(); diff --git a/public/components/chrome.js b/public/components/chrome.js new file mode 100644 index 0000000..5165a13 --- /dev/null +++ b/public/components/chrome.js @@ -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(); +} diff --git a/public/components/topbar.js b/public/components/topbar.js index e772eaf..ca252f8 100644 --- a/public/components/topbar.js +++ b/public/components/topbar.js @@ -4,6 +4,7 @@ import { el, mount, clear } from '../dom.js'; import { navigate } from '../router.js'; import { on } from '../state.js'; +import { toggleSidebar, toggleRail } from './chrome.js'; function captureModal() { 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'); mount(root, + el('button', { class: 'chrome-toggle', title: 'Toggle menu', onclick: toggleSidebar }, '☰'), el('div', { class: 'brand' }, 'VOID'), el('button', { class: 'icon-btn', onclick: captureModal }, '+ Capture'), el('div', { class: 'topbar-search' }, searchInput), el('div', { class: 'topbar-spacer' }), 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') ); diff --git a/public/style.css b/public/style.css index 1e2c8e2..ec332b6 100644 --- a/public/style.css +++ b/public/style.css @@ -240,3 +240,65 @@ ul.plain li:last-child { border-bottom: none; } .tile.status-unknown .dot { background: var(--muted); } .tile-go { color: var(--lb); font-size: 11px; opacity: 0; transition: opacity .25s; } .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) */ +}