docs/web-ui.md duplicated the web-ui/ directory name at the top level -- the only such collision in docs/ (every other subsystem has just a directory, no sibling <dir>.md file). That's exactly why it rendered outside the directory structure in the docs site nav (mara's report, hyperhive#4054): the site build walks docs/ generically with no special-casing, so a loose top-level file next to a same-named directory shows up as its own flat top-level entry instead of nesting under that directory's section. web-ui.md's own first paragraph already said as much -- 'This doc has been split for readability... start at web-ui/README.md instead.' It was a leftover pointer from before the split, not a page carrying unique content on its own merit. Folded its two sections web-ui/README.md didn't already have (the swarm-ui design-guide link, and the task-oriented 'reading paths' quick-lookup list) into web-ui/README.md's existing 'More depth' section, then deleted the stray file and repointed every real reference at it: 3 in-tree doc cross-links, 3 doc prose mentions (retargeted to the more specific dashboard.md/shape.md sub-page each one was actually about), and ~28 frontend source comments (dashboard/agent/shared packages) that cited it as 'docs/web-ui.md::<heading>' for implementation context -- retargeted each to whichever of dashboard.md/shape.md/agent.md actually carries that heading now, verified against each file's real heading list rather than guessed. Verified via scripts/check-doc-refs.sh (the same lint CI runs): 0 dead pointers, both before write (confirming the tree was clean beforehand) and after (confirming nothing broke).
218 lines
7.5 KiB
JavaScript
218 lines
7.5 KiB
JavaScript
// 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 once,
|
|
// eagerly, by each package's own module (dashboard/src/common.js's
|
|
// `sidePanel`, agent/src/app.js's `sidePanel`) and used directly — no
|
|
// wrapper object, no per-call lazy-init guard.
|
|
//
|
|
// 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 via the element's own tag name as the
|
|
// selector root (`hive-side-panel .md`, `hive-side-panel .agent-inbox`)
|
|
// — no compatibility class needed.
|
|
//
|
|
// 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/agent.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);
|