Compare commits

...
Author SHA1 Message Date
iris
8ebefeb0d5 frontend: move hive-menu's reachable dropdown/trigger chrome out of the caller stylesheet
Per mara's review on #2881: the dropdown box chrome (background/border/
radius/shadow/min-width/white-space) and the trigger button's base
icon-button treatment are both reachable from hive-menu.css now --
the box chrome lives on hive-menu's own shadow-owned .menu-dropdown
wrapper (no slotting constraint at all), and the trigger button is
styled via ::slotted([slot='trigger']) since it's the top-level slotted
node for that slot. Item-row styling stays in the caller's stylesheet
-- ::slotted() only reaches directly-slotted elements, not their
descendants, so individual dropdown items are architecturally
unreachable from hive-menu's shadow tree. Verified interactively via
headless Chromium/CDP: trigger opacity/hover/border-radius and the
dropdown wrapper's background/border all resolve correctly, hover and
click-to-open still work.
2026-07-31 23:24:22 +02:00
iris
395c9a6df2 frontend: split hive-agent-menu's generic dropdown mechanics into a shared hive-menu component
<hive-agent-menu> bundled two concerns: the agent-specific trigger/item
list, and generic "click a trigger, get a positioned dropdown" mechanics
(shadow attach, open/close, singleton close-on-open coordination,
outside-click/Escape handling). Pulled the latter out into a new
@hive/shared/hive-menu.js (<hive-menu>), following the established
per-component-directory + ._opts-before-append shadow-DOM pattern
(<hive-dialog>). <hive-agent-menu> now just builds the "⋮" trigger and
the action list and hands them to an internal <hive-menu> instance.

<hive-menu> takes ownership of every <hive-menu> instance in the app for
singleton coordination (closeAllMenus, renamed from closeAllAgentMenus)
— a deliberate widening from the old per-agent-menu-only tracking, since
the mechanism was never agent-specific to begin with.

The one subtlety worth spelling out: <hive-menu> projects the caller's
opaque trigger/content nodes via named <slot>s rather than moving them
into its own shadow root. That's load-bearing, not cosmetic — if it
re-parented them into its own shadow tree instead, <hive-agent-menu>'s
own classes (.agent-menu-btn, .agent-menu-item, ...) would stop applying,
since a <style> only styles elements within the same shadow tree/document
it's part of, and only slotting (not re-parenting) keeps the caller's
nodes in the caller's own tree for styling purposes. That in turn made
<hive-agent-menu>'s own shadow root redundant once it wasn't the thing
positioning or owning open/close state anymore, so it's dropped in favor
of a plain light-DOM element styled by dashboard.css (already the one
page it renders on) — hive-agent-menu.css is gone, its rules folded into
dashboard.css's per-agent-menu section, minus the positioning rules that
moved into hive-menu.css as the new generic `.menu-dropdown` wrapper.

Verified with a standalone esbuild bundle + a cached nix chromium driven
over raw CDP (no puppeteer/playwright/python3 available): hover-reveal
opacity, dropdown open/close/positioning, outside-click/Escape dismissal,
and cross-instance singleton coordination all behave identically to
before the split.
2026-07-31 23:24:22 +02:00
iris
f7b19c9d56 dashboard: convert the per-agent ⋮ context menu to a hive-agent-menu shadow-DOM component
Moves buildAgentMenu's DOM-building body, the menuItem/menuSep/menuLink
helpers, agentMenuPost, and the open-dropdown coordination logic out of
swarm.js and into a new <hive-agent-menu> autonomous custom element
(dashboard/src/agent-menu/), following the same shadow-DOM + one-dir-per-
component shape as hive-dialog. swarm.js's buildAgentMenu is now a thin
wrapper that constructs the element and sets ._opts before appending it,
same convention hive-dialog uses since a custom element created via
document.createElement can't take constructor args.

The module-level "one dropdown open at a time" singleton (previously a
single mutable variable in swarm.js) becomes a tracked Set of open
instances inside the component module; each instance closes itself via
its own close() method rather than another instance reaching into its
shadow internals. The document-level outside-click and Escape listeners
move into the component module too, keyed off e.composedPath() instead of
e.target.closest() -- shadow-DOM event retargeting means a plain
e.target check no longer reliably reaches into a specific instance's
shadow tree. closeAllAgentMenus() is exported for swarm.js's
buildAgentTree, which still needs to close any open menu before it
replaces the container tree DOM.

