Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
399a837e17 | ||
|
|
996899fcad | ||
|
|
c5610b075a |
11 changed files with 395 additions and 390 deletions
|
|
@ -891,83 +891,34 @@ 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). The `hive-side-panel .agent-inbox …` rules below use the
|
||||||
.side-panel-backdrop {
|
element's own tag name as the selector root — no compatibility class
|
||||||
position: absolute;
|
needed, the tag name already uniquely identifies the light-DOM
|
||||||
inset: 0;
|
instance app.js's `sidePanel` creates. */
|
||||||
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
|
||||||
let the panel body's own padding own the framing. */
|
let the panel body's own padding own the framing. */
|
||||||
.side-panel-body .agent-inbox {
|
hive-side-panel .agent-inbox {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
.side-panel-body .agent-inbox ul {
|
hive-side-panel .agent-inbox ul {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
|
||||||
|
|
@ -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,17 @@ 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
|
// The shared `<hive-side-panel>` element (see @hive/shared/side-panel.js
|
||||||
// into @hive/shared once both surfaces stabilise.
|
// for the chrome/behavior it owns), created once, eagerly, when this
|
||||||
const Panel = (() => {
|
// module's IIFE runs (ES modules execute after the document is
|
||||||
const root = $('side-panel');
|
// parsed, so `document.body` already exists). Used directly — this
|
||||||
const titleEl = $('side-panel-title');
|
// UI's opens always take an owner name, so call sites use the
|
||||||
const bodyEl = $('side-panel-body');
|
// element's own `openNamed(name, title, content)` rather than the
|
||||||
/** Owner key (e.g. 'inbox', 'loose-ends'). Refresh hooks check
|
// untyped `open`. `agent.css` reaches the slotted content via a plain
|
||||||
* against this so a live event only re-renders the panel when
|
// `hive-side-panel .agent-inbox …` tag-name selector (no compat class
|
||||||
* the matching view is actually visible. null when closed. */
|
// needed — the element's own tag name already identifies it).
|
||||||
let owner = null;
|
const sidePanel = document.createElement('hive-side-panel');
|
||||||
function open(name, title, content) {
|
document.body.append(sidePanel);
|
||||||
owner = name;
|
|
||||||
titleEl.textContent = title;
|
|
||||||
bodyEl.replaceChildren(...(content ? [content] : []));
|
|
||||||
root.classList.add('open');
|
|
||||||
root.setAttribute('aria-hidden', 'false');
|
|
||||||
}
|
|
||||||
function close() {
|
|
||||||
owner = null;
|
|
||||||
root.classList.remove('open');
|
|
||||||
root.setAttribute('aria-hidden', 'true');
|
|
||||||
}
|
|
||||||
function refresh(name, title, content) {
|
|
||||||
if (owner !== name) return;
|
|
||||||
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
|
||||||
|
|
@ -83,14 +58,14 @@ window.marked = marked;
|
||||||
const inboxPill = $('inbox-pill');
|
const inboxPill = $('inbox-pill');
|
||||||
if (inboxPill) {
|
if (inboxPill) {
|
||||||
inboxPill.addEventListener('click', () => {
|
inboxPill.addEventListener('click', () => {
|
||||||
Panel.open('inbox', 'inbox · ' + lastInbox.length,
|
sidePanel.openNamed('inbox', 'inbox · ' + lastInbox.length,
|
||||||
buildInboxList(lastInbox));
|
buildInboxList(lastInbox));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const todosPill = $('todos-pill');
|
const todosPill = $('todos-pill');
|
||||||
if (todosPill) {
|
if (todosPill) {
|
||||||
todosPill.addEventListener('click', () => {
|
todosPill.addEventListener('click', () => {
|
||||||
Panel.open('todos', 'todos · ' + lastTodos.length,
|
sidePanel.openNamed('todos', 'todos · ' + lastTodos.length,
|
||||||
buildTodosList(lastTodos));
|
buildTodosList(lastTodos));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -352,7 +327,7 @@ window.marked = marked;
|
||||||
else closeOverflowMenu();
|
else closeOverflowMenu();
|
||||||
}
|
}
|
||||||
// Wire once on boot. The trigger itself + click-outside + Escape
|
// Wire once on boot. The trigger itself + click-outside + Escape
|
||||||
// dismissal pattern matches the side-panel flyout (Panel).
|
// dismissal pattern matches the side-panel flyout (sidePanel).
|
||||||
(function bindOverflowMenu() {
|
(function bindOverflowMenu() {
|
||||||
const btn = $('overflow-btn');
|
const btn = $('overflow-btn');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|
@ -823,7 +798,7 @@ window.marked = marked;
|
||||||
function buildLooseEndsList(threads) {
|
function buildLooseEndsList(threads) {
|
||||||
// Returns the <div> the side panel renders. The structural shape
|
// Returns the <div> the side panel renders. The structural shape
|
||||||
// mirrors the legacy <details>-collapsible block — same CSS rules
|
// mirrors the legacy <details>-collapsible block — same CSS rules
|
||||||
// apply via `.side-panel-body .agent-inbox`.
|
// apply via `hive-side-panel .agent-inbox`.
|
||||||
const wrap = el('div', { class: 'agent-inbox' });
|
const wrap = el('div', { class: 'agent-inbox' });
|
||||||
if (!threads.length) {
|
if (!threads.length) {
|
||||||
wrap.append(el('p', { class: 'side-panel-empty' },
|
wrap.append(el('p', { class: 'side-panel-empty' },
|
||||||
|
|
@ -923,7 +898,7 @@ window.marked = marked;
|
||||||
const count = $('todos-count');
|
const count = $('todos-count');
|
||||||
if (count) count.textContent = todos.length;
|
if (count) count.textContent = todos.length;
|
||||||
if (pill) pill.hidden = todos.length === 0;
|
if (pill) pill.hidden = todos.length === 0;
|
||||||
Panel.refresh('todos', 'todos · ' + todos.length,
|
sidePanel.refresh('todos', 'todos · ' + todos.length,
|
||||||
buildTodosList(todos));
|
buildTodosList(todos));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1089,7 +1064,7 @@ window.marked = marked;
|
||||||
const count = $('inbox-count');
|
const count = $('inbox-count');
|
||||||
if (count) count.textContent = rows.length;
|
if (count) count.textContent = rows.length;
|
||||||
if (pill) pill.hidden = rows.length === 0;
|
if (pill) pill.hidden = rows.length === 0;
|
||||||
Panel.refresh('inbox', 'inbox · ' + rows.length, buildInboxList(rows));
|
sidePanel.refresh('inbox', 'inbox · ' + rows.length, buildInboxList(rows));
|
||||||
}
|
}
|
||||||
// Harness reachability badge: derived from the same `s.status` the
|
// Harness reachability badge: derived from the same `s.status` the
|
||||||
// status block reads. Each status maps to a glyph + label + colour
|
// status block reads. Each status maps to a glyph + label + colour
|
||||||
|
|
|
||||||
|
|
@ -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. -->
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
// live-mutation paths call an injected `onCountsChanged` callback the entry
|
// live-mutation paths call an injected `onCountsChanged` callback the entry
|
||||||
// registers once via `initCall`.
|
// registers once via `initCall`.
|
||||||
|
|
||||||
import { $, form, Panel, appendLinkified } from './common.js';
|
import { $, form, appendLinkified } from './common.js';
|
||||||
import { el } from '@hive/shared/dom.js';
|
import { el } from '@hive/shared/dom.js';
|
||||||
import { themedToast } from '@hive/shared/modal.js';
|
import { themedToast } from '@hive/shared/modal.js';
|
||||||
import { epochSec, fmtAgo, fmtDuration } from './util.js';
|
import { epochSec, fmtAgo, fmtDuration } from './util.js';
|
||||||
|
|
|
||||||
|
|
@ -366,134 +366,59 @@ 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). The `hive-side-panel .md …` rules below use the
|
||||||
inset: 0;
|
element's own tag name as the selector root — no compatibility class
|
||||||
background: color-mix(in srgb, var(--crust) 55%, transparent);
|
needed, the tag name already uniquely identifies the light-DOM
|
||||||
opacity: 0;
|
instance `common.js`'s `sidePanel` creates. */
|
||||||
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 {
|
hive-side-panel .md { color: var(--fg); line-height: 1.5; }
|
||||||
display: flex;
|
hive-side-panel .md > :first-child { margin-top: 0; }
|
||||||
align-items: center;
|
hive-side-panel .md > :last-child { margin-bottom: 0; }
|
||||||
gap: 0.6em;
|
hive-side-panel .md p { margin: 0.5em 0; }
|
||||||
padding: 0.6em 1em;
|
hive-side-panel .md h1,
|
||||||
border-bottom: 1px solid var(--border);
|
hive-side-panel .md h2,
|
||||||
flex: none;
|
hive-side-panel .md h3,
|
||||||
}
|
hive-side-panel .md h4 { color: var(--purple); margin: 0.9em 0 0.4em; }
|
||||||
.side-panel-title {
|
hive-side-panel .md code {
|
||||||
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 > :first-child { margin-top: 0; }
|
|
||||||
.side-panel-body .md > :last-child { margin-bottom: 0; }
|
|
||||||
.side-panel-body .md p { margin: 0.5em 0; }
|
|
||||||
.side-panel-body .md h1,
|
|
||||||
.side-panel-body .md h2,
|
|
||||||
.side-panel-body .md h3,
|
|
||||||
.side-panel-body .md h4 { color: var(--purple); margin: 0.9em 0 0.4em; }
|
|
||||||
.side-panel-body .md code {
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
padding: 0.1em 0.3em;
|
padding: 0.1em 0.3em;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
.side-panel-body .md pre {
|
hive-side-panel .md pre {
|
||||||
background: var(--crust);
|
background: var(--crust);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0.5em 0.7em;
|
padding: 0.5em 0.7em;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
}
|
}
|
||||||
.side-panel-body .md pre code { background: none; border: none; padding: 0; }
|
hive-side-panel .md pre code { background: none; border: none; padding: 0; }
|
||||||
.side-panel-body .md a { color: var(--cyan); }
|
hive-side-panel .md a { color: var(--cyan); }
|
||||||
.side-panel-body .md ul,
|
hive-side-panel .md ul,
|
||||||
.side-panel-body .md ol { margin: 0.4em 0; padding-left: 1.5em; }
|
hive-side-panel .md ol { margin: 0.4em 0; padding-left: 1.5em; }
|
||||||
.side-panel-body .md blockquote {
|
hive-side-panel .md blockquote {
|
||||||
border-left: 3px solid var(--purple-dim);
|
border-left: 3px solid var(--purple-dim);
|
||||||
padding-left: 0.8em;
|
padding-left: 0.8em;
|
||||||
margin: 0.4em 0;
|
margin: 0.4em 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.side-panel-body .md table { border-collapse: collapse; margin: 0.5em 0; }
|
hive-side-panel .md table { border-collapse: collapse; margin: 0.5em 0; }
|
||||||
.side-panel-body .md th,
|
hive-side-panel .md th,
|
||||||
.side-panel-body .md td {
|
hive-side-panel .md td {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0.2em 0.5em;
|
padding: 0.2em 0.5em;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ────────────────────────────────────────────────────────────
|
||||||
|
|
@ -280,141 +281,21 @@ export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
|
||||||
// ─── side panel ─────────────────────────────────────────────────────────
|
// ─── side panel ─────────────────────────────────────────────────────────
|
||||||
// Singleton drawer that swipes in from the right. Long content
|
// Singleton drawer that swipes in from the right. Long content
|
||||||
// (file previews, approval diffs, journald logs, applied config)
|
// (file previews, approval diffs, journald logs, applied config)
|
||||||
// opens here via `Panel.open(title, node)` instead of expanding
|
// opens here via `sidePanel.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;
|
// The instance is created once, eagerly, at module evaluation time — ES
|
||||||
let titleEl = null;
|
// modules run after the document is parsed (same timing `defer` scripts
|
||||||
let bodyEl = null;
|
// get), so `document.body` already exists here. Exported directly (not
|
||||||
let drawer = null;
|
// wrapped in a forwarding object) — callers use the element's own
|
||||||
/** Owner key set by `openNamed` (e.g. 'inbox'). `refresh(name, …)`
|
// `open`/`openNamed`/`refresh`/`close`/`currentOwner` methods, see
|
||||||
* is a no-op when the current owner doesn't match, so live
|
// @hive/shared/side-panel.js for what they do. `common.css` reaches the
|
||||||
* updates can re-render an open view without grabbing focus
|
// slotted content via a plain `hive-side-panel .md …` tag-name selector
|
||||||
* from a closed one (or from an unrelated open view like a
|
// (no compatibility class needed — the element's own tag name already
|
||||||
* diff drill-in). Untyped calls via `open(title, content)`
|
// uniquely identifies it in the light DOM).
|
||||||
* clear the owner — the legacy file-preview/diff/log paths
|
export const sidePanel = document.createElement('hive-side-panel');
|
||||||
* don't participate in named-refresh semantics. */
|
document.body.append(sidePanel);
|
||||||
let owner = null;
|
|
||||||
function ensure() {
|
|
||||||
if (!root) {
|
|
||||||
root = $('side-panel');
|
|
||||||
titleEl = $('side-panel-title');
|
|
||||||
bodyEl = $('side-panel-body');
|
|
||||||
drawer = root && root.querySelector('.side-panel-drawer');
|
|
||||||
}
|
|
||||||
return root != null;
|
|
||||||
}
|
|
||||||
function open(title, content) {
|
|
||||||
if (!ensure()) return;
|
|
||||||
owner = null;
|
|
||||||
titleEl.textContent = title;
|
|
||||||
bodyEl.replaceChildren(...(content ? [content] : []));
|
|
||||||
root.classList.add('open');
|
|
||||||
root.setAttribute('aria-hidden', 'false');
|
|
||||||
}
|
|
||||||
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
|
||||||
|
|
@ -505,14 +386,14 @@ async function openFilePanel(path) {
|
||||||
'(could not load image — it may be missing or over the preview size cap)'));
|
'(could not load image — it may be missing or over the preview size cap)'));
|
||||||
});
|
});
|
||||||
img.src = '/api/state-file?path=' + encodeURIComponent(path);
|
img.src = '/api/state-file?path=' + encodeURIComponent(path);
|
||||||
Panel.open('↳ ' + path, img);
|
sidePanel.open('↳ ' + path, img);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isMd = /\.(md|markdown)$/i.test(path);
|
const isMd = /\.(md|markdown)$/i.test(path);
|
||||||
const isSvg = /\.svg$/i.test(path);
|
const isSvg = /\.svg$/i.test(path);
|
||||||
const view = el('div');
|
const view = el('div');
|
||||||
view.textContent = '(fetching…)';
|
view.textContent = '(fetching…)';
|
||||||
Panel.open('↳ ' + path, view);
|
sidePanel.open('↳ ' + path, view);
|
||||||
try {
|
try {
|
||||||
const text = await fetchStateFile(path);
|
const text = await fetchStateFile(path);
|
||||||
if (isSvg) {
|
if (isSvg) {
|
||||||
|
|
|
||||||
|
|
@ -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. The
|
||||||
Panel.bind();
|
// side panel (<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 (`hive-side-panel .md` in the dashboard,
|
||||||
|
`hive-side-panel .agent-inbox` in the agent UI) live in that package's
|
||||||
|
own global stylesheet and reach the slotted content via the element's
|
||||||
|
own tag name as the selector root — no compatibility class needed,
|
||||||
|
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;
|
||||||
|
}
|
||||||
183
frontend/packages/shared/src/side-panel/hive-side-panel.js
Normal file
183
frontend/packages/shared/src/side-panel/hive-side-panel.js
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
// 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.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