Compare commits
11 changed files with 390 additions and 395 deletions
|
|
@ -891,34 +891,83 @@ 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. The
|
Inbox + loose-ends details open here instead of expanding inline.
|
||||||
drawer chrome itself (backdrop, drawer, resize handle, header, title,
|
Copy of the dashboard's side-panel pattern — candidate for
|
||||||
close button) is now the shared `<hive-side-panel>` element
|
extraction into @hive/shared once both surfaces stabilize. */
|
||||||
(@hive/shared/side-panel.js — this also picks up drag-to-resize,
|
.side-panel {
|
||||||
which this page didn't have before); what's left here is the drag
|
position: fixed;
|
||||||
cursor-override escape hatch (has to be a global, light-DOM rule — it
|
inset: 0;
|
||||||
reaches every element on the page during a drag, not just the shared
|
z-index: 50;
|
||||||
element's own shadow tree) and the styling for the panel's own
|
/* Closed: ignore pointer events so the agent page underneath stays
|
||||||
content types, which the shared element's shadow tree can never
|
interactive; `.open` flips it back on. */
|
||||||
reach (see @hive/shared/side-panel/hive-side-panel.css's header
|
pointer-events: none;
|
||||||
comment). The `hive-side-panel .agent-inbox …` rules below use the
|
}
|
||||||
element's own tag name as the selector root — no compatibility class
|
.side-panel-backdrop {
|
||||||
needed, the tag name already uniquely identifies the light-DOM
|
position: absolute;
|
||||||
instance app.js's `sidePanel` creates. */
|
inset: 0;
|
||||||
body.side-panel-resizing {
|
background: rgba(0, 0, 0, 0.55);
|
||||||
user-select: none;
|
opacity: 0;
|
||||||
|
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. */
|
||||||
hive-side-panel .agent-inbox {
|
.side-panel-body .agent-inbox {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
hive-side-panel .agent-inbox ul {
|
.side-panel-body .agent-inbox ul {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ 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';
|
||||||
|
|
||||||
|
|
@ -38,17 +37,43 @@ 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) ──────
|
||||||
// The shared `<hive-side-panel>` element (see @hive/shared/side-panel.js
|
// Shared shape with the dashboard's panel. Candidate for extraction
|
||||||
// for the chrome/behavior it owns), created once, eagerly, when this
|
// into @hive/shared once both surfaces stabilise.
|
||||||
// module's IIFE runs (ES modules execute after the document is
|
const Panel = (() => {
|
||||||
// parsed, so `document.body` already exists). Used directly — this
|
const root = $('side-panel');
|
||||||
// UI's opens always take an owner name, so call sites use the
|
const titleEl = $('side-panel-title');
|
||||||
// element's own `openNamed(name, title, content)` rather than the
|
const bodyEl = $('side-panel-body');
|
||||||
// untyped `open`. `agent.css` reaches the slotted content via a plain
|
/** Owner key (e.g. 'inbox', 'loose-ends'). Refresh hooks check
|
||||||
// `hive-side-panel .agent-inbox …` tag-name selector (no compat class
|
* against this so a live event only re-renders the panel when
|
||||||
// needed — the element's own tag name already identifies it).
|
* the matching view is actually visible. null when closed. */
|
||||||
const sidePanel = document.createElement('hive-side-panel');
|
let owner = null;
|
||||||
document.body.append(sidePanel);
|
function open(name, title, content) {
|
||||||
|
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
|
||||||
|
|
@ -58,14 +83,14 @@ window.marked = marked;
|
||||||
const inboxPill = $('inbox-pill');
|
const inboxPill = $('inbox-pill');
|
||||||
if (inboxPill) {
|
if (inboxPill) {
|
||||||
inboxPill.addEventListener('click', () => {
|
inboxPill.addEventListener('click', () => {
|
||||||
sidePanel.openNamed('inbox', 'inbox · ' + lastInbox.length,
|
Panel.open('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', () => {
|
||||||
sidePanel.openNamed('todos', 'todos · ' + lastTodos.length,
|
Panel.open('todos', 'todos · ' + lastTodos.length,
|
||||||
buildTodosList(lastTodos));
|
buildTodosList(lastTodos));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -327,7 +352,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 (sidePanel).
|
// dismissal pattern matches the side-panel flyout (Panel).
|
||||||
(function bindOverflowMenu() {
|
(function bindOverflowMenu() {
|
||||||
const btn = $('overflow-btn');
|
const btn = $('overflow-btn');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|
@ -798,7 +823,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 `hive-side-panel .agent-inbox`.
|
// apply via `.side-panel-body .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' },
|
||||||
|
|
@ -898,7 +923,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;
|
||||||
sidePanel.refresh('todos', 'todos · ' + todos.length,
|
Panel.refresh('todos', 'todos · ' + todos.length,
|
||||||
buildTodosList(todos));
|
buildTodosList(todos));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1064,7 +1089,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;
|
||||||
sidePanel.refresh('inbox', 'inbox · ' + rows.length, buildInboxList(rows));
|
Panel.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,10 +93,21 @@
|
||||||
<div id="term-input" class="term-input"></div>
|
<div id="term-input" class="term-input"></div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<!-- Slide-in side panel (inbox / todos flyouts) is a <hive-side-panel>
|
<!-- Slide-in side panel. Singleton — JS swaps the title + body
|
||||||
element (@hive/shared/side-panel.js) — the Panel singleton in
|
and toggles `.open`. Shared shape with the dashboard's panel
|
||||||
app.js creates + appends it to <body> lazily on first use, so
|
(candidate for extraction into @hive/shared in a follow-up). -->
|
||||||
nothing 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>
|
||||||
|
|
||||||
<!-- 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, appendLinkified } from './common.js';
|
import { $, form, Panel, 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,59 +366,134 @@ code {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── side panel ─────────────────────────────────────────────────
|
/* ─── side panel ─────────────────────────────────────────────────
|
||||||
Long content (file previews, diffs, journald, applied config) opens in
|
Long content (file previews, diffs, journald, applied config)
|
||||||
a drawer that swipes in from the right — used by both /flow.html and
|
opens in a drawer that swipes in from the right — used by both
|
||||||
the dashboard. The drawer chrome itself (backdrop, drawer, resize
|
/flow.html and the dashboard. */
|
||||||
handle, header, title, close button) is now the shared
|
.side-panel {
|
||||||
`<hive-side-panel>` element (@hive/shared/side-panel.js); what's left
|
position: fixed;
|
||||||
here is the drag cursor-override escape hatch (has to be a global,
|
inset: 0;
|
||||||
light-DOM rule — it reaches every element on the page during a drag,
|
z-index: 200;
|
||||||
not just the shared element's own shadow tree) and the styling for
|
pointer-events: none;
|
||||||
the panel's own content types, which the shared element's shadow tree
|
}
|
||||||
can never reach (see @hive/shared/side-panel/hive-side-panel.css's
|
.side-panel-backdrop {
|
||||||
header comment). The `hive-side-panel .md …` rules below use the
|
position: absolute;
|
||||||
element's own tag name as the selector root — no compatibility class
|
inset: 0;
|
||||||
needed, the tag name already uniquely identifies the light-DOM
|
background: color-mix(in srgb, var(--crust) 55%, transparent);
|
||||||
instance `common.js`'s `sidePanel` creates. */
|
opacity: 0;
|
||||||
|
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; }
|
||||||
hive-side-panel .md { color: var(--fg); line-height: 1.5; }
|
.side-panel-head {
|
||||||
hive-side-panel .md > :first-child { margin-top: 0; }
|
display: flex;
|
||||||
hive-side-panel .md > :last-child { margin-bottom: 0; }
|
align-items: center;
|
||||||
hive-side-panel .md p { margin: 0.5em 0; }
|
gap: 0.6em;
|
||||||
hive-side-panel .md h1,
|
padding: 0.6em 1em;
|
||||||
hive-side-panel .md h2,
|
border-bottom: 1px solid var(--border);
|
||||||
hive-side-panel .md h3,
|
flex: none;
|
||||||
hive-side-panel .md h4 { color: var(--purple); margin: 0.9em 0 0.4em; }
|
}
|
||||||
hive-side-panel .md code {
|
.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 > :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;
|
||||||
}
|
}
|
||||||
hive-side-panel .md pre {
|
.side-panel-body .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;
|
||||||
}
|
}
|
||||||
hive-side-panel .md pre code { background: none; border: none; padding: 0; }
|
.side-panel-body .md pre code { background: none; border: none; padding: 0; }
|
||||||
hive-side-panel .md a { color: var(--cyan); }
|
.side-panel-body .md a { color: var(--cyan); }
|
||||||
hive-side-panel .md ul,
|
.side-panel-body .md ul,
|
||||||
hive-side-panel .md ol { margin: 0.4em 0; padding-left: 1.5em; }
|
.side-panel-body .md ol { margin: 0.4em 0; padding-left: 1.5em; }
|
||||||
hive-side-panel .md blockquote {
|
.side-panel-body .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);
|
||||||
}
|
}
|
||||||
hive-side-panel .md table { border-collapse: collapse; margin: 0.5em 0; }
|
.side-panel-body .md table { border-collapse: collapse; margin: 0.5em 0; }
|
||||||
hive-side-panel .md th,
|
.side-panel-body .md th,
|
||||||
hive-side-panel .md td {
|
.side-panel-body .md td {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0.2em 0.5em;
|
padding: 0.2em 0.5em;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
|
|
||||||
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 ────────────────────────────────────────────────────────────
|
||||||
|
|
@ -281,21 +280,141 @@ 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 `sidePanel.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 = (() => {
|
||||||
// The instance is created once, eagerly, at module evaluation time — ES
|
let root = null;
|
||||||
// modules run after the document is parsed (same timing `defer` scripts
|
let titleEl = null;
|
||||||
// get), so `document.body` already exists here. Exported directly (not
|
let bodyEl = null;
|
||||||
// wrapped in a forwarding object) — callers use the element's own
|
let drawer = null;
|
||||||
// `open`/`openNamed`/`refresh`/`close`/`currentOwner` methods, see
|
/** Owner key set by `openNamed` (e.g. 'inbox'). `refresh(name, …)`
|
||||||
// @hive/shared/side-panel.js for what they do. `common.css` reaches the
|
* is a no-op when the current owner doesn't match, so live
|
||||||
// slotted content via a plain `hive-side-panel .md …` tag-name selector
|
* updates can re-render an open view without grabbing focus
|
||||||
// (no compatibility class needed — the element's own tag name already
|
* from a closed one (or from an unrelated open view like a
|
||||||
// uniquely identifies it in the light DOM).
|
* diff drill-in). Untyped calls via `open(title, content)`
|
||||||
export const sidePanel = document.createElement('hive-side-panel');
|
* clear the owner — the legacy file-preview/diff/log paths
|
||||||
document.body.append(sidePanel);
|
* don't participate in named-refresh semantics. */
|
||||||
|
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
|
||||||
|
|
@ -386,14 +505,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);
|
||||||
sidePanel.open('↳ ' + path, img);
|
Panel.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…)';
|
||||||
sidePanel.open('↳ ' + path, view);
|
Panel.open('↳ ' + path, view);
|
||||||
try {
|
try {
|
||||||
const text = await fetchStateFile(path);
|
const text = await fetchStateFile(path);
|
||||||
if (isSvg) {
|
if (isSvg) {
|
||||||
|
|
|
||||||
|
|
@ -234,11 +234,23 @@
|
||||||
<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 (clicked file previews, approval diffs,
|
<!-- Slide-in detail panel. Long content (clicked file previews,
|
||||||
journald logs, applied config) is a <hive-side-panel> element
|
approval diffs, journald logs, applied config) opens here
|
||||||
(@hive/shared/side-panel.js) — the Panel singleton in common.js
|
instead of expanding inline. Singleton — JS swaps the title +
|
||||||
creates + appends it to <body> lazily on first use, so nothing
|
body and toggles `.open`. Lives outside the tab panes so it
|
||||||
needs to be pre-declared here. -->
|
overlays any active tab. -->
|
||||||
|
<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 {
|
||||||
$,
|
$,
|
||||||
NOTIF,
|
Panel, 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,9 +276,8 @@ 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. The
|
// mute flag the settings page sets, so no bind() is needed here.
|
||||||
// side panel (<hive-side-panel>) wires its own internals in
|
Panel.bind();
|
||||||
// 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,8 +19,7 @@
|
||||||
"./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/"
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
// 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