frontend: shared <hive-warn> component for inline warning banners

Consolidates three independently-written instances of the same thing:
.cred-warning (credentials.html, static markup), .tombstone-warn
(core.js, JS-built), .port-conflict (swarm.js, JS-built) -- the first
two differed only by an undeliberate 10% vs 8% tint, the strongest
argument this was drift rather than three genuinely different needs.

New autonomous custom element, frontend/packages/shared/src/hive-warn/,
matching the established one-component-one-dir shadow-DOM pattern
(hive-btn, hive-toast, hive-dialog). Purely presentational -- no
lifecycle beyond attaching its shadow root once, no
attributeChangedCallback needed since severity/pulse are read directly
by :host([...]) CSS selectors rather than mirrored onto an inner
element. Content passes through via a single default <slot>, so every
call site keeps writing its existing <strong>/<code> markup unchanged.

API: <hive-warn> (amber, default) / <hive-warn severity="red"> for an
active incident vs a standing caveat, plus an opt-in pulse boolean
modifier (only the port-collision banner wants it -- a banner that's
always present and always pulsing just trains you to stop seeing it).
Tint is a single canonical 8% for both severities now, replacing the
10%/8% split.

Registered once in dashboard's common.js (same side-effect-import
pattern as <hive-side-panel>) so every dashboard page picks it up
without a per-file import, since all three call sites (core.js,
swarm.js, credentials.js) already transitively import it.

Verified: npm run build clean for both dashboard and agent packages,
grepped source for leftover cred-warning/tombstone-warn/port-conflict
references (none), confirmed hive-warn/HiveWarn/hive-warn-pulse present
in every affected dist bundle, confirmed the still-live
questions-pulse keyframe (.questions) untouched.
This commit is contained in:
iris 2026-08-03 18:21:01 +02:00 committed by mara
commit dfd92a7da9
9 changed files with 99 additions and 39 deletions

View file

@ -6,6 +6,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 '@hive/shared/side-panel.js'; // registers <hive-side-panel> — side-effect import
import '@hive/shared/hive-warn.js'; // registers <hive-warn> — side-effect import
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
// ─── helpers ──────────────────────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────────────────────

View file

