frontend: unify dashboard + agent side panel into shared hive-side-panel
The dashboard's Panel singleton and the per-agent UI's own inline Panel IIFE each had their own near-identical implementation of the right-side slide-in drawer used for file previews, diffs, logs, and inbox/todo lists. Both are now thin wrappers around a new <hive-side-panel> shadow-DOM custom element in @hive/shared, following the same house pattern as <hive-menu>: the element owns and builds all its structural chrome itself (backdrop, drawer, resize handle, header, title, close button) in connectedCallback, and only the caller's opaque content node is projected in via a default <slot> so each package's own content-type-specific CSS keeps reaching it. Public API is the union of both originals: open(title, content), openNamed(name, title, content), refresh(name, title, content), close(), and currentOwner(). Drag-to-resize + localStorage width persistence (ported verbatim from the dashboard's original implementation, the only one of the two that had it) is now available to both consumers by default — a deliberate behavior widening for the agent UI, which didn't have resize before. Along the way, fixed a latent bug in the ported CSS: the resize handle was setting a --side-panel-w custom property that no width rule ever consumed, so dragging never actually resized the drawer even though it looked wired up; the new shared stylesheet's width rule reads it properly. Each package's own global stylesheet keeps its content-specific rules (common.css's .side-panel-body .md, agent.css's .side-panel-body .agent-inbox) exactly where they were — those can never be reached from the shared element's shadow tree, same architectural floor as <hive-menu>'s item-row styling. Each wrapper applies a plain 'side-panel-body' compatibility class to its own <hive-side-panel> instance so those existing selectors keep matching by ordinary light-DOM descendant matching, with the shared element itself having no knowledge of what that class name means. Panel.bind() is gone from both packages' public API — the shared element wires its own listeners in connectedCallback, so there's no bind step left to call. tabs.js's one call site (the only bind() caller in either package) was updated to drop it. The two original chrome CSS blocks disagreed on several purely visual details beyond the resize-handle rules (z-index, backdrop color, drawer border/box-shadow, title typography) — the dashboard's values (the more feature-complete of the two) were kept as canonical, which is a small visible style change for the agent UI's panel chrome (thinner border, no box-shadow, no bold purple title). Flagged for visibility since nothing in the original two implementations called this out explicitly. Verified with a real headless-Chromium/CDP harness (bundled the actual component + built page CSS, served statically, drove via raw CDP) for both usage shapes: open/close, backdrop-click dismiss, Escape dismiss, refresh() owner-matching (no-op on wrong owner, applies on matching owner), and drag-to-resize (drawer width updates live during drag and persists to localStorage on release).
This commit is contained in:
parent
d10eebd455
commit
c5610b075a
10 changed files with 388 additions and 358 deletions
|
|
@ -891,73 +891,24 @@ pre.diff {
|
||||||
/* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */
|
/* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */
|
||||||
|
|
||||||
/* ─── side panel (singleton drawer) ────────────────────────────────
|
/* ─── side panel (singleton drawer) ────────────────────────────────
|
||||||
Inbox + loose-ends details open here instead of expanding inline.
|
Inbox + loose-ends details open here instead of expanding inline. The
|
||||||
Copy of the dashboard's side-panel pattern — candidate for
|
drawer chrome itself (backdrop, drawer, resize handle, header, title,
|
||||||
extraction into @hive/shared once both surfaces stabilize. */
|
close button) is now the shared `<hive-side-panel>` element
|
||||||
.side-panel {
|
(@hive/shared/side-panel.js — this also picks up drag-to-resize,
|
||||||
position: fixed;
|
which this page didn't have before); what's left here is the drag
|
||||||
inset: 0;
|
cursor-override escape hatch (has to be a global, light-DOM rule — it
|
||||||
z-index: 50;
|
reaches every element on the page during a drag, not just the shared
|
||||||
/* Closed: ignore pointer events so the agent page underneath stays
|
element's own shadow tree) and the styling for the panel's own
|
||||||
interactive; `.open` flips it back on. */
|
content types, which the shared element's shadow tree can never
|
||||||
pointer-events: none;
|
reach (see @hive/shared/side-panel/hive-side-panel.css's header
|
||||||
}
|
comment). `.side-panel-body` below is the compatibility class
|
||||||
.side-panel-backdrop {
|
app.js's `Panel` wrapper puts directly on its `<hive-side-panel>`
|
||||||
position: absolute;
|
instance so these rules keep reaching the slotted content by
|
||||||
inset: 0;
|
ordinary light-DOM descendant matching. */
|
||||||
background: rgba(0, 0, 0, 0.55);
|
body.side-panel-resizing {
|
||||||
opacity: 0;
|
user-select: none;
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
.side-panel-drawer {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: min(640px, 92vw);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--bg-elev);
|
|
||||||
border-left: 2px solid var(--purple);
|
|
||||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.45);
|
|
||||||
transform: translateX(100%);
|
|
||||||
transition: transform 0.25s ease;
|
|
||||||
}
|
|
||||||
.side-panel.open { pointer-events: auto; }
|
|
||||||
.side-panel.open .side-panel-backdrop { opacity: 1; }
|
|
||||||
.side-panel.open .side-panel-drawer { transform: translateX(0); }
|
|
||||||
.side-panel-head {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1em;
|
|
||||||
padding: 0.7em 1em;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.side-panel-title {
|
|
||||||
color: var(--purple);
|
|
||||||
font-weight: bold;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.side-panel-close {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1em;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0.25em 0.55em;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.side-panel-close:hover { border-color: var(--red); color: var(--red); }
|
|
||||||
.side-panel-body {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 0.8em 1em;
|
|
||||||
}
|
}
|
||||||
|
body.side-panel-resizing * { cursor: ew-resize !important; }
|
||||||
/* Inbox / loose-ends lists rendered into the side-panel body. The
|
/* Inbox / loose-ends lists rendered into the side-panel body. The
|
||||||
legacy <details>-collapsible variant of .agent-inbox is gone, so
|
legacy <details>-collapsible variant of .agent-inbox is gone, so
|
||||||
here we strip the inbox-only chrome (background, border-left) and
|
here we strip the inbox-only chrome (background, border-left) and
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { create as termCreate, linkify as termLinkify } from '@hive/shared/termi
|
||||||
import { asyncBtn, bindAsyncForms } from '@hive/shared/forms.js';
|
import { asyncBtn, bindAsyncForms } from '@hive/shared/forms.js';
|
||||||
import { themedConfirm } from '@hive/shared/modal.js';
|
import { themedConfirm } from '@hive/shared/modal.js';
|
||||||
import { el } from '@hive/shared/dom.js';
|
import { el } from '@hive/shared/dom.js';
|
||||||
|
import '@hive/shared/side-panel.js'; // registers <hive-side-panel> — side-effect import
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
|
@ -37,43 +38,30 @@ window.marked = marked;
|
||||||
bindAsyncForms(() => refreshState());
|
bindAsyncForms(() => refreshState());
|
||||||
|
|
||||||
// ─── side panel (singleton drawer for inbox + loose-ends flyouts) ──────
|
// ─── side panel (singleton drawer for inbox + loose-ends flyouts) ──────
|
||||||
// Shared shape with the dashboard's panel. Candidate for extraction
|
// Thin wrapper around the shared `<hive-side-panel>` element (see
|
||||||
// into @hive/shared once both surfaces stabilise.
|
// @hive/shared/side-panel.js) — created and appended to <body> lazily
|
||||||
const Panel = (() => {
|
// on first use, same pattern `themedToast` uses for its toast-stack
|
||||||
const root = $('side-panel');
|
// container. This UI's `open(name, title, content)` always takes an
|
||||||
const titleEl = $('side-panel-title');
|
// owner name, so it maps straight onto the shared element's
|
||||||
const bodyEl = $('side-panel-body');
|
// `openNamed`; `side-panel-body` is a plain compatibility class this
|
||||||
/** Owner key (e.g. 'inbox', 'loose-ends'). Refresh hooks check
|
// wrapper puts on its own instance so `agent.css`'s existing
|
||||||
* against this so a live event only re-renders the panel when
|
// `.side-panel-body .agent-inbox …` rules keep reaching the slotted
|
||||||
* the matching view is actually visible. null when closed. */
|
// content by ordinary light-DOM descendant matching.
|
||||||
let owner = null;
|
let panelEl = null;
|
||||||
function open(name, title, content) {
|
function ensurePanel() {
|
||||||
owner = name;
|
if (!panelEl) {
|
||||||
titleEl.textContent = title;
|
panelEl = document.createElement('hive-side-panel');
|
||||||
bodyEl.replaceChildren(...(content ? [content] : []));
|
panelEl.classList.add('side-panel-body');
|
||||||
root.classList.add('open');
|
document.body.append(panelEl);
|
||||||
root.setAttribute('aria-hidden', 'false');
|
|
||||||
}
|
}
|
||||||
function close() {
|
return panelEl;
|
||||||
owner = null;
|
}
|
||||||
root.classList.remove('open');
|
const Panel = {
|
||||||
root.setAttribute('aria-hidden', 'true');
|
open: (name, title, content) => ensurePanel().openNamed(name, title, content),
|
||||||
}
|
close: () => ensurePanel().close(),
|
||||||
function refresh(name, title, content) {
|
refresh: (name, title, content) => ensurePanel().refresh(name, title, content),
|
||||||
if (owner !== name) return;
|
currentOwner: () => ensurePanel().currentOwner(),
|
||||||
titleEl.textContent = title;
|
};
|
||||||
bodyEl.replaceChildren(...(content ? [content] : []));
|
|
||||||
}
|
|
||||||
function bind() {
|
|
||||||
$('side-panel-close').addEventListener('click', close);
|
|
||||||
$('side-panel-backdrop').addEventListener('click', close);
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && root.classList.contains('open')) close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { open, close, refresh, bind, currentOwner: () => owner };
|
|
||||||
})();
|
|
||||||
Panel.bind();
|
|
||||||
|
|
||||||
// Wire the header pills to open the side panel. Pre-built (vs
|
// Wire the header pills to open the side panel. Pre-built (vs
|
||||||
// re-building per-click) so the freshest snapshot already lives
|
// re-building per-click) so the freshest snapshot already lives
|
||||||
|
|
|
||||||
|
|
@ -93,21 +93,10 @@
|
||||||
<div id="term-input" class="term-input"></div>
|
<div id="term-input" class="term-input"></div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<!-- Slide-in side panel. Singleton — JS swaps the title + body
|
<!-- Slide-in side panel (inbox / todos flyouts) is a <hive-side-panel>
|
||||||
and toggles `.open`. Shared shape with the dashboard's panel
|
element (@hive/shared/side-panel.js) — the Panel singleton in
|
||||||
(candidate for extraction into @hive/shared in a follow-up). -->
|
app.js creates + appends it to <body> lazily on first use, so
|
||||||
<div id="side-panel" class="side-panel" aria-hidden="true">
|
nothing needs to be pre-declared here. -->
|
||||||
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
|
|
||||||
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
|
|
||||||
aria-labelledby="side-panel-title">
|
|
||||||
<header class="side-panel-head">
|
|
||||||
<span class="side-panel-title" id="side-panel-title"></span>
|
|
||||||
<button type="button" class="side-panel-close" id="side-panel-close"
|
|
||||||
title="close (esc)">✕</button>
|
|
||||||
</header>
|
|
||||||
<div class="side-panel-body" id="side-panel-body"></div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
|
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
|
||||||
the marked npm package into app.js. -->
|
the marked npm package into app.js. -->
|
||||||
|
|
|
||||||
|
|
@ -366,99 +366,24 @@ code {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── side panel ─────────────────────────────────────────────────
|
/* ─── side panel ─────────────────────────────────────────────────
|
||||||
Long content (file previews, diffs, journald, applied config)
|
Long content (file previews, diffs, journald, applied config) opens in
|
||||||
opens in a drawer that swipes in from the right — used by both
|
a drawer that swipes in from the right — used by both /flow.html and
|
||||||
/flow.html and the dashboard. */
|
the dashboard. The drawer chrome itself (backdrop, drawer, resize
|
||||||
.side-panel {
|
handle, header, title, close button) is now the shared
|
||||||
position: fixed;
|
`<hive-side-panel>` element (@hive/shared/side-panel.js); what's left
|
||||||
inset: 0;
|
here is the drag cursor-override escape hatch (has to be a global,
|
||||||
z-index: 200;
|
light-DOM rule — it reaches every element on the page during a drag,
|
||||||
pointer-events: none;
|
not just the shared element's own shadow tree) and the styling for
|
||||||
}
|
the panel's own content types, which the shared element's shadow tree
|
||||||
.side-panel-backdrop {
|
can never reach (see @hive/shared/side-panel/hive-side-panel.css's
|
||||||
position: absolute;
|
header comment). `.side-panel-body` below is the compatibility class
|
||||||
inset: 0;
|
common.js's `Panel` wrapper puts directly on its `<hive-side-panel>`
|
||||||
background: color-mix(in srgb, var(--crust) 55%, transparent);
|
instance so these rules keep reaching the slotted content by ordinary
|
||||||
opacity: 0;
|
light-DOM descendant matching. */
|
||||||
transition: opacity 200ms ease;
|
|
||||||
}
|
|
||||||
.side-panel-drawer {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: min(42em, 95vw);
|
|
||||||
background: var(--bg-elev);
|
|
||||||
border-left: 1px solid var(--purple-dim);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
transform: translateX(100%);
|
|
||||||
transition: transform 220ms ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.side-panel.open { pointer-events: auto; }
|
|
||||||
.side-panel.open .side-panel-backdrop { opacity: 1; }
|
|
||||||
.side-panel.open .side-panel-drawer { transform: translateX(0); }
|
|
||||||
.side-panel-resize {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 6px;
|
|
||||||
cursor: ew-resize;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 120ms;
|
|
||||||
background: var(--purple-dim);
|
|
||||||
}
|
|
||||||
.side-panel-resize:hover,
|
|
||||||
body.side-panel-resizing .side-panel-resize {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
body.side-panel-resizing .side-panel-resize {
|
|
||||||
background: var(--purple);
|
|
||||||
}
|
|
||||||
body.side-panel-resizing {
|
body.side-panel-resizing {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
body.side-panel-resizing * { cursor: ew-resize !important; }
|
body.side-panel-resizing * { cursor: ew-resize !important; }
|
||||||
.side-panel-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6em;
|
|
||||||
padding: 0.6em 1em;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
.side-panel-title {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.88em;
|
|
||||||
color: var(--muted);
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.side-panel-close {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 3px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 1em;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0.15em 0.4em;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s ease, color 0.15s ease;
|
|
||||||
}
|
|
||||||
.side-panel-close:hover { border-color: var(--red); color: var(--red); }
|
|
||||||
.side-panel-body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0.8em 1em;
|
|
||||||
font-size: 0.88em;
|
|
||||||
}
|
|
||||||
.side-panel-body .md { color: var(--fg); line-height: 1.5; }
|
.side-panel-body .md { color: var(--fg); line-height: 1.5; }
|
||||||
.side-panel-body .md > :first-child { margin-top: 0; }
|
.side-panel-body .md > :first-child { margin-top: 0; }
|
||||||
.side-panel-body .md > :last-child { margin-bottom: 0; }
|
.side-panel-body .md > :last-child { margin-bottom: 0; }
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { linkify as termLinkify } from '@hive/shared/terminal.js';
|
import { linkify as termLinkify } from '@hive/shared/terminal.js';
|
||||||
import { el } from '@hive/shared/dom.js';
|
import { el } from '@hive/shared/dom.js';
|
||||||
|
import '@hive/shared/side-panel.js'; // registers <hive-side-panel> — side-effect import
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
// ─── helpers ────────────────────────────────────────────────────────────
|
// ─── helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
@ -283,138 +284,31 @@ export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
|
||||||
// opens here via `Panel.open(title, node)` instead of expanding
|
// opens here via `Panel.open(title, node)` instead of expanding
|
||||||
// inline. Body is swapped on each open; closing just slides out so
|
// inline. Body is swapped on each open; closing just slides out so
|
||||||
// the content stays visible through the transition.
|
// the content stays visible through the transition.
|
||||||
export const Panel = (() => {
|
//
|
||||||
let root = null;
|
// Thin wrapper around the shared `<hive-side-panel>` element (see
|
||||||
let titleEl = null;
|
// @hive/shared/side-panel.js for the chrome/behavior it owns, including
|
||||||
let bodyEl = null;
|
// drag-to-resize) — the instance is created and appended to <body>
|
||||||
let drawer = null;
|
// lazily on first use, same pattern `themedToast` uses for its
|
||||||
/** Owner key set by `openNamed` (e.g. 'inbox'). `refresh(name, …)`
|
// toast-stack container. `side-panel-body` is a plain compatibility
|
||||||
* is a no-op when the current owner doesn't match, so live
|
// class this wrapper puts on its own instance so `common.css`'s
|
||||||
* updates can re-render an open view without grabbing focus
|
// existing `.side-panel-body .md …` rules keep reaching the slotted
|
||||||
* from a closed one (or from an unrelated open view like a
|
// content by ordinary light-DOM descendant matching — the shared
|
||||||
* diff drill-in). Untyped calls via `open(title, content)`
|
// element itself has no idea that class name means anything.
|
||||||
* clear the owner — the legacy file-preview/diff/log paths
|
let panelEl = null;
|
||||||
* don't participate in named-refresh semantics. */
|
function ensurePanel() {
|
||||||
let owner = null;
|
if (!panelEl) {
|
||||||
function ensure() {
|
panelEl = document.createElement('hive-side-panel');
|
||||||
if (!root) {
|
panelEl.classList.add('side-panel-body');
|
||||||
root = $('side-panel');
|
document.body.append(panelEl);
|
||||||
titleEl = $('side-panel-title');
|
|
||||||
bodyEl = $('side-panel-body');
|
|
||||||
drawer = root && root.querySelector('.side-panel-drawer');
|
|
||||||
}
|
|
||||||
return root != null;
|
|
||||||
}
|
}
|
||||||
function open(title, content) {
|
return panelEl;
|
||||||
if (!ensure()) return;
|
}
|
||||||
owner = null;
|
export const Panel = {
|
||||||
titleEl.textContent = title;
|
open: (title, content) => ensurePanel().open(title, content),
|
||||||
bodyEl.replaceChildren(...(content ? [content] : []));
|
openNamed: (name, title, content) => ensurePanel().openNamed(name, title, content),
|
||||||
root.classList.add('open');
|
refresh: (name, title, content) => ensurePanel().refresh(name, title, content),
|
||||||
root.setAttribute('aria-hidden', 'false');
|
close: () => ensurePanel().close(),
|
||||||
}
|
};
|
||||||
function openNamed(name, title, content) {
|
|
||||||
open(title, content);
|
|
||||||
owner = name;
|
|
||||||
}
|
|
||||||
function refresh(name, title, content) {
|
|
||||||
if (!ensure()) return;
|
|
||||||
if (owner !== name) return;
|
|
||||||
titleEl.textContent = title;
|
|
||||||
bodyEl.replaceChildren(...(content ? [content] : []));
|
|
||||||
}
|
|
||||||
function close() {
|
|
||||||
if (!ensure()) return;
|
|
||||||
owner = null;
|
|
||||||
root.classList.remove('open');
|
|
||||||
root.setAttribute('aria-hidden', 'true');
|
|
||||||
}
|
|
||||||
// Drag-to-resize the drawer's width. See docs/web-ui.md::Side panel
|
|
||||||
// for the hit-strip + pointer-capture + localStorage persistence
|
|
||||||
// model; CSS clamps the stored value to min 320px / max 96vw and
|
|
||||||
// out-of-range stored values are dropped silently.
|
|
||||||
const WIDTH_KEY = 'hyperhive:side-panel-width';
|
|
||||||
const WIDTH_MIN = 320;
|
|
||||||
function clampWidth(w) {
|
|
||||||
// Use document.documentElement.clientWidth rather than window.innerWidth:
|
|
||||||
// clientWidth gives the actual CSS layout viewport width and is not
|
|
||||||
// altered by Firefox's Fingerprinting Protection (which rounds
|
|
||||||
// window.innerWidth/outerWidth to the nearest 200px).
|
|
||||||
const max = Math.floor(document.documentElement.clientWidth * 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(document.documentElement.clientWidth - 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);
|
|
||||||
$('side-panel-backdrop').addEventListener('click', close);
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && root.classList.contains('open')) close();
|
|
||||||
});
|
|
||||||
applyStoredWidth();
|
|
||||||
bindResize();
|
|
||||||
}
|
|
||||||
return { open, openNamed, refresh, close, bind };
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ─── path linkification ─────────────────────────────────────────────────
|
// ─── path linkification ─────────────────────────────────────────────────
|
||||||
// Agents constantly drop pointer strings into messages + question
|
// Agents constantly drop pointer strings into messages + question
|
||||||
|
|
|
||||||
|
|
@ -234,23 +234,11 @@
|
||||||
<p>▲△▲ <a href="https://forge.darkest.space/hyperhive/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
|
<p>▲△▲ <a href="https://forge.darkest.space/hyperhive/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<!-- Slide-in detail panel. Long content (clicked file previews,
|
<!-- Slide-in detail panel (clicked file previews, approval diffs,
|
||||||
approval diffs, journald logs, applied config) opens here
|
journald logs, applied config) is a <hive-side-panel> element
|
||||||
instead of expanding inline. Singleton — JS swaps the title +
|
(@hive/shared/side-panel.js) — the Panel singleton in common.js
|
||||||
body and toggles `.open`. Lives outside the tab panes so it
|
creates + appends it to <body> lazily on first use, so nothing
|
||||||
overlays any active tab. -->
|
needs to be pre-declared here. -->
|
||||||
<div id="side-panel" class="side-panel" aria-hidden="true">
|
|
||||||
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
|
|
||||||
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
|
|
||||||
aria-labelledby="side-panel-title">
|
|
||||||
<header class="side-panel-head">
|
|
||||||
<span class="side-panel-title" id="side-panel-title"></span>
|
|
||||||
<button type="button" class="side-panel-close" id="side-panel-close"
|
|
||||||
title="close (esc)">✕</button>
|
|
||||||
</header>
|
|
||||||
<div class="side-panel-body" id="side-panel-body"></div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Selection action bar. Sticky-bottom strip that slides into
|
<!-- Selection action bar. Sticky-bottom strip that slides into
|
||||||
view when one or more agent cards is selected (click the icon
|
view when one or more agent cards is selected (click the icon
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import {
|
import {
|
||||||
$,
|
$,
|
||||||
Panel, NOTIF,
|
NOTIF,
|
||||||
openStream, renderServerWarnings,
|
openStream, renderServerWarnings,
|
||||||
} from './common.js';
|
} from './common.js';
|
||||||
import { el } from '@hive/shared/dom.js';
|
import { el } from '@hive/shared/dom.js';
|
||||||
|
|
@ -276,8 +276,9 @@ window.marked = marked;
|
||||||
// NOTIF.bind() (the enable/mute/unmute toggle UI) moved to /settings.html
|
// NOTIF.bind() (the enable/mute/unmute toggle UI) moved to /settings.html
|
||||||
// with the S3TT1NGS page. The dashboard keeps NOTIF.show() for approval/
|
// with the S3TT1NGS page. The dashboard keeps NOTIF.show() for approval/
|
||||||
// question notifications — it reads the browser permission + localStorage
|
// question notifications — it reads the browser permission + localStorage
|
||||||
// mute flag the settings page sets, so no bind() is needed here.
|
// mute flag the settings page sets, so no bind() is needed here. Panel
|
||||||
Panel.bind();
|
// (backed by <hive-side-panel>) wires its own internals in
|
||||||
|
// connectedCallback — no bind() step needed either.
|
||||||
|
|
||||||
// ─── live updates: dashboard event stream ──────────────────────────────
|
// ─── live updates: dashboard event stream ──────────────────────────────
|
||||||
// The dashboard subscribes to /dashboard/stream for live mutation
|
// The dashboard subscribes to /dashboard/stream for live mutation
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@
|
||||||
"./dom.js": "./src/dom.js",
|
"./dom.js": "./src/dom.js",
|
||||||
"./modal.js": "./src/modal.js",
|
"./modal.js": "./src/modal.js",
|
||||||
"./shadow-css.js": "./src/shadow-css.js",
|
"./shadow-css.js": "./src/shadow-css.js",
|
||||||
"./hive-menu.js": "./src/hive-menu/hive-menu.js"
|
"./hive-menu.js": "./src/hive-menu/hive-menu.js",
|
||||||
|
"./side-panel.js": "./src/side-panel/hive-side-panel.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"src/"
|
"src/"
|
||||||
|
|
|
||||||
111
frontend/packages/shared/src/side-panel/hive-side-panel.css
Normal file
111
frontend/packages/shared/src/side-panel/hive-side-panel.css
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
/* hive-side-panel.css — scoped stylesheet for the generic <hive-side-panel>
|
||||||
|
shadow-DOM custom element (hive-side-panel.js). Loaded as raw text at
|
||||||
|
build time (esbuild's `text` loader) and appended as a <style> element
|
||||||
|
inside the shadow root — see @hive/shared/shadow-css.js's header comment
|
||||||
|
for why a plain <style> tag and not adoptedStyleSheets.
|
||||||
|
|
||||||
|
Every rule here styles shadow-owned chrome this element builds itself
|
||||||
|
(backdrop, drawer, resize handle, header, title, close button) — none of
|
||||||
|
it is slotted, so this file needs zero `::slotted()`. The one slotted
|
||||||
|
node (the caller's opaque content, passed to open()/openNamed()/
|
||||||
|
refresh()) is intentionally un-styled from in here: each package's own
|
||||||
|
content-type-specific rules (`.side-panel-body .md` in the dashboard,
|
||||||
|
`.side-panel-body .agent-inbox` in the agent UI) live in that package's
|
||||||
|
own global stylesheet and reach the slotted content via a light-DOM
|
||||||
|
class its own wrapper puts directly on its `<hive-side-panel>` instance
|
||||||
|
— same architecture floor as `<hive-menu>`'s item-row styling, not a
|
||||||
|
scope choice.
|
||||||
|
|
||||||
|
`:host(.open)`/`:host(.resizing)` respond to the two state classes the
|
||||||
|
JS toggles on the host itself (`open`, and `resizing` during a drag) —
|
||||||
|
the shadow-scoped counterparts of the page-global `body.side-panel-
|
||||||
|
resizing` cursor-override class, which stays in each package's own
|
||||||
|
stylesheet since it has to reach light-DOM elements outside this
|
||||||
|
element entirely (see hive-side-panel.js's module header). */
|
||||||
|
|
||||||
|
:host {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.side-panel-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: color-mix(in srgb, var(--crust) 55%, transparent);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
.side-panel-drawer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: var(--side-panel-w, min(42em, 95vw));
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-left: 1px solid var(--purple-dim);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 220ms ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
:host(.open) { pointer-events: auto; }
|
||||||
|
:host(.open) .side-panel-backdrop { opacity: 1; }
|
||||||
|
:host(.open) .side-panel-drawer { transform: translateX(0); }
|
||||||
|
.side-panel-resize {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 6px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 120ms;
|
||||||
|
background: var(--purple-dim);
|
||||||
|
}
|
||||||
|
.side-panel-resize:hover,
|
||||||
|
:host(.resizing) .side-panel-resize {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
:host(.resizing) .side-panel-resize {
|
||||||
|
background: var(--purple);
|
||||||
|
}
|
||||||
|
.side-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6em;
|
||||||
|
padding: 0.6em 1em;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.side-panel-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.88em;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.side-panel-close {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.15em 0.4em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.side-panel-close:hover { border-color: var(--red); color: var(--red); }
|
||||||
|
.side-panel-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.8em 1em;
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
182
frontend/packages/shared/src/side-panel/hive-side-panel.js
Normal file
182
frontend/packages/shared/src/side-panel/hive-side-panel.js
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
// hive-side-panel.js — <hive-side-panel>, the generic slide-in drawer
|
||||||
|
// shadow-DOM custom element behind the dashboard's and the per-agent UI's
|
||||||
|
// side panel (file previews, diffs, logs, inbox/todo lists — anything too
|
||||||
|
// long to expand inline). One singleton instance per page, created +
|
||||||
|
// appended lazily by each package's own thin wrapper
|
||||||
|
// (dashboard/src/common.js's `Panel`, agent/src/app.js's `Panel`) — same
|
||||||
|
// lazy-creation pattern `themedToast` uses for its toast-stack container.
|
||||||
|
//
|
||||||
|
// Builds all structural chrome itself in `connectedCallback` (backdrop,
|
||||||
|
// drawer, resize handle, header, title, close button) — shadow-owned,
|
||||||
|
// zero `::slotted()` needed. Only the panel *content* is opaque
|
||||||
|
// caller-built DOM, appended as a light-DOM child and picked up by the
|
||||||
|
// shadow tree's single default `<slot>`, exactly like `<hive-menu>`'s
|
||||||
|
// `content` (see that file's header for why). Each package's own global
|
||||||
|
// stylesheet reaches that content the same way it always did, via a
|
||||||
|
// light-DOM class its own wrapper puts on the instance it owns.
|
||||||
|
//
|
||||||
|
// Public API is instance methods: `open(title, content)`,
|
||||||
|
// `openNamed(name, title, content)`, `refresh(name, title, content)`,
|
||||||
|
// `close()`, `currentOwner()`. `open` is exactly `openNamed(null, title,
|
||||||
|
// content)` — untyped opens clear the owner, matching the dashboard's
|
||||||
|
// original semantics.
|
||||||
|
//
|
||||||
|
// Drag-to-resize + localStorage width persistence is ported from the
|
||||||
|
// dashboard's original `Panel` (the only one of the two originals that
|
||||||
|
// had it) — making it available to every consumer (the agent UI didn't
|
||||||
|
// have it before) is a deliberate behavior widening, not incidental.
|
||||||
|
|
||||||
|
import { el } from '../dom.js';
|
||||||
|
import { attachShadowCss } from '../shadow-css.js';
|
||||||
|
import sidePanelCss from './hive-side-panel.css';
|
||||||
|
|
||||||
|
// See docs/web-ui.md::Side panel for the hit-strip + pointer-capture +
|
||||||
|
// localStorage persistence model; CSS clamps the stored value to min
|
||||||
|
// 320px / max 96vw and out-of-range stored values are dropped silently.
|
||||||
|
// Shared by both packages deliberately — see module header.
|
||||||
|
const WIDTH_KEY = 'hyperhive:side-panel-width';
|
||||||
|
const WIDTH_MIN = 320;
|
||||||
|
|
||||||
|
class HiveSidePanel extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
const root = attachShadowCss(this, sidePanelCss);
|
||||||
|
this.setAttribute('aria-hidden', 'true');
|
||||||
|
this._owner = null;
|
||||||
|
|
||||||
|
this._titleEl = el('span', { class: 'side-panel-title', id: 'side-panel-title' });
|
||||||
|
this._closeBtn = el('button', {
|
||||||
|
type: 'button', class: 'side-panel-close', title: 'close (esc)',
|
||||||
|
}, '✕');
|
||||||
|
const head = el('header', { class: 'side-panel-head' }, this._titleEl, this._closeBtn);
|
||||||
|
|
||||||
|
this._resizeHandle = el('div', {
|
||||||
|
class: 'side-panel-resize',
|
||||||
|
role: 'separator',
|
||||||
|
'aria-orientation': 'vertical',
|
||||||
|
'aria-label': 'drag to resize side panel',
|
||||||
|
title: 'drag to resize',
|
||||||
|
});
|
||||||
|
|
||||||
|
this._bodyEl = el('div', { class: 'side-panel-body' }, el('slot'));
|
||||||
|
|
||||||
|
this._drawer = el('aside', {
|
||||||
|
class: 'side-panel-drawer',
|
||||||
|
role: 'dialog',
|
||||||
|
'aria-modal': 'true',
|
||||||
|
'aria-labelledby': 'side-panel-title',
|
||||||
|
}, this._resizeHandle, head, this._bodyEl);
|
||||||
|
|
||||||
|
this._backdrop = el('div', { class: 'side-panel-backdrop' });
|
||||||
|
|
||||||
|
root.append(this._backdrop, this._drawer);
|
||||||
|
|
||||||
|
this._closeBtn.addEventListener('click', () => this.close());
|
||||||
|
this._backdrop.addEventListener('click', () => this.close());
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && this.classList.contains('open')) this.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
this._applyStoredWidth();
|
||||||
|
this._bindResize();
|
||||||
|
}
|
||||||
|
|
||||||
|
open(title, content) {
|
||||||
|
this.openNamed(null, title, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
openNamed(name, title, content) {
|
||||||
|
this._owner = name;
|
||||||
|
this._titleEl.textContent = title;
|
||||||
|
this.replaceChildren(...(content ? [content] : []));
|
||||||
|
this.classList.add('open');
|
||||||
|
this.setAttribute('aria-hidden', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(name, title, content) {
|
||||||
|
if (this._owner !== name) return;
|
||||||
|
this._titleEl.textContent = title;
|
||||||
|
this.replaceChildren(...(content ? [content] : []));
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this._owner = null;
|
||||||
|
this.classList.remove('open');
|
||||||
|
this.setAttribute('aria-hidden', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
currentOwner() {
|
||||||
|
return this._owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
_clampWidth(w) {
|
||||||
|
// Use document.documentElement.clientWidth rather than window.innerWidth:
|
||||||
|
// clientWidth gives the actual CSS layout viewport width and is not
|
||||||
|
// altered by Firefox's Fingerprinting Protection (which rounds
|
||||||
|
// window.innerWidth/outerWidth to the nearest 200px).
|
||||||
|
const max = Math.floor(document.documentElement.clientWidth * 0.96);
|
||||||
|
return Math.max(WIDTH_MIN, Math.min(max, w));
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyStoredWidth() {
|
||||||
|
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;
|
||||||
|
this._drawer.style.setProperty('--side-panel-w', this._clampWidth(parsed) + 'px');
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindResize() {
|
||||||
|
const handle = this._resizeHandle;
|
||||||
|
const drawer = this._drawer;
|
||||||
|
let dragging = false;
|
||||||
|
handle.addEventListener('pointerdown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
document.body.classList.add('side-panel-resizing');
|
||||||
|
// Shadow-scoped counterpart of the global `body.side-panel-resizing`
|
||||||
|
// class below — the resize handle's own hover/drag appearance lives
|
||||||
|
// inside this element's shadow tree, which the global class (a
|
||||||
|
// light-DOM-only escape hatch for the page-wide cursor override)
|
||||||
|
// can't reach.
|
||||||
|
this.classList.add('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 = this._clampWidth(document.documentElement.clientWidth - e.clientX);
|
||||||
|
drawer.style.setProperty('--side-panel-w', w + 'px');
|
||||||
|
});
|
||||||
|
const stopDrag = () => {
|
||||||
|
if (!dragging) return;
|
||||||
|
dragging = false;
|
||||||
|
document.body.classList.remove('side-panel-resizing');
|
||||||
|
this.classList.remove('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 = this._clampWidth(cur);
|
||||||
|
if (clamped !== Math.round(cur)) {
|
||||||
|
drawer.style.setProperty('--side-panel-w', clamped + 'px');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('hive-side-panel', HiveSidePanel);
|
||||||
Loading…
Reference in a new issue