The hover-reveal opacity rule crosses the shadow boundary via a
--menu-btn-opacity custom property (custom properties inherit through
shadow boundaries): dashboard.css sets it on hover of the light-DOM
hive-agent-menu element, and the component sets it directly from JS while
its own dropdown is open, since that's component-internal state a CSS
selector out in the light DOM can't see. The host element itself takes on
the structural role (flex:none, position:relative, ...) the old
light-DOM .agent-menu wrapper div played, since its shadow tree's
absolute-positioned dropdown needs a positioned ancestor to anchor off of.

Verified end to end with a standalone esbuild-bundled test harness run
under headless chromium: row layout/flex sizing, hover-reveal opacity,
and dropdown positioning all render correctly, and a scripted interaction
pass (singleton exclusivity, outside-click close, Escape close, toggle
behavior, menu-item click close, and the exported closeAllAgentMenus())
all pass.
2026-07-31 23:24:22 +02:00
iris
ed92883294 frontend/shared: add attachShadowCss, the shared shadow-DOM style-attach helper
Pulls the attachShadow-plus-plain-<style>-tag boilerplate that hive-dialog
and hive-toast already build per component into one shared helper, so the
next shadow-DOM custom element (the agent context menu, next commit) can
reuse it instead of re-deriving the same few lines. A plain <style>
element rather than a constructed CSSStyleSheet with adoptedStyleSheets --
each component instance builds its own fresh stylesheet per connect with
no sharing across instances, so adoptedStyleSheets buys nothing here over
the plain, universally-understood <style> tag.
2026-07-31 23:24:22 +02:00
6 changed files with 404 additions and 228 deletions

View file