@ -50,7 +50,7 @@ function renderTombstones(s) {
// Wording is deliberately about what the list *is* rather than what it // Wording is deliberately about what the list *is* rather than what it
// isn't: nothing records a destroy, so "container absent" is the only thing // isn't: nothing records a destroy, so "container absent" is the only thing
// the backend can actually tell. // the backend can actually tell.
const warn = el('p', { class: 'tombstone-warn' }); const warn = el('hive-warn', {});
warn.append( warn.append(
el('strong', {}, 'shows every agent whose container is absent'), el('strong', {}, 'shows every agent whose container is absent'),
' — not only destroyed ones. An agent part-way through being spawned ' + ' — not only destroyed ones. An agent part-way through being spawned ' +

View file

@ -27,15 +27,6 @@ body.cred-shell {
.cred-pane[hidden] { display: none; } .cred-pane[hidden] { display: none; }
.cred-warning {
border: 1px solid var(--amber);
border-radius: 4px;
padding: 0.7rem 0.9rem;
margin: 0.6rem 0 1.1rem;
font-size: 0.9rem;
background: color-mix(in srgb, var(--amber) 10%, transparent);
}
.gh-status { margin: 0.5rem 0 1.2rem; } .gh-status { margin: 0.5rem 0 1.2rem; }
.gh-status-line { .gh-status-line {
display: flex; display: flex;

View file

@ -90,7 +90,7 @@
Security-warning banner + a link to generate a PAT. --> Security-warning banner + a link to generate a PAT. -->
<section class="cred-pane" id="cred-pane-github" data-tab-pane="github" <section class="cred-pane" id="cred-pane-github" data-tab-pane="github"
role="tabpanel" aria-labelledby="cred-tab-github" hidden> role="tabpanel" aria-labelledby="cred-tab-github" hidden>
<div class="cred-warning"> <hive-warn>
⚠ use a <strong>dedicated bot account</strong>, not a human's &mdash; ⚠ use a <strong>dedicated bot account</strong>, not a human's &mdash;
and a <strong>minimally-scoped</strong> personal access token (only and a <strong>minimally-scoped</strong> personal access token (only
the repos/scopes the agent actually needs, e.g. <code>repo</code> + the repos/scopes the agent actually needs, e.g. <code>repo</code> +
@ -98,7 +98,7 @@
anything within the token's scopes is reachable if the agent is anything within the token's scopes is reachable if the agent is
ever compromised. the token is injected into the agent's state dir ever compromised. the token is injected into the agent's state dir
and is <strong>never displayed back</strong> on this page. and is <strong>never displayed back</strong> on this page.
</div> </hive-warn>
<h3>◇ status</h3> <h3>◇ status</h3>
<div id="gh-status" class="gh-status"><p class="meta">select an agent to see its github credential status.</p></div> <div id="gh-status" class="gh-status"><p class="meta">select an agent to see its github credential status.</p></div>

View file

@ -444,33 +444,14 @@ hive-agent-menu {
} }
.container-row.tombstone .name { color: var(--muted); } .container-row.tombstone .name { color: var(--muted); }
/* K3PT ST4T3 caveat. Amber, not red like `.port-conflict`, and no pulse: this /* K3PT ST4T3 caveat and the port-collision banner both moved to the
is a standing "read the list this way" note, not an incident a pulsing shared <hive-warn> component amber/no-pulse for
banner that is always present just teaches you to stop seeing it. the standing caveat (a pulsing banner that's always present just
Nothing records a destroy, so this list is every agent whose container is teaches you to stop seeing it), `severity="red" pulse` for the port
absent which includes one being spawned right now. */ collision (an incident, not a caveat). See core.js/swarm.js and
.tombstone-warn { hive-warn.css for the actual styling now.
background: color-mix(in srgb, var(--amber) 8%, transparent);
border: 1px solid var(--amber);
color: var(--amber);
padding: 0.5em 0.8em;
margin-bottom: 0.6em;
border-radius: 4px;
}
.tombstone-warn strong { color: var(--amber); }
/* Notification controls — sit between the banner and the containers. */ Notification controls below sit between the banner and the containers. */
.port-conflict {
background: color-mix(in srgb, var(--red) 8%, transparent);
border: 1px solid var(--red);
color: var(--red);
padding: 0.5em 0.8em;
margin-bottom: 0.6em;
border-radius: 4px;
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 40%, transparent);
animation: questions-pulse 2.4s ease-in-out infinite;
}
.port-conflict strong { color: var(--red); }
/* Build-queue summary banner on the SW4RM tab: one compact line /* Build-queue summary banner on the SW4RM tab: one compact line
when the rebuild queue has active work, with a link to the full queue on when the rebuild queue has active work, with a link to the full queue on

View file

@ -582,7 +582,7 @@ export function renderContainers(s) {
// rebuild. The banner sits above the agent list so it's the // rebuild. The banner sits above the agent list so it's the
// first thing the operator sees when something's wedged. // first thing the operator sees when something's wedged.
if (portConflicts.length) { if (portConflicts.length) {
const banner = el('div', { class: 'port-conflict' }, const banner = el('hive-warn', { severity: 'red', pulse: '' },
el('strong', {}, '⚠ port collision'), ' — '); el('strong', {}, '⚠ port collision'), ' — ');
const groups = portConflicts.map((c) => const groups = portConflicts.map((c) =>
`:${c.port} (${c.agents.join(' + ')})`).join('; '); `:${c.port} (${c.agents.join(' + ')})`).join('; ');

View file

@ -22,6 +22,7 @@
"./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",
"./hive-warn.js": "./src/hive-warn/hive-warn.js",
"./side-panel.js": "./src/side-panel/hive-side-panel.js", "./side-panel.js": "./src/side-panel/hive-side-panel.js",
"./jobq-graph.js": "./src/jobq-graph/hive-jobq-graph.js" "./jobq-graph.js": "./src/jobq-graph/hive-jobq-graph.js"
}, },

View file

@ -0,0 +1,49 @@
/* hive-warn.css shadow-DOM stylesheet for the <hive-warn> autonomous
custom element (hive-warn.js). `severity` is an attribute on the HOST
(how callers set it, `hive-warn.js`'s own header explains why)
`:host([severity="…"])` sets `color`/`border-color`/`background`,
which the shadow content then inherits/reads via `currentColor`, same
pattern as hive-btn.css's `[variant]`. Default (no `severity` set) is
amber the more common case among the three call sites this replaces.
Tint is a single canonical 8% for both severities `.cred-warning`
(the amber call site) was independently written at 10%, `.tombstone-warn`
at 8%; picking one number is the whole point of this component
existing (mara, on review: "so they are all themed consistently"). */
:host {
display: block;
border: 1px solid var(--amber);
border-radius: 4px;
padding: 0.5em 0.8em;
margin-bottom: 0.6em;
color: var(--amber);
background: color-mix(in srgb, var(--amber) 8%, transparent);
}
:host([severity="red"]) {
border-color: var(--red);
color: var(--red);
background: color-mix(in srgb, var(--red) 8%, transparent);
}
/* `pulse`: opt-in, for "something is wrong right now" rather than a
standing caveat deliberately not the default. A banner that's
always present and always pulsing just trains you to stop seeing it
(the reasoning on the original `.tombstone-warn`, which stays
non-pulsing; `.port-conflict severity="red" pulse` is the one
call site that wants it). `currentColor` throughout so the glow
matches whichever severity is active without a second color switch. */
:host([pulse]) {
text-shadow: 0 0 6px color-mix(in srgb, currentColor 40%, transparent);
animation: hive-warn-pulse 2.4s ease-in-out infinite;
}
@keyframes hive-warn-pulse {
0%, 100% { box-shadow: 0 0 12px -4px color-mix(in srgb, currentColor 55%, transparent); }
50% { box-shadow: 0 0 22px -2px color-mix(in srgb, currentColor 95%, transparent); }
}
/* Explicit rather than relying on plain inheritance matches what
every one of the three original rules declared, kept for parity. */
::slotted(strong) {
color: inherit;
}

View file

@ -0,0 +1,37 @@
// hive-warn.js — <hive-warn>, the shared inline warning-banner
// component. Consolidates three independently-written instances of the
// same thing: `.cred-warning` (credentials.html, static
// markup), `.tombstone-warn` (core.js, JS-built), `.port-conflict`
// (swarm.js, JS-built) — the first two differed only by an
// undeliberate 10% vs 8% tint, the strongest evidence this was drift,
// not three genuinely different needs.
//
// Purely presentational — no internal state, no lifecycle beyond
// attaching its shadow root once. `severity` ('amber' | 'red', default
// amber) and `pulse` (boolean) are read directly by hive-warn.css's
// `:host([...])` selectors; unlike `<hive-btn>`'s `variant`/`disabled`
// there's no inner native element that needs the attribute mirrored
// onto it, so no `attributeChangedCallback` is needed at all.
//
// Content is arbitrary light-DOM children through the shadow tree's
// single default `<slot>` — same reuse of the existing `<strong>`/
// `<code>` markup every call site already writes, no caller-side
// rewrite of their message content needed.
//
// Usage:
// `<hive-warn>...</hive-warn>` (amber, static HTML)
// `el('hive-warn', {}, ...)` (amber, JS-built)
// `el('hive-warn', { severity: 'red', pulse: '' }, ...)` (urgent)
import { attachShadowCss } from '../shadow-css.js';
import hiveWarnCss from './hive-warn.css';
class HiveWarn extends HTMLElement {
connectedCallback() {
if (this._built) return; // re-parenting re-fires connectedCallback
const root = attachShadowCss(this, hiveWarnCss);
root.append(document.createElement('slot'));
this._built = true;
}
}
customElements.define('hive-warn', HiveWarn);