diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js index 8998ff46..5b59da4f 100644 --- a/frontend/packages/dashboard/src/common.js +++ b/frontend/packages/dashboard/src/common.js @@ -216,6 +216,7 @@ export const Panel = (() => { let root = null; let titleEl = null; let bodyEl = null; + let drawer = null; /** Owner key set by `openNamed` (e.g. 'inbox'). `refresh(name, …)` * is a no-op when the current owner doesn't match, so live * updates can re-render an open view without grabbing focus @@ -229,6 +230,7 @@ export const Panel = (() => { root = $('side-panel'); titleEl = $('side-panel-title'); bodyEl = $('side-panel-body'); + drawer = root && root.querySelector('.side-panel-drawer'); } return root != null; } @@ -256,6 +258,79 @@ export const Panel = (() => { root.classList.remove('open'); root.setAttribute('aria-hidden', 'true'); } + // #451: drag-to-resize the drawer's width. Listens on a thin + // hit-strip glued to the drawer's left edge; mousedown captures + // pointermove + pointerup on the document so the drag continues + // even if the cursor strays outside the 6px handle band. Width + // persists to localStorage so it survives page reload. The CSS + // clamps the value (min-width: 320px, max-width: 96vw) — drop + // unparseable / out-of-range stored values silently. + const WIDTH_KEY = 'hyperhive:side-panel-width'; + const WIDTH_MIN = 320; + function clampWidth(w) { + const max = Math.floor(window.innerWidth * 0.96); + return Math.max(WIDTH_MIN, Math.min(max, w)); + } + function applyStoredWidth() { + if (!drawer) return; + const raw = (() => { + try { return localStorage.getItem(WIDTH_KEY); } + catch { return null; } + })(); + if (!raw) return; + const parsed = parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return; + drawer.style.setProperty('--side-panel-w', clampWidth(parsed) + 'px'); + } + function bindResize() { + if (!drawer) return; + const handle = document.createElement('div'); + handle.className = 'side-panel-resize'; + handle.setAttribute('role', 'separator'); + handle.setAttribute('aria-orientation', 'vertical'); + handle.setAttribute('aria-label', 'drag to resize side panel'); + handle.title = 'drag to resize'; + drawer.prepend(handle); + let dragging = false; + handle.addEventListener('pointerdown', (e) => { + e.preventDefault(); + dragging = true; + document.body.classList.add('side-panel-resizing'); + // Capture so we keep getting pointermove even when the cursor + // outpaces the handle band (drag-fast-then-pause loses the + // handle's :hover state otherwise). + try { handle.setPointerCapture(e.pointerId); } catch { /* legacy */ } + }); + document.addEventListener('pointermove', (e) => { + if (!dragging) return; + // Drawer is anchored to the right edge — width = viewport - pointer X. + const w = clampWidth(window.innerWidth - e.clientX); + drawer.style.setProperty('--side-panel-w', w + 'px'); + }); + function stopDrag() { + if (!dragging) return; + dragging = false; + document.body.classList.remove('side-panel-resizing'); + // Persist the final width. Read the actual rendered width + // rather than re-deriving so the stored value matches what + // the operator saw at mouseup. + const w = drawer.getBoundingClientRect().width; + try { localStorage.setItem(WIDTH_KEY, String(Math.round(w))); } + catch { /* localStorage unavailable — width is session-only */ } + } + document.addEventListener('pointerup', stopDrag); + document.addEventListener('pointercancel', stopDrag); + // Re-clamp on viewport resize so a persisted width that exceeds + // 96vw doesn't push the drawer off-screen after a window shrink. + window.addEventListener('resize', () => { + if (dragging) return; + const cur = drawer.getBoundingClientRect().width; + const clamped = clampWidth(cur); + if (clamped !== Math.round(cur)) { + drawer.style.setProperty('--side-panel-w', clamped + 'px'); + } + }); + } function bind() { if (!ensure()) return; $('side-panel-close').addEventListener('click', close); @@ -263,6 +338,8 @@ export const Panel = (() => { document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && root.classList.contains('open')) close(); }); + applyStoredWidth(); + bindResize(); } return { open, openNamed, refresh, close, bind }; })(); diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 3caffb1c..1ca6a9bf 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -1111,8 +1111,10 @@ summary:hover { color: var(--purple); } background: var(--bg-elev); border: 1px solid var(--border); padding: 0.5em 0.8em; - max-height: 24em; - overflow-y: auto; + /* #450: no max-height cap — let the inbox grow to fill the + side-panel-body which already scrolls (`overflow: auto`). The + pre-#450 24em cap clamped the list well short of the available + panel height even on tall viewports. */ } .inbox li { padding: 0.25em 0; @@ -1297,7 +1299,19 @@ footer .banner-thin { top: 0; right: 0; bottom: 0; - width: min(760px, 94vw); + /* #451: width is a CSS variable so the drag handle (added by + Panel.bind) can update it live, and so localStorage-persisted + widths apply on first paint. Default min(760px, 94vw) preserves + the pre-#451 behaviour for operators who never drag. The body + adds `data-spw=""` once persisted; CSS doesn't read it + directly — JS sets `--side-panel-w` via inline style on the + drawer. */ + width: var(--side-panel-w, min(760px, 94vw)); + /* Clamp so a stored width can never push the drawer off-screen + or shrink it past readability. min content width matches the + panel-head's natural width with the close button visible. */ + min-width: 320px; + max-width: 96vw; display: flex; flex-direction: column; background: var(--bg-elev); @@ -1309,6 +1323,36 @@ footer .banner-thin { .side-panel.open { pointer-events: auto; } .side-panel.open .side-panel-backdrop { opacity: 1; } .side-panel.open .side-panel-drawer { transform: translateX(0); } +/* #451: drag-to-resize handle on the drawer's left edge. The handle + itself is invisible until hover/drag so it doesn't compete with the + 2px mauve `border-left` for the visual boundary. Pointer-cursor + tells the operator the edge is grabbable; the brighter glow during + drag (`body.side-panel-resizing`) is the affordance the eye + tracks. */ +.side-panel-resize { + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 6px; + margin-left: -3px; + cursor: ew-resize; + z-index: 1; + background: transparent; + transition: background 120ms ease; +} +.side-panel-resize:hover, +body.side-panel-resizing .side-panel-resize { + background: rgba(203, 166, 247, 0.55); +} +/* While dragging: kill text selection + force the resize cursor + page-wide so the cursor doesn't flip back to default the moment it + leaves the 6px handle band. */ +body.side-panel-resizing { + cursor: ew-resize !important; + user-select: none; +} +body.side-panel-resizing * { cursor: ew-resize !important; } .side-panel-head { flex: 0 0 auto; display: flex;