@ -0,0 +1,181 @@
// hive-agent-menu.js — <hive-agent-menu>, the per-agent "⋮" context-menu
// custom element. One instance per agent card, appended after
// .card-body in the container row's flex layout (swarm.js's
// buildContainerLi via buildAgentMenu).
//
// Deliberately a *light-DOM* element with no shadow root of its own: all
// the generic dropdown-menu mechanics (shadow attach, open/close state,
// positioning, singleton coordination, outside-click/Escape) now live in
// the shared `<hive-menu>` component (@hive/shared/hive-menu.js) — this
// element's whole job is building the agent-specific trigger button +
// item list and handing them to an internal `<hive-menu>` via its
// `._opts = { trigger, content }` contract, same convention `<hive-
// dialog>` uses (custom elements can't take constructor args via
// `document.createElement`). Its own shadow root would've bought nothing
// once `<hive-menu>` owns shadow/positioning/open-close, so it doesn't
// have one — which means its `.agent-menu-*` classes need a stylesheet
// that actually reaches them. Since `<hive-menu>` slots this element's
// nodes in rather than moving them into its own shadow root (see
// hive-menu.js's header for why), they stay in the light DOM the whole
// way down — reachable by dashboard.css's ordinary global rules (there's
// no hive-agent-menu.css anymore; its old rules moved there, minus the
// pure positioning rule which is now generic and lives in `<hive-menu>`'s
// own shadow-scoped `.menu-dropdown`).
import { el } from '@hive/shared/dom.js';
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
import '@hive/shared/hive-menu.js'; // registers <hive-menu> — side-effect import
// Single-agent POST helper shared by all menu items.
async function agentMenuPost(actionPath, name, body, graceful) {
const url = actionPath + encodeURIComponent(name) + (graceful ? '?graceful=true' : '');
try {
const resp = await fetch(url, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {},
body: body ? new URLSearchParams(body) : undefined,
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
}
} catch (err) {
themedToast('action failed: ' + err, { type: 'error' });
}
}
class HiveAgentMenu extends HTMLElement {
connectedCallback() {
const { c, forgeBase } = this._opts || {};
const btn = el('button', {
type: 'button',
class: 'agent-menu-btn',
title: `actions for ${c.name}`,
'aria-label': `actions for ${c.name}`,
'aria-haspopup': 'menu',
'aria-expanded': 'false',
}, '⋮');
const dropdown = el('ul', { class: 'agent-menu-dropdown', role: 'menu' });
const close = () => this._menu.close();
const menuItem = (label, opts) => {
const li = el('li', { role: 'presentation' });
const item = el('button', {
type: 'button',
class: 'agent-menu-item',
role: 'menuitem',
}, label);
item.addEventListener('click', async () => {
close();
let graceful = false;
if (opts.confirm) {
const r = await themedConfirm({
message: opts.confirm,
danger: true,
confirmLabel: opts.confirmLabel || 'confirm',
checkboxes: opts.graceful
? [{ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let the agent finish its turn and flush state before the container stops' }]
: [],
});
if (!r) return;
graceful = !!r.graceful;
}
await agentMenuPost(opts.action, c.name, opts.body || null, graceful);
});
li.append(item);
return li;
};
const menuSep = () => el('li', { class: 'agent-menu-sep', role: 'separator' });
// Navigation link item (opens in same tab by default).
const menuLink = (label, href, title) => {
const li = el('li', { role: 'presentation' });
const a = el('a', {
class: 'agent-menu-item',
href,
role: 'menuitem',
title: title || '',
}, label);
a.addEventListener('click', close);
li.append(a);
return li;
};
// Show only actions that are applicable in the current state.
if (c.running) {
dropdown.append(
menuItem('↺ R3ST4RT', {
action: '/api/restart/',
confirm: `restart ${c.name}?`,
graceful: true,
gracefulLabel: 'restart gracefully — let the agent finish its turn and flush state before the container restarts',
}),
menuItem('■ ST0P', { action: '/api/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }),
);
} else {
dropdown.append(
menuItem('▶ ST4RT', { action: '/api/start/', confirm: `start ${c.name}?` }),
);
}
// Pause/resume is orthogonal to running: a paused stopped agent boots
// paused; a paused running agent keeps its container but drives no turns.
if (c.paused) {
dropdown.append(
menuItem('▶ R3SUM3', { action: '/api/resume/', confirm: `resume ${c.name}? the turn loop restarts and drains queued messages.` }),
);
} else {
dropdown.append(
menuItem('⏸ P4US3', { action: '/api/pause/', confirm: `pause ${c.name}? parks the turn loop — inbox messages queue unacked.` }),
);
}
dropdown.append(
menuSep(),
menuItem('↻ R3BU1LD', { action: '/api/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }),
menuSep(),
// Deep-link to the AGENT log tab pre-filtered to this container.
// The ?agent= param is read by logs.js on load and pre-selects this
// agent's journal without extra clicks.
menuLink('journal logs →',
`/logs.html?agent=${encodeURIComponent(c.name)}#agent`,
`view ${c.name} journal logs`),
);
dropdown.append(
menuSep(),
menuItem('DESTR0Y', {
action: '/api/destroy/',
confirm: `destroy ${c.name}? container removed; state + creds kept.`,
}),
menuItem('PURG3', {
action: '/api/destroy/',
body: { purge: 'on' },
confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
}),
);
if (c.deployed_sha && forgeBase) {
const li = el('li', { role: 'presentation' });
const a = el('a', {
class: 'agent-menu-item',
href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`,
target: '_blank',
rel: 'noopener',
role: 'menuitem',
title: 'deployed config commit on forge',
}, `deployed:${c.deployed_sha}`);
li.append(a);
dropdown.append(menuSep(), li);
}
this._menu = document.createElement('hive-menu');
this._menu._opts = { trigger: btn, content: dropdown };
this.append(this._menu);
}
}
customElements.define('hive-agent-menu', HiveAgentMenu);

View file

@ -279,53 +279,41 @@ body.dashboard-shell {
}
/* per-agent three-dot context menu
Positioned after .card-body in the flex row. */
.agent-menu {
Positioned after .card-body in the flex row. `<hive-agent-menu>`
(agent-menu/hive-agent-menu.js) is a light-DOM element with no shadow
root of its own the generic dropdown mechanics (shadow attach,
open/close, positioning) live in the shared `<hive-menu>` component
instead (@hive/shared/hive-menu.js), which slots this element's
trigger/content nodes into position rather than moving them into its
own shadow root. That keeps these nodes in the ordinary light DOM the
whole way down, so this ordinary global stylesheet reaches them
directly see hive-agent-menu.js's header for the full reasoning.
`hive-agent-menu` itself just needs the flex/alignment role the old
light-DOM `.agent-menu` wrapper div played in this row; the
`position: relative` an absolutely-positioned dropdown needs now lives
on `<hive-menu>`'s own `:host` instead, since that's the element whose
shadow tree the dropdown is actually positioned within. */
hive-agent-menu {
flex: none;
position: relative;
align-self: flex-start;
margin-top: 0.3em;
}
.agent-menu-btn {
display: block;
background: none;
border: none;
color: var(--subtext0);
font-size: 1.1em;
line-height: 1;
cursor: pointer;
padding: 0.1em 0.4em;
border-radius: 4px;
opacity: 0;
transition: opacity 120ms, background 120ms, color 120ms;
}
.container-row:hover .agent-menu-btn,
.agent-menu.open .agent-menu-btn {
opacity: 1;
}
.agent-menu-btn:hover,
.agent-menu-btn:focus-visible {
background: color-mix(in srgb, var(--purple) 10%, transparent);
color: var(--purple);
outline: none;
}
.agent-menu-btn:focus-visible {
outline: 1px solid var(--purple);
}
/* .agent-menu-btn carries no styling of its own anymore the trigger is
a top-level `slot="trigger"` node inside <hive-menu>'s shadow tree, so
its base icon-button chrome now lives in hive-menu.css's
`::slotted([slot='trigger'])` rules instead (reachable and genuinely
generic any <hive-menu> trigger gets the same treatment). The class
itself stays, just as an identification hook (aria-label/title
already carry the real semantics). */
/* Box chrome (background/border/radius/shadow/min-width/white-space) now
lives on <hive-menu>'s own `.menu-dropdown` wrapper (shadow-DOM-owned
markup, no slotting constraint) this is just the `<ul>`'s own
list-reset + item padding, which only the caller (owning the actual
list markup) can set. */
.agent-menu-dropdown {
position: absolute;
right: 0;
top: calc(100% + 2px);
z-index: 50;
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
list-style: none;
margin: 0;
padding: 0.3em 0;
min-width: 10em;
white-space: nowrap;
}
.agent-menu-item {
display: block;
@ -352,6 +340,13 @@ body.dashboard-shell {
margin: 0.3em 0;
padding: 0;
}
/* Hover reveal: `--menu-btn-opacity` is forced to 1 while a hover
selector (or the trigger's own open state, set by <hive-menu>) applies
inherits down through `hive-agent-menu` `<hive-menu>` the
trigger button same as any custom property, shadow trees included. */
.container-row:hover hive-agent-menu {
--menu-btn-opacity: 1;
}
/* Pending state splits queued vs running. */
.container-row.pending .actions { opacity: 0.4; pointer-events: none; }
.container-row.pending-running {

View file

@ -11,6 +11,8 @@ import { themedConfirm, themedToast } from '@hive/shared/modal.js';
import {
containersState, questionsState,
} from './state.js';
import { closeAllMenus } from '@hive/shared/hive-menu.js';
import './agent-menu/hive-agent-menu.js'; // registers <hive-agent-menu> — side-effect import
// Context-window badge thresholds. Preferred source is each container's
// `context_window_tokens` from /api/state (the real window for the model
@ -45,8 +47,6 @@ const transientsState = new Map();
// tab-gated visibility).
const selectionState = new Set();
let openAgentMenu = null; // currently open dropdown element, or null
// ─── rebuild queue ──────────────────────────────────────────────────────────
export function syncRebuildQueueFromSnapshot(s) {
@ -186,192 +186,19 @@ document.addEventListener('click', (e) => {
// stopped; rebuild + destroy/purge always shown.
// The button is CSS-invisible until the row is hovered (or menu is
// open) so it doesn't clutter quiet rows.
function closeAllAgentMenus() {
if (!openAgentMenu) return;
openAgentMenu.hidden = true;
const wrap = openAgentMenu.closest('.agent-menu');
if (wrap) {
wrap.classList.remove('open');
const btn = wrap.querySelector('.agent-menu-btn');
if (btn) btn.setAttribute('aria-expanded', 'false');
}
openAgentMenu = null;
}
// Close on any click outside an agent-menu element.
document.addEventListener('click', (e) => {
if (!e.target.closest('.agent-menu')) closeAllAgentMenus();
}, true);
// Close on Escape. stopImmediatePropagation so the selection-clear
// handler on the same element doesn't also fire when a menu is open.
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && openAgentMenu) {
closeAllAgentMenus();
e.stopImmediatePropagation();
}
}, true);
// Single-agent POST helper shared by all menu items.
async function agentMenuPost(actionPath, name, body, graceful) {
const url = actionPath + encodeURIComponent(name) + (graceful ? '?graceful=true' : '');
try {
const resp = await fetch(url, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {},
body: body ? new URLSearchParams(body) : undefined,
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
}
} catch (err) {
themedToast('action failed: ' + err, { type: 'error' });
}
}
// Rendering + agent-specific interaction lives in the <hive-agent-menu>
// custom element (./agent-menu/hive-agent-menu.js, imported above for its
// customElements.define side effect); the generic dropdown mechanics
// (open/close, positioning, singleton coordination — closed via
// `closeAllMenus` below) live in the shared `<hive-menu>` component it
// composes internally (@hive/shared/hive-menu.js). This is a thin
// wrapper matching <hive-dialog>'s `._opts`-before-append convention,
// since a custom element created via `document.createElement` can't
// take constructor args.
function buildAgentMenu(c, forgeBase) {
const wrap = el('div', { class: 'agent-menu' });
const btn = el('button', {
type: 'button',
class: 'agent-menu-btn',
title: `actions for ${c.name}`,
'aria-label': `actions for ${c.name}`,
'aria-haspopup': 'menu',
'aria-expanded': 'false',
}, '⋮');
const dropdown = el('ul', { class: 'agent-menu-dropdown', hidden: true, role: 'menu' });
function menuItem(label, opts) {
const li = el('li', { role: 'presentation' });
const item = el('button', {
type: 'button',
class: 'agent-menu-item',
role: 'menuitem',
}, label);
item.addEventListener('click', async () => {
closeAllAgentMenus();
let graceful = false;
if (opts.confirm) {
const r = await themedConfirm({
message: opts.confirm,
danger: true,
confirmLabel: opts.confirmLabel || 'confirm',
checkboxes: opts.graceful
? [{ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let the agent finish its turn and flush state before the container stops' }]
: [],
});
if (!r) return;
graceful = !!r.graceful;
}
await agentMenuPost(opts.action, c.name, opts.body || null, graceful);
});
li.append(item);
return li;
}
function menuSep() {
return el('li', { class: 'agent-menu-sep', role: 'separator' });
}
// Navigation link item (opens in same tab by default).
function menuLink(label, href, title) {
const li = el('li', { role: 'presentation' });
const a = el('a', {
class: 'agent-menu-item',
href,
role: 'menuitem',
title: title || '',
}, label);
a.addEventListener('click', () => closeAllAgentMenus());
li.append(a);
return li;
}
// Show only actions that are applicable in the current state.
if (c.running) {
dropdown.append(
menuItem('↺ R3ST4RT', {
action: '/api/restart/',
confirm: `restart ${c.name}?`,
graceful: true,
gracefulLabel: 'restart gracefully — let the agent finish its turn and flush state before the container restarts',
}),
menuItem('■ ST0P', { action: '/api/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }),
);
} else {
dropdown.append(
menuItem('▶ ST4RT', { action: '/api/start/', confirm: `start ${c.name}?` }),
);
}
// Pause/resume is orthogonal to running: a paused stopped agent boots
// paused; a paused running agent keeps its container but drives no turns.
if (c.paused) {
dropdown.append(
menuItem('▶ R3SUM3', { action: '/api/resume/', confirm: `resume ${c.name}? the turn loop restarts and drains queued messages.` }),
);
} else {
dropdown.append(
menuItem('⏸ P4US3', { action: '/api/pause/', confirm: `pause ${c.name}? parks the turn loop — inbox messages queue unacked.` }),
);
}
dropdown.append(
menuSep(),
menuItem('↻ R3BU1LD', { action: '/api/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }),
menuSep(),
// Deep-link to the AGENT log tab pre-filtered to this container.
// The ?agent= param is read by logs.js on load and pre-selects this
// agent's journal without extra clicks.
menuLink('journal logs →',
`/logs.html?agent=${encodeURIComponent(c.name)}#agent`,
`view ${c.name} journal logs`),
);
dropdown.append(
menuSep(),
menuItem('DESTR0Y', {
action: '/api/destroy/',
confirm: `destroy ${c.name}? container removed; state + creds kept.`,
}),
menuItem('PURG3', {
action: '/api/destroy/',
body: { purge: 'on' },
confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
}),
);
if (c.deployed_sha && forgeBase) {
const li = el('li', { role: 'presentation' });
const a = el('a', {
class: 'agent-menu-item',
href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`,
target: '_blank',
rel: 'noopener',
role: 'menuitem',
title: 'deployed config commit on forge',
}, `deployed:${c.deployed_sha}`);
li.append(a);
dropdown.append(menuSep(), li);
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
const wasHidden = dropdown.hidden;
closeAllAgentMenus();
if (wasHidden) {
dropdown.hidden = false;
btn.setAttribute('aria-expanded', 'true');
wrap.classList.add('open');
openAgentMenu = dropdown;
}
});
wrap.append(btn, dropdown);
return wrap;
const menu = document.createElement('hive-agent-menu');
menu._opts = { c, forgeBase };
return menu;
}
// ─── port conflicts ──────────────────────────────────────────────────────────
@ -403,7 +230,7 @@ function derivePortConflicts(containers) {
function buildAgentTree(containers) {
// Close any open context menu before replacing the DOM tree — the
// previous dropdown element would otherwise be a stale reference.
closeAllAgentMenus();
closeAllMenus();
const byName = new Map();
for (const c of containers) byName.set(c.name, c);
const children = new Map(); // parent_name -> [child_name, ...]

View file

@ -17,7 +17,9 @@
"./chrome.css": "./src/chrome.css",
"./forms.js": "./src/forms.js",
"./dom.js": "./src/dom.js",
"./modal.js": "./src/modal.js"
"./modal.js": "./src/modal.js",
"./shadow-css.js": "./src/shadow-css.js",
"./hive-menu.js": "./src/hive-menu/hive-menu.js"
},
"files": [
"src/"

View file

@ -0,0 +1,58 @@
/* hive-menu.css scoped stylesheet for the generic <hive-menu>
shadow-DOM custom element (hive-menu.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.
Two kinds of rules live here, split by what they can actually reach:
`:host`/`.menu-dropdown` style shadow-DOM-owned markup this element
builds itself (no slotting constraint at all); `::slotted(...)` rules
reach the caller's *top-level* trigger/content nodes (the ones with a
`slot` attribute) since those are true light-DOM children, just
rendered here. What can NOT live here: anything inside the content
node (e.g. individual dropdown item rows) `::slotted()` only
matches directly-slotted elements, not their descendants, and no CSS
mechanism pierces further than that. That's a hard Shadow DOM
architecture limit, not a scope choice item-level styling stays in
the caller's own stylesheet regardless of how generic it looks. */
:host {
position: relative;
}
.menu-dropdown {
position: absolute;
right: 0;
top: calc(100% + 2px);
z-index: 50;
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
min-width: 10em;
white-space: nowrap;
}
/* The trigger is the top-level `slot="trigger"` node reachable, so its
base icon-button chrome (invisible until hover/open, via
`--menu-btn-opacity`) lives here rather than duplicated per caller. */
::slotted([slot='trigger']) {
display: block;
background: none;
border: none;
color: var(--subtext0);
font-size: 1.1em;
line-height: 1;
cursor: pointer;
padding: 0.1em 0.4em;
border-radius: 4px;
opacity: var(--menu-btn-opacity, 0);
transition: opacity 120ms, background 120ms, color 120ms;
}
::slotted([slot='trigger']:hover),
::slotted([slot='trigger']:focus-visible) {
background: color-mix(in srgb, var(--purple) 10%, transparent);
color: var(--purple);
outline: none;
}
::slotted([slot='trigger']:focus-visible) {
outline: 1px solid var(--purple);
}

View file

@ -0,0 +1,113 @@
// hive-menu.js — <hive-menu>, the generic dropdown-menu shadow-DOM custom
// element behind any "click a trigger, get a positioned dropdown" UI
// (currently just <hive-agent-menu>'s per-agent "⋮" menu). Owns
// open/close state, trigger + dropdown positioning, singleton
// close-on-open coordination across every `<hive-menu>` instance in the
// app, and the document-level outside-click/Escape listeners. Doesn't
// know or care what's inside the trigger/dropdown — opaque DOM nodes the
// caller hands over via `._opts = { trigger, content }` before appending
// (same `._opts`-before-append convention `<hive-dialog>` uses).
//
// `trigger`/`content` are appended as *light-DOM* children, projected
// into the shadow template via named `<slot>`s rather than moved into
// the shadow root. Load-bearing: it keeps the caller's own class-scoped
// styling applying to the nodes it built — a `<style>` only styles
// elements within the same tree it's part of, and slotted content keeps
// the tree membership of wherever it's actually a light-DOM child, so
// re-parenting into this element's own shadow root would make the
// caller's classes go dark.
//
// `openInstances` tracks every open instance app-wide (a deliberate
// widening from the old per-agent-menu-only coordination) so
// `closeAll()` can close each via its own `.close()`. `closeAllMenus()`
// force-closes everything before tearing down DOM one might anchor off
// of (e.g. swarm.js before replacing the container tree).
//
// The document-level listeners below, registered once: shadow-DOM
// retargeting means `e.target` for a click inside a shadow tree gets
// retargeted past that instance's host, so `e.composedPath()` (includes
// slotted content) tests "did this land inside any open menu" — same
// reasoning as `<hive-dialog>`'s backdrop-click check.
import { el } from '../dom.js';
import { attachShadowCss } from '../shadow-css.js';
import hiveMenuCss from './hive-menu.css';
const openInstances = new Set();
function closeAll() {
for (const inst of [...openInstances]) inst.close();
}
// Exported for any caller that needs to force-close every open menu —
// see module header.
export function closeAllMenus() {
closeAll();
}
// Close on any click outside every currently-open instance.
document.addEventListener('click', (e) => {
if (!openInstances.size) return;
const path = e.composedPath();
for (const inst of [...openInstances]) {
if (!path.includes(inst)) inst.close();
}
}, true);
// Close on Escape. stopImmediatePropagation so a caller's own
// selection-clear Escape handler (e.g. swarm.js's) doesn't also fire
// while a menu is open.
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && openInstances.size) {
closeAll();
e.stopImmediatePropagation();
}
}, true);
class HiveMenu extends HTMLElement {
connectedCallback() {
const { trigger, content } = this._opts || {};
const root = attachShadowCss(this, hiveMenuCss);
// Project the caller's opaque nodes via named slots — see module
// header for why this has to be slotting, not a shadow-root append.
trigger.slot = 'trigger';
content.slot = 'content';
this.append(trigger, content);
const dropdown = el('div', { class: 'menu-dropdown', hidden: true });
dropdown.append(el('slot', { name: 'content' }));
root.append(el('slot', { name: 'trigger' }), dropdown);
this._trigger = trigger;
this._dropdown = dropdown;
trigger.addEventListener('click', (e) => {
e.stopPropagation();
const wasOpen = openInstances.has(this);
closeAll();
if (!wasOpen) this.open();
});
}
disconnectedCallback() {
// Guards against a stale entry if the element is removed from the DOM
// (row re-render, tab switch) while its dropdown was still open.
openInstances.delete(this);
}
open() {
this._dropdown.hidden = false;
this._trigger.setAttribute('aria-expanded', 'true');
this.style.setProperty('--menu-btn-opacity', '1');
openInstances.add(this);
}
close() {
if (!openInstances.has(this)) return;
this._dropdown.hidden = true;
this._trigger.setAttribute('aria-expanded', 'false');
this.style.removeProperty('--menu-btn-opacity');
openInstances.delete(this);
}
}
customElements.define('hive-menu', HiveMenu);