treefmt: apply prettier

Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.

Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.

None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
This commit is contained in:
atlas 2026-09-02 14:29:33 +02:00
commit 39b95c2ede
203 changed files with 10090 additions and 6085 deletions

View file

@ -20,10 +20,10 @@
// Same shared-component shape as `JobqRollup`/`JobqGraph`:
// `render(h(ApiErrorPanel, { problem }), container)` from vanilla JS, or
// `<ApiErrorPanel problem={...} />` from swarm-ui's JSX.
import { useState } from 'preact/hooks';
import { WarnBanner } from '../warn-banner/WarnBanner.js';
import type { ProblemDetails } from '../api-error.js';
import './api-error-panel.css';
import { useState } from "preact/hooks";
import { WarnBanner } from "../warn-banner/WarnBanner.js";
import type { ProblemDetails } from "../api-error.js";
import "./api-error-panel.css";
export interface ApiErrorPanelProps {
problem: ProblemDetails;
@ -39,12 +39,16 @@ function formatForCopy(p: ProblemDetails): string {
if (p.title) lines.push(`title: ${p.title}`);
if (p.type) lines.push(`type: ${p.type}`);
if (p.detail) lines.push(`detail: ${p.detail}`);
return lines.length ? lines.join('\n') : 'request failed';
return lines.length ? lines.join("\n") : "request failed";
}
export function ApiErrorPanel({ problem, context }: ApiErrorPanelProps) {
const [copied, setCopied] = useState(false);
const heading = problem.title || (problem.status !== undefined ? `http ${problem.status}` : 'request failed');
const heading =
problem.title ||
(problem.status !== undefined
? `http ${problem.status}`
: "request failed");
async function copy() {
try {
@ -62,11 +66,11 @@ export function ApiErrorPanel({ problem, context }: ApiErrorPanelProps) {
<WarnBanner level="error" class="api-error-panel">
<div class="api-error-heading">
<span class="api-error-title">
{context ? `${context}: ` : ''}
{context ? `${context}: ` : ""}
{heading}
</span>
<button type="button" class="api-error-copy" onClick={copy}>
{copied ? 'copied' : 'copy'}
{copied ? "copied" : "copy"}
</button>
</div>
{problem.detail ? <p class="api-error-detail">{problem.detail}</p> : null}

View file

@ -33,17 +33,17 @@ export async function readApiError(resp: Response): Promise<ProblemDetails> {
} catch {
return { status };
}
if (raw && (raw[0] === '{' || raw[0] === '[')) {
if (raw && (raw[0] === "{" || raw[0] === "[")) {
try {
const body = JSON.parse(raw) as Record<string, unknown>;
return {
type: typeof body.type === 'string' ? body.type : undefined,
title: typeof body.title === 'string' ? body.title : undefined,
status: typeof body.status === 'number' ? body.status : status,
type: typeof body.type === "string" ? body.type : undefined,
title: typeof body.title === "string" ? body.title : undefined,
status: typeof body.status === "number" ? body.status : status,
detail:
typeof body.detail === 'string'
typeof body.detail === "string"
? body.detail
: typeof body.error === 'string'
: typeof body.error === "string"
? body.error
: undefined,
};
@ -59,5 +59,9 @@ export async function readApiError(resp: Response): Promise<ProblemDetails> {
// same fallback order `readErrorBody` used: detail (or its `error` alias,
// already folded in above) → title → a bare status line.
export function problemMessage(p: ProblemDetails): string {
return p.detail || p.title || (p.status !== undefined ? `http ${p.status}` : 'request failed');
return (
p.detail ||
p.title ||
(p.status !== undefined ? `http ${p.status}` : "request failed")
);
}

View file

@ -51,12 +51,12 @@
the settings/links "should not have the badge bg" bug on those
triggers. Verified against a real repro before landing, not just
the specificity arithmetic. */
:root[data-theme='light'] .ui-badge:not(.ui-badge-quiet) {
:root[data-theme="light"] .ui-badge:not(.ui-badge-quiet) {
background: var(--purple-dim);
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
:root[data-theme='dark'] .ui-badge:not(.ui-badge-quiet) {
:root[data-theme="dark"] .ui-badge:not(.ui-badge-quiet) {
background: color-mix(in srgb, var(--purple-dim) 65%, transparent);
-webkit-backdrop-filter: blur(6px) saturate(140%);
backdrop-filter: blur(6px) saturate(140%);
@ -81,12 +81,14 @@
.ui-badge-quiet {
background: none;
}
.ui-badge-interactive[aria-expanded='true'] {
.ui-badge-interactive[aria-expanded="true"] {
background: var(--bg-elev);
outline: 1px solid var(--purple);
}
.ui-badge-label {
color: var(--muted-on-dim); /* --muted alone is too low-contrast on this fill, see theme.css */
color: var(
--muted-on-dim
); /* --muted alone is too low-contrast on this fill, see theme.css */
}
.ui-badge-value {
color: var(--fg);

View file

@ -18,10 +18,15 @@
// swarm-ui *and* the per-agent page need the interactive shape, and
// `shared` is the one package both already depend on (see
// `docs/web-ui/design-guide.md`'s component-first + junk-drawer rules).
import type { ComponentChildren } from 'preact';
import './Badge.css';
import type { ComponentChildren } from "preact";
import "./Badge.css";
export type BadgeTone = 'neutral' | 'positive' | 'warning' | 'negative' | 'accent';
export type BadgeTone =
| "neutral"
| "positive"
| "warning"
| "negative"
| "accent";
/**
* `'default'` the filled pill every status/picker badge has always
* been. `'quiet'` no permanent fill, only a background on hover/
@ -34,7 +39,7 @@ export type BadgeTone = 'neutral' | 'positive' | 'warning' | 'negative' | 'accen
* weight is orthogonal to `tone` (color semantics), so it's its own prop
* rather than a new `tone` value.
*/
export type BadgeVariant = 'default' | 'quiet';
export type BadgeVariant = "default" | "quiet";
export interface BadgeProps {
/** Dim prefix text, e.g. "model". Omit for a single-value badge like "alive". */
@ -63,8 +68,8 @@ export interface BadgeProps {
export function Badge({
label,
value,
tone = 'neutral',
variant = 'default',
tone = "neutral",
variant = "default",
icon,
onClick,
expanded,
@ -73,14 +78,14 @@ export function Badge({
title,
}: BadgeProps) {
const classes = [
'ui-badge',
"ui-badge",
`ui-badge-${tone}`,
variant === 'quiet' && 'ui-badge-quiet',
onClick && 'ui-badge-interactive',
variant === "quiet" && "ui-badge-quiet",
onClick && "ui-badge-interactive",
extraClass,
]
.filter(Boolean)
.join(' ');
.join(" ");
const content = (
<>
{icon ? (

View file

@ -18,7 +18,8 @@ body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
font-family:
"JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
line-height: 1.6;
}
@ -33,4 +34,11 @@ body {
animation: spin 1s linear infinite;
color: var(--amber);
}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View file

@ -41,7 +41,9 @@
white-space: nowrap;
flex: none;
}
.page-back:hover { text-decoration: underline; }
.page-back:hover {
text-decoration: underline;
}
.page-title {
color: var(--subtext0);

View file

@ -142,7 +142,7 @@
both blocks below just re-point at the same `--mocha-baseNN` /
`--latte-baseNN` custom properties declared once at the top of this
file. */
:root[data-theme='light'] {
:root[data-theme="light"] {
--base00: var(--latte-base00);
--base01: var(--latte-base01);
--base02: var(--latte-base02);
@ -160,7 +160,7 @@
--base0E: var(--latte-base0E);
--base0F: var(--latte-base0F);
}
:root[data-theme='dark'] {
:root[data-theme="dark"] {
--base00: var(--mocha-base00);
--base01: var(--mocha-base01);
--base02: var(--mocha-base02);

View file

@ -14,8 +14,8 @@
export const el = (tag, attrs = {}, ...children) => {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') e.className = v;
else if (k === 'html') e.innerHTML = v;
if (k === "class") e.className = v;
else if (k === "html") e.innerHTML = v;
else e.setAttribute(k, v);
}
for (const c of children) {

View file

@ -39,7 +39,7 @@
background: var(--purple-dim);
}
.ui-dropdown-item-active .ui-dropdown-item-label::before {
content: '✓ ';
content: "✓ ";
color: var(--purple);
}
/* Destructive action row (cancel turn) ported from the old standalone

View file

@ -14,9 +14,9 @@
// to that box's bottom-left via CSS. No portal — a badge in a normal
// document-flow header never needs one, and skipping it keeps focus
// management simple (no re-parenting to `<body>` to reason about).
import { useEffect, useRef } from 'preact/hooks';
import type { ComponentChildren, RefObject } from 'preact';
import './Dropdown.css';
import { useEffect, useRef } from "preact/hooks";
import type { ComponentChildren, RefObject } from "preact";
import "./Dropdown.css";
export interface DropdownOption {
value: string;
@ -53,7 +53,15 @@ export interface DropdownProps {
anchorRef?: RefObject<HTMLElement>;
}
export function Dropdown({ open, options, activeValue, onSelect, onClose, label, anchorRef }: DropdownProps) {
export function Dropdown({
open,
options,
activeValue,
onSelect,
onClose,
label,
anchorRef,
}: DropdownProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -65,16 +73,16 @@ export function Dropdown({ open, options, activeValue, onSelect, onClose, label,
onClose();
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
if (e.key === "Escape") onClose();
}
// `pointerdown` (not `click`) so a drag-to-select that ends outside
// still closes; capture phase so this sees the event before a
// stopPropagation() elsewhere in the tree could swallow it.
document.addEventListener('pointerdown', handlePointerDown, true);
document.addEventListener('keydown', handleKeyDown);
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open, onClose]);
@ -89,14 +97,16 @@ export function Dropdown({ open, options, activeValue, onSelect, onClose, label,
role="menuitemradio"
aria-checked={opt.value === activeValue}
class={
'ui-dropdown-item' +
(opt.value === activeValue ? ' ui-dropdown-item-active' : '') +
(opt.danger ? ' ui-dropdown-item-danger' : '')
"ui-dropdown-item" +
(opt.value === activeValue ? " ui-dropdown-item-active" : "") +
(opt.danger ? " ui-dropdown-item-danger" : "")
}
onClick={() => onSelect(opt.value)}
>
<span class="ui-dropdown-item-label">{opt.label}</span>
{opt.description ? <span class="ui-dropdown-item-desc">{opt.description}</span> : null}
{opt.description ? (
<span class="ui-dropdown-item-desc">{opt.description}</span>
) : null}
</button>
))}
</div>

View file

@ -51,30 +51,37 @@ export function asyncBtn(btn, fn) {
// `data-prompt-field`) are surfaced via the themed dialogs in `modal.js`
// rather than native `confirm()`/`prompt()`, and errors via `themedToast`
// rather than `alert()`, so every page gets the same in-theme experience.
import { themedConfirm, themedPrompt, themedToast } from './modal.js';
import { themedConfirm, themedPrompt, themedToast } from "./modal.js";
export function bindAsyncForms(onSuccess) {
document.addEventListener('submit', async (e) => {
document.addEventListener("submit", async (e) => {
const f = e.target;
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
if (!(f instanceof HTMLFormElement) || !f.hasAttribute("data-async"))
return;
e.preventDefault();
if (f.dataset.confirm && !(await themedConfirm({ message: f.dataset.confirm }))) return;
if (
f.dataset.confirm &&
!(await themedConfirm({ message: f.dataset.confirm }))
)
return;
if (f.dataset.prompt) {
const ans = await themedPrompt({ message: f.dataset.prompt });
if (ans === null) return; // operator hit Cancel
// Drop into a hidden input named after `data-prompt-field` (or
// 'note' by default) so the value rides along on the POST.
const field = f.dataset.promptField || 'note';
const field = f.dataset.promptField || "note";
let input = f.querySelector(`input[name="${field}"]`);
if (!input) {
input = document.createElement('input');
input.type = 'hidden';
input = document.createElement("input");
input.type = "hidden";
input.name = field;
f.append(input);
}
input.value = ans;
}
const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
const btn = f.querySelector(
'button[type="submit"], button:not([type]), .btn-inline',
);
// Inner action: POST, clear inputs, call onSuccess.
// Errors are surfaced via themedToast; the caller does not re-throw
// so asyncBtn's finally always runs (restoring the button).
@ -82,25 +89,37 @@ export function bindAsyncForms(onSuccess) {
let resp;
try {
resp = await fetch(f.action, {
method: f.method || 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: f.method || "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(new FormData(f)),
redirect: 'manual',
redirect: "manual",
});
} catch (err) {
themedToast('action failed: ' + err, { type: 'error' });
themedToast("action failed: " + err, { type: "error" });
return;
}
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
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' });
const text = await resp.text().catch(() => "");
themedToast(
"action failed: " + resp.status + (text ? "\n\n" + text : ""),
{ type: "error" },
);
return;
}
// Clear text inputs whose value was just submitted.
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
if (!f.hasAttribute('data-no-refresh') && typeof onSuccess === 'function') {
f.querySelectorAll(
'input[type="text"], input:not([type]), textarea',
).forEach((i) => {
i.value = "";
});
if (
!f.hasAttribute("data-no-refresh") &&
typeof onSuccess === "function"
) {
onSuccess();
}
};

View file

@ -12,9 +12,15 @@
:host {
display: contents;
}
:host([variant="cancel"]) { color: var(--subtext0); }
:host([variant="confirm"]) { color: var(--green); }
:host([variant="danger"]) { color: var(--red); }
:host([variant="cancel"]) {
color: var(--subtext0);
}
:host([variant="confirm"]) {
color: var(--green);
}
:host([variant="danger"]) {
color: var(--red);
}
button {
font-family: inherit;

View file

@ -20,10 +20,10 @@
// `disabled`/`type` are likewise plain attributes on the host, mirrored
// onto the inner button on connect and on every attribute change.
import { attachShadowCss } from '../shadow-css.js';
import hiveBtnCss from './hive-btn.css';
import { attachShadowCss } from "../shadow-css.js";
import hiveBtnCss from "./hive-btn.css";
const OBSERVED = ['disabled', 'type'];
const OBSERVED = ["disabled", "type"];
class HiveBtn extends HTMLElement {
static get observedAttributes() {
@ -36,8 +36,8 @@ class HiveBtn extends HTMLElement {
return; // already built (e.g. re-parenting re-fires connectedCallback)
}
const root = attachShadowCss(this, hiveBtnCss, { delegatesFocus: true });
const btn = document.createElement('button');
btn.append(document.createElement('slot'));
const btn = document.createElement("button");
btn.append(document.createElement("slot"));
root.append(btn);
this._btn = btn;
this._sync();
@ -49,8 +49,8 @@ class HiveBtn extends HTMLElement {
_sync() {
if (!this._btn) return;
this._btn.disabled = this.hasAttribute('disabled');
this._btn.type = this.getAttribute('type') || 'button';
this._btn.disabled = this.hasAttribute("disabled");
this._btn.type = this.getAttribute("type") || "button";
}
}
customElements.define('hive-btn', HiveBtn);
customElements.define("hive-btn", HiveBtn);

View file

@ -35,8 +35,15 @@
letter-spacing: 0.08em;
color: var(--subtext0);
}
.message { color: var(--fg); line-height: 1.45; }
.checks { display: flex; flex-direction: column; gap: 0.4em; }
.message {
color: var(--fg);
line-height: 1.45;
}
.checks {
display: flex;
flex-direction: column;
gap: 0.4em;
}
.checkrow {
display: flex;
align-items: flex-start;
@ -46,15 +53,25 @@
font-size: 0.92em;
line-height: 1.35;
}
.checkrow .check { margin-top: 0.2em; flex: 0 0 auto; }
.checkrow .check {
margin-top: 0.2em;
flex: 0 0 auto;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.6em;
margin-top: 0.2em;
}
.promptfield { display: flex; flex-direction: column; gap: 0.4em; }
.promptlabel { color: var(--subtext0); font-size: 0.9em; }
.promptfield {
display: flex;
flex-direction: column;
gap: 0.4em;
}
.promptlabel {
color: var(--subtext0);
font-size: 0.9em;
}
.input {
font-family: inherit;
font-size: 1em;
@ -65,7 +82,9 @@
border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em;
}
.input:focus { outline: 1px solid var(--green); }
.input:focus {
outline: 1px solid var(--green);
}
.textarea {
resize: vertical;
min-height: 4.5em;

View file

@ -14,17 +14,20 @@
// Imports `../hive-btn/hive-btn.js` for its side effect (registers
// `<hive-btn>`) since the dialog's own buttons are `<hive-btn>` elements.
import { el } from '../dom.js';
import { attachShadowCss } from '../shadow-css.js';
import '../hive-btn/hive-btn.js'; // registers <hive-btn> — side-effect import, no named export needed
import dialogCss from './hive-dialog.css';
import { el } from "../dom.js";
import { attachShadowCss } from "../shadow-css.js";
import "../hive-btn/hive-btn.js"; // registers <hive-btn> — side-effect import, no named export needed
import dialogCss from "./hive-dialog.css";
class HiveDialog extends HTMLElement {
connectedCallback() {
const {
title = '', message = '', content = null,
buttons = [{ label: 'ok', value: true }],
danger = false, dismissable = true,
title = "",
message = "",
content = null,
buttons = [{ label: "ok", value: true }],
danger = false,
dismissable = true,
} = this._opts || {};
const root = attachShadowCss(this, dialogCss);
@ -33,12 +36,14 @@ class HiveDialog extends HTMLElement {
const done = (value) => {
if (settled) return;
settled = true;
document.removeEventListener('keydown', onKey, true);
this.dispatchEvent(new CustomEvent('hive-dialog-close', { detail: value }));
document.removeEventListener("keydown", onKey, true);
this.dispatchEvent(
new CustomEvent("hive-dialog-close", { detail: value }),
);
this.remove();
};
const onKey = (e) => {
if (dismissable && e.key === 'Escape') {
if (dismissable && e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
done(null);
@ -50,12 +55,16 @@ class HiveDialog extends HTMLElement {
// `b.class` names the variant ('cancel' | 'confirm'); `b.danger`
// overrides it to the 'danger' look regardless (a destructive
// confirm button reads as danger, not as a plain confirm).
const variant = b.danger ? 'danger' : b.class;
const btn = el('hive-btn', {
type: 'button',
...(variant ? { variant } : {}),
}, b.label);
btn.addEventListener('click', () => done(b.value));
const variant = b.danger ? "danger" : b.class;
const btn = el(
"hive-btn",
{
type: "button",
...(variant ? { variant } : {}),
},
b.label,
);
btn.addEventListener("click", () => done(b.value));
return { spec: b, btn };
});
@ -65,21 +74,30 @@ class HiveDialog extends HTMLElement {
// this shadow root, so no cross-instance collision risk even without
// the random suffix — kept anyway since it costs nothing and guards
// against a future shared-DOM edge case (e.g. `::part()` piercing).
const labelId = 'dlg-' + Math.random().toString(36).slice(2, 9);
const titleEl = title ? el('div', { class: 'title', id: labelId }, title) : null;
const messageEl = message
? el('div', title ? { class: 'message' } : { class: 'message', id: labelId }, message)
const labelId = "dlg-" + Math.random().toString(36).slice(2, 9);
const titleEl = title
? el("div", { class: "title", id: labelId }, title)
: null;
const boxAttrs = { class: 'box', role: 'dialog', 'aria-modal': 'true' };
if (titleEl || messageEl) boxAttrs['aria-labelledby'] = labelId;
const box = el('div', boxAttrs,
const messageEl = message
? el(
"div",
title ? { class: "message" } : { class: "message", id: labelId },
message,
)
: null;
const boxAttrs = { class: "box", role: "dialog", "aria-modal": "true" };
if (titleEl || messageEl) boxAttrs["aria-labelledby"] = labelId;
const box = el(
"div",
boxAttrs,
titleEl,
messageEl,
content || null,
el('div', { class: 'actions' }, ...btnEls.map((b) => b.btn)));
el("div", { class: "actions" }, ...btnEls.map((b) => b.btn)),
);
root.append(box);
this.addEventListener('click', (e) => {
this.addEventListener("click", (e) => {
// `e.target` is retargeted to `this` (the host) for ANY click that
// originated inside the shadow tree, once it bubbles out to a
// listener attached on the host itself — per spec, retargeting
@ -94,12 +112,13 @@ class HiveDialog extends HTMLElement {
// tree was actually under the cursor, i.e. a real backdrop click.
if (dismissable && e.composedPath()[0] === this) done(null);
});
document.addEventListener('keydown', onKey, true);
document.addEventListener("keydown", onKey, true);
const focusTarget = btnEls.find((b) => b.spec.autofocus)
|| (danger ? btnEls.find((b) => !b.spec.danger) : null)
|| btnEls[btnEls.length - 1];
const focusTarget =
btnEls.find((b) => b.spec.autofocus) ||
(danger ? btnEls.find((b) => !b.spec.danger) : null) ||
btnEls[btnEls.length - 1];
if (focusTarget) focusTarget.btn.focus();
}
}
customElements.define('hive-dialog', HiveDialog);
customElements.define("hive-dialog", HiveDialog);

View file

@ -34,7 +34,7 @@
/* 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']) {
::slotted([slot="trigger"]) {
display: block;
background: none;
border: none;
@ -45,14 +45,17 @@
padding: 0.1em 0.4em;
border-radius: 4px;
opacity: var(--menu-btn-opacity, 0);
transition: opacity 120ms, background 120ms, color 120ms;
transition:
opacity 120ms,
background 120ms,
color 120ms;
}
::slotted([slot='trigger']:hover),
::slotted([slot='trigger']:focus-visible) {
::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) {
::slotted([slot="trigger"]:focus-visible) {
outline: 1px solid var(--purple);
}

View file

@ -29,9 +29,9 @@
// 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';
import { el } from "../dom.js";
import { attachShadowCss } from "../shadow-css.js";
import hiveMenuCss from "./hive-menu.css";
const openInstances = new Set();
@ -46,22 +46,30 @@ export function closeAllMenus() {
}
// 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);
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);
document.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape" && openInstances.size) {
closeAll();
e.stopImmediatePropagation();
}
},
true,
);
class HiveMenu extends HTMLElement {
connectedCallback() {
@ -80,18 +88,18 @@ class HiveMenu extends HTMLElement {
// 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';
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);
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) => {
trigger.addEventListener("click", (e) => {
e.stopPropagation();
const wasOpen = openInstances.has(this);
closeAll();
@ -107,17 +115,17 @@ class HiveMenu extends HTMLElement {
open() {
this._dropdown.hidden = false;
this._trigger.setAttribute('aria-expanded', 'true');
this.style.setProperty('--menu-btn-opacity', '1');
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');
this._trigger.setAttribute("aria-expanded", "false");
this.style.removeProperty("--menu-btn-opacity");
openInstances.delete(this);
}
}
customElements.define('hive-menu', HiveMenu);
customElements.define("hive-menu", HiveMenu);

View file

@ -18,9 +18,20 @@
color: var(--fg);
white-space: pre-wrap;
opacity: 1;
transition: opacity 0.2s ease, transform 0.2s ease;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
:host(.out) {
opacity: 0;
transform: translateX(0.5em);
}
:host(.error) {
border-left-color: var(--red);
}
:host(.info) {
border-left-color: var(--purple-dim);
}
:host(.ok) {
border-left-color: var(--green);
}
:host(.out) { opacity: 0; transform: translateX(0.5em); }
:host(.error) { border-left-color: var(--red); }
:host(.info) { border-left-color: var(--purple-dim); }
:host(.ok) { border-left-color: var(--green); }

View file

@ -9,29 +9,29 @@
// directly in the shadow root rather than via a `<slot>`, since there's no
// external light-DOM content to project.
import { attachShadowCss } from '../shadow-css.js';
import toastCss from './hive-toast.css';
import { attachShadowCss } from "../shadow-css.js";
import toastCss from "./hive-toast.css";
class HiveToast extends HTMLElement {
connectedCallback() {
const { type = 'info', duration } = this._opts || {};
const ms = duration != null ? duration : (type === 'error' ? 8000 : 4000);
const { type = "info", duration } = this._opts || {};
const ms = duration != null ? duration : type === "error" ? 8000 : 4000;
const root = attachShadowCss(this, toastCss);
this.classList.add(type);
this.setAttribute('role', type === 'error' ? 'alert' : 'status');
root.textContent = this._message || '';
this.setAttribute("role", type === "error" ? "alert" : "status");
root.textContent = this._message || "";
let removed = false;
this._remove = () => {
if (removed) return;
removed = true;
this.classList.add('out');
this.classList.add("out");
setTimeout(() => this.remove(), 200);
};
this.addEventListener('click', this._remove);
this.addEventListener("click", this._remove);
if (ms > 0) this._timer = setTimeout(this._remove, ms);
}
disconnectedCallback() {
clearTimeout(this._timer);
}
}
customElements.define('hive-toast', HiveToast);
customElements.define("hive-toast", HiveToast);

View file

@ -42,8 +42,13 @@
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); }
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

View file

@ -27,15 +27,15 @@
// `el('hive-warn', { level: 'warning' }, ...)` (JS-built)
// `el('hive-warn', { level: 'error' }, ...)` (active incident, pulses)
import { attachShadowCss } from '../shadow-css.js';
import hiveWarnCss from './hive-warn.css';
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'));
root.append(document.createElement("slot"));
this._built = true;
}
}
customElements.define('hive-warn', HiveWarn);
customElements.define("hive-warn", HiveWarn);

View file

@ -1,3 +1,3 @@
// Convenience re-export so consumers can `import { create, linkify }
// from '@hive/shared'` without naming the sub-module path.
export { create, linkify } from './terminal/terminal.js';
export { create, linkify } from "./terminal/terminal.js";

View file

@ -28,21 +28,28 @@
// loader is `text` (for unrelated shadow-DOM components' CSS-as-string
// needs), and that loader is global per call, not per-module.
import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
import { useState, useEffect, useCallback, useRef } from "preact/hooks";
// Mirrors `hive_jobq_wire::StateSchema` verbatim (variant names, no
// `rename_all`) — see that enum's own doc comment for why it's kept in
// an exhaustive match on the Rust side; this union is this file's
// equivalent contract.
type NodeState = 'Pending' | 'Running' | 'Finishing' | 'Done' | 'Failed' | 'Cancelled' | 'Skipped';
type NodeState =
| "Pending"
| "Running"
| "Finishing"
| "Done"
| "Failed"
| "Cancelled"
| "Skipped";
type TerminalState = 'Done' | 'Failed' | 'Cancelled' | 'Skipped';
type TerminalState = "Done" | "Failed" | "Cancelled" | "Skipped";
// Mirrors `hive_jobq_wire::GraphDep` — externally tagged on `kind`,
// values are the Rust variant names verbatim.
type GraphDep =
| { kind: 'Node'; id: number; accepts: TerminalState[] }
| { kind: 'Resource'; name: string; count: number };
| { kind: "Node"; id: number; accepts: TerminalState[] }
| { kind: "Resource"; name: string; count: number };
interface NodePayload {
label: string;
@ -69,13 +76,13 @@ interface TreeNode extends GraphNode {
}
const STATE_GLYPH: Record<NodeState, string> = {
Pending: '⏸',
Running: '▶',
Finishing: '◐',
Done: '✔',
Failed: '✖',
Cancelled: '⊘',
Skipped: '·',
Pending: "⏸",
Running: "▶",
Finishing: "◐",
Done: "✔",
Failed: "✖",
Cancelled: "⊘",
Skipped: "·",
};
// Declaration order doubles as render order for the filter checkboxes —
@ -84,12 +91,16 @@ const STATE_GLYPH: Record<NodeState, string> = {
const ALL_STATES = Object.keys(STATE_GLYPH) as NodeState[];
// Product call: "default selection filters out skipped and done."
const DEFAULT_HIDDEN_STATES = new Set<NodeState>(['Done', 'Skipped']);
const DEFAULT_HIDDEN_STATES = new Set<NodeState>(["Done", "Skipped"]);
// Non-terminal states a cancel button makes sense on. Finishing is
// included — "own logic done, children still running" is still a subtree
// worth stopping early.
const CANCELLABLE_STATES = new Set<NodeState>(['Pending', 'Running', 'Finishing']);
const CANCELLABLE_STATES = new Set<NodeState>([
"Pending",
"Running",
"Finishing",
]);
// Build a parent/child tree from the flat wire array. `parent` (structural
// grouping) defines tree shape. Sibling order follows array order, which
@ -117,7 +128,9 @@ function buildTree(nodes: GraphNode[]): TreeNode[] {
}
for (const n of byId.values()) {
n._waitsOn = (n.deps || [])
.filter((d): d is Extract<GraphDep, { kind: 'Node' }> => d.kind === 'Node')
.filter(
(d): d is Extract<GraphDep, { kind: "Node" }> => d.kind === "Node",
)
.map((d) => byId.get(d.id))
.filter((dep): dep is TreeNode => dep != null)
.map((dep) => dep.payload.label);
@ -131,17 +144,19 @@ function buildTree(nodes: GraphNode[]): TreeNode[] {
// single stringified row rather than silently dropping it).
function DataList({ data }: { data: unknown }) {
if (data == null) return null;
const isPlainObject = typeof data === 'object' && !Array.isArray(data);
const isPlainObject = typeof data === "object" && !Array.isArray(data);
const entries: [string, unknown][] = isPlainObject
? Object.entries(data as Record<string, unknown>)
: [['data', data]];
: [["data", data]];
if (!entries.length) return null;
return (
<dl class="jg-data">
{entries.map(([k, v]) => (
<>
<dt key={k + '-dt'}>{k}</dt>
<dd key={k + '-dd'}>{typeof v === 'string' ? v : JSON.stringify(v)}</dd>
<dt key={k + "-dt"}>{k}</dt>
<dd key={k + "-dd"}>
{typeof v === "string" ? v : JSON.stringify(v)}
</dd>
</>
))}
</dl>
@ -157,7 +172,7 @@ function NodeView({
cancellable: boolean;
onCancel?: (id: number) => void;
}) {
const glyph = STATE_GLYPH[n.state] || '?';
const glyph = STATE_GLYPH[n.state] || "?";
const showCancel = cancellable && CANCELLABLE_STATES.has(n.state);
// Flash the state glyph on a genuine state change (Pending → Running,
// etc.), not on mount — `prevState` starts at the node's own initial
@ -176,27 +191,41 @@ function NodeView({
return (
<div class="jg-node">
<div class="jg-row">
<span class={'jg-state jg-state-' + n.state.toLowerCase() + (flashing ? ' jg-state-flash' : '')}
title={n.state + (n.error ? ' — ' + n.error : '')}
onAnimationEnd={() => setFlashing(false)}>
<span
class={
"jg-state jg-state-" +
n.state.toLowerCase() +
(flashing ? " jg-state-flash" : "")
}
title={n.state + (n.error ? " — " + n.error : "")}
onAnimationEnd={() => setFlashing(false)}
>
{glyph}
</span>
{' '}
</span>{" "}
<span class="jg-label">{n.payload.label}</span>
{showCancel && (
<button type="button" class="jg-cancel-btn" title={'cancel ' + n.payload.label}
onClick={() => onCancel && onCancel(n.id)}>
<button
type="button"
class="jg-cancel-btn"
title={"cancel " + n.payload.label}
onClick={() => onCancel && onCancel(n.id)}
>
</button>
)}
</div>
{n._waitsOn && n._waitsOn.length > 0 && (
<div class="jg-waits-on">waits on: {n._waitsOn.join(', ')}</div>
<div class="jg-waits-on">waits on: {n._waitsOn.join(", ")}</div>
)}
<DataList data={n.payload.data} />
{n.error && <pre class="jg-error">{n.error}</pre>}
{n._children.map((child) => (
<NodeView key={child.id} n={child} cancellable={cancellable} onCancel={onCancel} />
<NodeView
key={child.id}
n={child}
cancellable={cancellable}
onCancel={onCancel}
/>
))}
</div>
);
@ -212,12 +241,20 @@ function FilterBar({
return (
<div class="jg-filter">
{ALL_STATES.map((state) => {
const id = 'jg-filter-' + state.toLowerCase();
const id = "jg-filter-" + state.toLowerCase();
return (
<label key={state} for={id} class={'jg-filter-label jg-state-' + state.toLowerCase()}>
<input type="checkbox" id={id} checked={selectedStates.has(state)}
onChange={() => onToggle(state)} />
{' '}{STATE_GLYPH[state] + ' ' + state}
<label
key={state}
for={id}
class={"jg-filter-label jg-state-" + state.toLowerCase()}
>
<input
type="checkbox"
id={id}
checked={selectedStates.has(state)}
onChange={() => onToggle(state)}
/>{" "}
{STATE_GLYPH[state] + " " + state}
</label>
);
})}
@ -229,11 +266,14 @@ function FilterBar({
// param — omitted entirely when every state is checked, so the
// unfiltered default case sends the exact same request as before this
// filter existed.
function fetchUrl(endpoint: string | undefined, selectedStates: Set<NodeState>): string | null {
function fetchUrl(
endpoint: string | undefined,
selectedStates: Set<NodeState>,
): string | null {
if (!endpoint) return null;
if (selectedStates.size >= ALL_STATES.length) return endpoint;
const url = new URL(endpoint, window.location.origin);
url.searchParams.set('states', Array.from(selectedStates).join(','));
url.searchParams.set("states", Array.from(selectedStates).join(","));
return url.pathname + url.search;
}
@ -249,7 +289,13 @@ export interface JobqGraphProps {
// change identity so the effect below re-runs, giving a host an
// explicit "refetch now" lever (bump it and re-render) without an
// imperative ref into this component.
export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }: JobqGraphProps) {
export function JobqGraph({
endpoint,
cancellable = false,
onUpdate,
onCancel,
refreshToken = 0,
}: JobqGraphProps) {
const [selectedStates, setSelectedStates] = useState<Set<NodeState>>(
() => new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s))),
);
@ -259,7 +305,8 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
const toggleState = useCallback((state: NodeState) => {
setSelectedStates((prev) => {
const next = new Set(prev);
if (next.has(state)) next.delete(state); else next.add(state);
if (next.has(state)) next.delete(state);
else next.add(state);
return next;
});
}, []);
@ -271,7 +318,7 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
(async () => {
try {
const r = await fetch(url);
if (!r.ok) throw new Error('http ' + r.status);
if (!r.ok) throw new Error("http " + r.status);
const data = (await r.json()) as GraphNode[];
if (cancelled) return;
setNodes(data);
@ -282,11 +329,13 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
setError(String(err));
}
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedStates is a Set;
// its *contents* are what should retrigger the fetch, not its identity, and the
// string form below already changes identity exactly when contents do.
}, [endpoint, Array.from(selectedStates).sort().join(','), refreshToken]);
}, [endpoint, Array.from(selectedStates).sort().join(","), refreshToken]);
return (
<div class="jg-root">
@ -300,7 +349,12 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
<p class="jg-empty">empty</p>
) : (
buildTree(nodes).map((root) => (
<NodeView key={root.id} n={root} cancellable={cancellable} onCancel={onCancel} />
<NodeView
key={root.id}
n={root}
cancellable={cancellable}
onCancel={onCancel}
/>
))
)}
</div>

View file

@ -65,10 +65,10 @@
animation: none;
}
}
:root[data-motion='reduce'] .jg-node {
:root[data-motion="reduce"] .jg-node {
animation: none;
}
:root[data-motion='allow'] .jg-node {
:root[data-motion="allow"] .jg-node {
animation: jg-node-enter 160ms ease;
}
@ -93,13 +93,30 @@
min-width: 1.2em;
text-align: center;
}
.jg-state-pending { color: var(--muted); }
.jg-state-running { color: var(--cyan); }
.jg-state-finishing { color: var(--cyan); opacity: 0.75; }
.jg-state-done { color: var(--green); }
.jg-state-failed { color: var(--red); }
.jg-state-cancelled { color: var(--muted); text-decoration: line-through; }
.jg-state-skipped { color: var(--muted); opacity: 0.5; }
.jg-state-pending {
color: var(--muted);
}
.jg-state-running {
color: var(--cyan);
}
.jg-state-finishing {
color: var(--cyan);
opacity: 0.75;
}
.jg-state-done {
color: var(--green);
}
.jg-state-failed {
color: var(--red);
}
.jg-state-cancelled {
color: var(--muted);
text-decoration: line-through;
}
.jg-state-skipped {
color: var(--muted);
opacity: 0.5;
}
/* Short-lived pulse applied by `NodeView` (JobqGraph.tsx) exactly when
a node's own `state` value changes on an existing DOM node a mount
@ -123,10 +140,10 @@
animation: none;
}
}
:root[data-motion='reduce'] .jg-state-flash {
:root[data-motion="reduce"] .jg-state-flash {
animation: none;
}
:root[data-motion='allow'] .jg-state-flash {
:root[data-motion="allow"] .jg-state-flash {
animation: jg-state-flash 350ms ease;
}
@ -149,7 +166,9 @@
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease, border-color 0.15s ease;
transition:
color 0.15s ease,
border-color 0.15s ease;
}
.jg-cancel-btn:hover,
.jg-cancel-btn:focus-visible {
@ -166,8 +185,13 @@
grid-template-columns: auto 1fr;
gap: 0 0.5em;
}
.jg-data dt { font-weight: 600; }
.jg-data dd { margin: 0; word-break: break-word; }
.jg-data dt {
font-weight: 600;
}
.jg-data dd {
margin: 0;
word-break: break-word;
}
.jg-waits-on {
margin: 0.1em 0 0 1.6em;

View file

@ -28,12 +28,19 @@
// `refreshToken` to force a refetch. No mount wrapper: `render` is
// already the re-render/diff entry point.
import { useState, useEffect } from 'preact/hooks';
import { useState, useEffect } from "preact/hooks";
// Mirrors `hive_jobq_wire::StateSchema` — only the subset this banner
// cares about, not the full union `JobqGraph.tsx` mirrors, since a
// rollup row's `state` is read by exact string match, not rendered.
type NodeState = 'Pending' | 'Running' | 'Finishing' | 'Done' | 'Failed' | 'Cancelled' | 'Skipped';
type NodeState =
| "Pending"
| "Running"
| "Finishing"
| "Done"
| "Failed"
| "Cancelled"
| "Skipped";
interface StateCount {
state: NodeState;
@ -47,7 +54,11 @@ export interface JobqRollupProps {
refreshToken?: number;
}
export function JobqRollup({ endpoint, queueHref, refreshToken = 0 }: JobqRollupProps) {
export function JobqRollup({
endpoint,
queueHref,
refreshToken = 0,
}: JobqRollupProps) {
const [counts, setCounts] = useState<StateCount[]>([]);
useEffect(() => {
@ -66,12 +77,16 @@ export function JobqRollup({ endpoint, queueHref, refreshToken = 0 }: JobqRollup
// ignore — keep the previous snapshot
}
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [endpoint, refreshToken]);
const byState = new Map(counts.map((c) => [c.state, c]));
const running = (byState.get('Running')?.roots ?? 0) + (byState.get('Finishing')?.roots ?? 0);
const queued = byState.get('Pending')?.roots ?? 0;
const running =
(byState.get("Running")?.roots ?? 0) +
(byState.get("Finishing")?.roots ?? 0);
const queued = byState.get("Pending")?.roots ?? 0;
if (!running && !queued) return null;
const parts: string[] = [];
@ -80,10 +95,12 @@ export function JobqRollup({ endpoint, queueHref, refreshToken = 0 }: JobqRollup
return (
<div class="jqr-summary">
<span class="jqr-glyph spinner"></span>{' '}
<strong>build queue</strong> {parts.join(' · ')}{' '}
<span class="jqr-glyph spinner"></span> <strong>build queue</strong> {" "}
{parts.join(" · ")}{" "}
{queueHref && (
<a class="jqr-link" href={queueHref}>view queue </a>
<a class="jqr-link" href={queueHref}>
view queue
</a>
)}
</div>
);

View file

@ -14,7 +14,9 @@
margin-bottom: 0.6em;
border-radius: 4px;
}
.jqr-summary strong { color: var(--amber); }
.jqr-summary strong {
color: var(--amber);
}
.jqr-link {
margin-left: auto;
color: var(--amber);
@ -22,4 +24,6 @@
font-weight: bold;
white-space: nowrap;
}
.jqr-link:hover { text-decoration: underline; }
.jqr-link:hover {
text-decoration: underline;
}

View file

@ -19,9 +19,9 @@
// Other `.btn` consumers across the app stay on the light-DOM `.btn`
// class for now — migrating them is a separate follow-up.
import { el } from './dom.js';
import './hive-dialog/hive-dialog.js'; // registers <hive-dialog> — side-effect import
import './hive-toast/hive-toast.js'; // registers <hive-toast> — side-effect import
import { el } from "./dom.js";
import "./hive-dialog/hive-dialog.js"; // registers <hive-dialog> — side-effect import
import "./hive-toast/hive-toast.js"; // registers <hive-toast> — side-effect import
// openDialog({ title, message, content, buttons, danger, dismissable })
// → Promise resolving to the clicked button's `value`, or `null` when the
@ -37,9 +37,11 @@ import './hive-toast/hive-toast.js'; // registers <hive-toast> — side-effect i
// stray Enter can't fire the destructive path), else the last button.
export function openDialog(opts = {}) {
return new Promise((resolve) => {
const dlg = document.createElement('hive-dialog');
const dlg = document.createElement("hive-dialog");
dlg._opts = opts;
dlg.addEventListener('hive-dialog-close', (e) => resolve(e.detail), { once: true });
dlg.addEventListener("hive-dialog-close", (e) => resolve(e.detail), {
once: true,
});
document.body.append(dlg);
});
}
@ -54,19 +56,31 @@ export function openDialog(opts = {}) {
// doStop(r.graceful);
export function themedConfirm(opts = {}) {
const {
title = '', message = '', danger = false,
confirmLabel = 'confirm', cancelLabel = 'cancel', checkboxes = [],
title = "",
message = "",
danger = false,
confirmLabel = "confirm",
cancelLabel = "cancel",
checkboxes = [],
} = opts;
const boxes = checkboxes.map((cb) => {
const input = el('input', { type: 'checkbox', class: 'check', name: cb.name });
const input = el("input", {
type: "checkbox",
class: "check",
name: cb.name,
});
if (cb.checked) input.checked = true;
const row = el('label', { class: 'checkrow' },
input, el('span', {}, cb.label || cb.name));
const row = el(
"label",
{ class: "checkrow" },
input,
el("span", {}, cb.label || cb.name),
);
return { input, row };
});
const content = boxes.length
? el('div', { class: 'checks' }, ...boxes.map((b) => b.row))
? el("div", { class: "checks" }, ...boxes.map((b) => b.row))
: null;
return openDialog({
@ -75,13 +89,20 @@ export function themedConfirm(opts = {}) {
content,
danger,
buttons: [
{ label: cancelLabel, value: null, class: 'cancel', autofocus: danger },
{ label: confirmLabel, value: 'confirm', danger, class: 'confirm', autofocus: !danger },
{ label: cancelLabel, value: null, class: "cancel", autofocus: danger },
{
label: confirmLabel,
value: "confirm",
danger,
class: "confirm",
autofocus: !danger,
},
],
}).then((v) => {
if (v !== 'confirm') return null;
if (v !== "confirm") return null;
const out = {};
for (let i = 0; i < boxes.length; i++) out[checkboxes[i].name] = boxes[i].input.checked;
for (let i = 0; i < boxes.length; i++)
out[checkboxes[i].name] = boxes[i].input.checked;
return out;
});
}
@ -95,10 +116,19 @@ export function themedConfirm(opts = {}) {
// (via openDialog).
export function themedPrompt(opts = {}) {
const {
title = '', message = '', label = '', placeholder = '', value = '',
confirmLabel = 'ok', cancelLabel = 'cancel',
title = "",
message = "",
label = "",
placeholder = "",
value = "",
confirmLabel = "ok",
cancelLabel = "cancel",
} = opts;
const input = el('textarea', { class: 'input textarea', rows: '3', placeholder });
const input = el("textarea", {
class: "input textarea",
rows: "3",
placeholder,
});
if (value) input.value = value;
// Enter submits (clicks the confirm button mounted by openDialog);
// Shift+Enter falls through to the textarea's default newline insert.
@ -106,24 +136,32 @@ export function themedPrompt(opts = {}) {
// lives in, so this resolves to the dialog's own `.box`/confirm button
// without leaking across instances. The confirm button is selected by
// its `variant` attribute now (hive-btn.js), not a CSS class.
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
input.closest('.box')?.querySelector('[variant="confirm"]')?.click();
input.closest(".box")?.querySelector('[variant="confirm"]')?.click();
}
});
const content = el('div', { class: 'promptfield' },
label ? el('label', { class: 'promptlabel' }, label) : null,
input);
const content = el(
"div",
{ class: "promptfield" },
label ? el("label", { class: "promptlabel" }, label) : null,
input,
);
const result = openDialog({
title,
message,
content,
buttons: [
{ label: cancelLabel, value: '__cancel__', class: 'cancel' },
{ label: confirmLabel, value: '__ok__', class: 'confirm', autofocus: true },
{ label: cancelLabel, value: "__cancel__", class: "cancel" },
{
label: confirmLabel,
value: "__ok__",
class: "confirm",
autofocus: true,
},
],
}).then((v) => (v === '__ok__' ? input.value : null));
}).then((v) => (v === "__ok__" ? input.value : null));
// Prefer focusing the field over the OK button once the dialog has mounted.
setTimeout(() => input.focus(), 0);
return result;
@ -138,24 +176,24 @@ export function themedPrompt(opts = {}) {
// component — no theming, no encapsulation need), so it's styled with a
// one-off inline style rather than a stylesheet.
export function themedToast(message, opts = {}) {
let container = document.getElementById('tc-toasts');
let container = document.getElementById("tc-toasts");
if (!container) {
container = document.createElement('div');
container.id = 'tc-toasts';
container = document.createElement("div");
container.id = "tc-toasts";
Object.assign(container.style, {
position: 'fixed',
top: '1em',
right: '1em',
zIndex: '1100',
display: 'flex',
flexDirection: 'column',
gap: '0.5em',
maxWidth: 'min(28em, 92vw)',
pointerEvents: 'none',
position: "fixed",
top: "1em",
right: "1em",
zIndex: "1100",
display: "flex",
flexDirection: "column",
gap: "0.5em",
maxWidth: "min(28em, 92vw)",
pointerEvents: "none",
});
document.body.append(container);
}
const toast = document.createElement('hive-toast');
const toast = document.createElement("hive-toast");
toast._message = message;
toast._opts = opts;
container.append(toast);

View file

@ -5,7 +5,7 @@
// `ExpandDetailsSetting` (the per-agent page's own settings popover) is
// the only writer today; `Row.tsx` is the only reader.
const EXPAND_DETAILS_KEY = 'hive-agent-expand-details';
const EXPAND_DETAILS_KEY = "hive-agent-expand-details";
// Whether a per-agent terminal's otherwise-collapsed `<details>` panels
// (long tool-results, Write/Edit diffs, …) should default open. Pure
@ -16,14 +16,14 @@ const EXPAND_DETAILS_KEY = 'hive-agent-expand-details';
// any already-open agent tab without a reload.
export function getExpandDetailsPref(): boolean {
try {
return localStorage.getItem(EXPAND_DETAILS_KEY) === '1';
return localStorage.getItem(EXPAND_DETAILS_KEY) === "1";
} catch {
return false;
}
}
export function setExpandDetailsPref(v: boolean): void {
try {
if (v) localStorage.setItem(EXPAND_DETAILS_KEY, '1');
if (v) localStorage.setItem(EXPAND_DETAILS_KEY, "1");
else localStorage.removeItem(EXPAND_DETAILS_KEY);
} catch {
/* localStorage unavailable — preference is session-only */

View file

@ -23,16 +23,16 @@
// once, high in its tree, with the SAME key strings passed to this
// component, so the two stay in sync without this component owning any
// page-specific naming decision.
import { useEffect, useRef, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import { Badge } from '../badge/Badge.js';
import { GearIcon } from '../icons.js';
import { useThemeOverride, type ThemeOverride } from './theme-apply.js';
import { useMotionOverride, type MotionOverride } from './motion-apply.js';
import './SettingsMenu.css';
import { useEffect, useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { Badge } from "../badge/Badge.js";
import { GearIcon } from "../icons.js";
import { useThemeOverride, type ThemeOverride } from "./theme-apply.js";
import { useMotionOverride, type MotionOverride } from "./motion-apply.js";
import "./SettingsMenu.css";
const THEME_OPTIONS: ThemeOverride[] = ['system', 'light', 'dark'];
const MOTION_OPTIONS: MotionOverride[] = ['system', 'allow', 'reduce'];
const THEME_OPTIONS: ThemeOverride[] = ["system", "light", "dark"];
const MOTION_OPTIONS: MotionOverride[] = ["system", "allow", "reduce"];
export interface SettingsMenuProps {
themeKey: string;
@ -51,7 +51,11 @@ export interface SettingsMenuProps {
children?: ComponentChildren;
}
export function SettingsMenu({ themeKey, motionKey, children }: SettingsMenuProps) {
export function SettingsMenu({
themeKey,
motionKey,
children,
}: SettingsMenuProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const [theme, setTheme] = useThemeOverride(themeKey);
@ -61,16 +65,21 @@ export function SettingsMenu({ themeKey, motionKey, children }: SettingsMenuProp
useEffect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) setOpen(false);
if (
rootRef.current &&
e.target instanceof Node &&
!rootRef.current.contains(e.target)
)
setOpen(false);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
if (e.key === "Escape") setOpen(false);
}
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKeyDown);
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
@ -89,7 +98,9 @@ export function SettingsMenu({ themeKey, motionKey, children }: SettingsMenuProp
<span>theme</span>
<select
value={theme}
onChange={(e) => setTheme((e.target as HTMLSelectElement).value as ThemeOverride)}
onChange={(e) =>
setTheme((e.target as HTMLSelectElement).value as ThemeOverride)
}
>
{THEME_OPTIONS.map((o) => (
<option key={o} value={o}>
@ -102,7 +113,11 @@ export function SettingsMenu({ themeKey, motionKey, children }: SettingsMenuProp
<span>motion</span>
<select
value={motion}
onChange={(e) => setMotion((e.target as HTMLSelectElement).value as MotionOverride)}
onChange={(e) =>
setMotion(
(e.target as HTMLSelectElement).value as MotionOverride,
)
}
>
{MOTION_OPTIONS.map((o) => (
<option key={o} value={o}>

View file

@ -12,22 +12,28 @@
// (see swarm-ui's `shell/Shell.css` file-top comment for a worked
// example). A page with no motion-gated animation yet can still mount
// this — the attribute is simply inert until something reads it.
import { useEffect } from 'preact/hooks';
import { useLocalSetting } from './settings-storage.js';
import { useEffect } from "preact/hooks";
import { useLocalSetting } from "./settings-storage.js";
export type MotionOverride = 'system' | 'reduce' | 'allow';
export type MotionOverride = "system" | "reduce" | "allow";
export function useMotionOverride(key: string, fallback: MotionOverride = 'system') {
export function useMotionOverride(
key: string,
fallback: MotionOverride = "system",
) {
return useLocalSetting<MotionOverride>(key, fallback);
}
// Mounted once alongside `useApplyThemeOverride` — same single-mount-
// point rationale.
export function useApplyMotionOverride(key: string, fallback: MotionOverride = 'system'): void {
export function useApplyMotionOverride(
key: string,
fallback: MotionOverride = "system",
): void {
const [override] = useMotionOverride(key, fallback);
useEffect(() => {
const root = document.documentElement;
if (override === 'system') {
if (override === "system") {
delete root.dataset.motion;
} else {
root.dataset.motion = override;

View file

@ -14,7 +14,7 @@
// reading) need their own same-tab signal. The tiny module-level pub/sub
// below is that signal; it's deliberately not exported, callers only see
// the hook.
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from "preact/hooks";
const listeners = new Map<string, Set<() => void>>();
@ -70,10 +70,16 @@ function writeLocalSetting<T>(key: string, value: T): void {
// this component's state through the identical "react to a change"
// path every other subscriber uses — one path, not two that have to
// agree.
export function useLocalSetting<T>(key: string, fallback: T): [T, (value: T) => void] {
export function useLocalSetting<T>(
key: string,
fallback: T,
): [T, (value: T) => void] {
const [value, setValue] = useState<T>(() => readLocalSetting(key, fallback));
useEffect(() => subscribe(key, () => setValue(readLocalSetting(key, fallback))), [key]);
useEffect(
() => subscribe(key, () => setValue(readLocalSetting(key, fallback))),
[key],
);
return [value, (next: T) => writeLocalSetting(key, next)];
}

View file

@ -13,12 +13,15 @@
// — `'system'` as a default silently reads as light-by-default for most
// first-time visitors (mara: "light mode seems to be default"). Still
// fully overridable per caller if a page ever wants a different default.
import { useEffect } from 'preact/hooks';
import { useLocalSetting } from './settings-storage.js';
import { useEffect } from "preact/hooks";
import { useLocalSetting } from "./settings-storage.js";
export type ThemeOverride = 'system' | 'light' | 'dark';
export type ThemeOverride = "system" | "light" | "dark";
export function useThemeOverride(key: string, fallback: ThemeOverride = 'dark') {
export function useThemeOverride(
key: string,
fallback: ThemeOverride = "dark",
) {
return useLocalSetting<ThemeOverride>(key, fallback);
}
@ -27,11 +30,14 @@ export function useThemeOverride(key: string, fallback: ThemeOverride = 'dark')
// same-tab subscription means `SettingsMenu` changing the value re-runs
// this effect without either component needing to know about the other
// directly.
export function useApplyThemeOverride(key: string, fallback: ThemeOverride = 'dark'): void {
export function useApplyThemeOverride(
key: string,
fallback: ThemeOverride = "dark",
): void {
const [override] = useThemeOverride(key, fallback);
useEffect(() => {
const root = document.documentElement;
if (override === 'system') {
if (override === "system") {
delete root.dataset.theme;
} else {
root.dataset.theme = override;

View file

@ -13,8 +13,8 @@
// one parsed stylesheet object across many instances — that's the case it
// exists for.
export function attachShadowCss(host, cssText, shadowInit = {}) {
const root = host.attachShadow({ mode: 'open', ...shadowInit });
const style = document.createElement('style');
const root = host.attachShadow({ mode: "open", ...shadowInit });
const style = document.createElement("style");
style.textContent = cssText;
root.append(style);
return root;

View file

@ -50,9 +50,15 @@
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); }
: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;
@ -100,9 +106,14 @@
line-height: 1;
padding: 0.15em 0.4em;
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
transition:
border-color 0.15s ease,
color 0.15s ease;
}
.side-panel-close:hover {
border-color: var(--red);
color: var(--red);
}
.side-panel-close:hover { border-color: var(--red); color: var(--red); }
.side-panel-body {
flex: 1;
overflow-y: auto;

View file

@ -27,54 +27,74 @@
// 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';
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_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.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._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._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._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._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' });
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._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();
@ -89,8 +109,8 @@ class HiveSidePanel extends HTMLElement {
this._owner = name;
this._titleEl.textContent = title;
this.replaceChildren(...(content ? [content] : []));
this.classList.add('open');
this.setAttribute('aria-hidden', 'false');
this.classList.add("open");
this.setAttribute("aria-hidden", "false");
}
refresh(name, title, content) {
@ -101,8 +121,8 @@ class HiveSidePanel extends HTMLElement {
close() {
this._owner = null;
this.classList.remove('open');
this.setAttribute('aria-hidden', 'true');
this.classList.remove("open");
this.setAttribute("aria-hidden", "true");
}
currentOwner() {
@ -120,64 +140,79 @@ class HiveSidePanel extends HTMLElement {
_applyStoredWidth() {
const raw = (() => {
try { return localStorage.getItem(WIDTH_KEY); }
catch { return null; }
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');
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) => {
handle.addEventListener("pointerdown", (e) => {
e.preventDefault();
dragging = true;
document.body.classList.add('side-panel-resizing');
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');
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 */ }
try {
handle.setPointerCapture(e.pointerId);
} catch {
/* legacy */
}
});
document.addEventListener('pointermove', (e) => {
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 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');
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 */ }
try {
localStorage.setItem(WIDTH_KEY, String(Math.round(w)));
} catch {
/* localStorage unavailable — width is session-only */
}
};
document.addEventListener('pointerup', stopDrag);
document.addEventListener('pointercancel', stopDrag);
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', () => {
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');
drawer.style.setProperty("--side-panel-w", clamped + "px");
}
});
}
}
customElements.define('hive-side-panel', HiveSidePanel);
customElements.define("hive-side-panel", HiveSidePanel);

View file

@ -27,29 +27,37 @@
// call site keeps working unchanged. Call configure() exactly once per
// element (throws if called twice), mirroring the old call sites.
import { el } from '../dom.js';
import { createTabStrip } from './tabs.js';
import { el } from "../dom.js";
import { createTabStrip } from "./tabs.js";
class HiveTabStrip extends HTMLElement {
configure({ tabs, defaultId, onShow }) {
if (this._api) {
throw new Error('hive-tab-strip: configure() called twice on the same element');
throw new Error(
"hive-tab-strip: configure() called twice on the same element",
);
}
const prefix = this.getAttribute('prefix');
const prefix = this.getAttribute("prefix");
if (!prefix) {
throw new Error('hive-tab-strip: missing required `prefix` attribute');
throw new Error("hive-tab-strip: missing required `prefix` attribute");
}
for (const tab of tabs) {
const a = el('a', {
class: 'hive-tab',
id: `${prefix}-tab-${tab.id}`,
href: `#${tab.id}`,
role: 'tab',
'aria-controls': `${prefix}-pane-${tab.id}`,
'data-tab': tab.id,
}, tab.label);
const a = el(
"a",
{
class: "hive-tab",
id: `${prefix}-tab-${tab.id}`,
href: `#${tab.id}`,
role: "tab",
"aria-controls": `${prefix}-pane-${tab.id}`,
"data-tab": tab.id,
},
tab.label,
);
if (tab.badgeId) {
a.append(el('span', { class: 'tab-count', id: tab.badgeId, hidden: '' }));
a.append(
el("span", { class: "tab-count", id: tab.badgeId, hidden: "" }),
);
}
this.append(a);
}
@ -57,4 +65,4 @@ class HiveTabStrip extends HTMLElement {
return this._api;
}
}
customElements.define('hive-tab-strip', HiveTabStrip);
customElements.define("hive-tab-strip", HiveTabStrip);

View file

@ -23,7 +23,9 @@
font-size: 0.85em;
letter-spacing: 0.05em;
cursor: pointer;
transition: background 100ms, color 100ms;
transition:
background 100ms,
color 100ms;
}
.hive-tab:hover {

View file

@ -27,44 +27,44 @@ export function createTabStrip(tabbar, opts = {}) {
if (!tabbar) {
return { show() {}, active: () => null };
}
const tabs = Array.from(tabbar.querySelectorAll('[data-tab]'));
const ids = tabs.map((t) => t.getAttribute('data-tab'));
const tabs = Array.from(tabbar.querySelectorAll("[data-tab]"));
const ids = tabs.map((t) => t.getAttribute("data-tab"));
const fallback = defaultId && ids.includes(defaultId) ? defaultId : ids[0];
const paneFor = (id) => document.querySelector(`[data-tab-pane="${id}"]`);
const active = () => {
const hash = location.hash.replace(/^#/, '');
const hash = location.hash.replace(/^#/, "");
return ids.includes(hash) ? hash : fallback;
};
const show = (id) => {
const target = ids.includes(id) ? id : fallback;
for (const tab of tabs) {
const tid = tab.getAttribute('data-tab');
const tid = tab.getAttribute("data-tab");
const on = tid === target;
tab.classList.toggle('hive-tab--active', on);
tab.setAttribute('aria-selected', String(on));
tab.classList.toggle("hive-tab--active", on);
tab.setAttribute("aria-selected", String(on));
const pane = paneFor(tid);
if (pane) pane.hidden = !on;
}
if (typeof onShow === 'function') onShow(target);
if (typeof onShow === "function") onShow(target);
};
// Drive every activation through the hash so deep-links + back/forward
// stay authoritative; the hashchange listener does the actual showing.
for (const tab of tabs) {
tab.addEventListener('click', (e) => {
tab.addEventListener("click", (e) => {
e.preventDefault();
const id = tab.getAttribute('data-tab');
if (location.hash.replace(/^#/, '') === id) {
const id = tab.getAttribute("data-tab");
if (location.hash.replace(/^#/, "") === id) {
show(id); // same hash → no hashchange, re-show directly
} else {
location.hash = id;
}
});
}
window.addEventListener('hashchange', () => show(active()));
window.addEventListener("hashchange", () => show(active()));
show(active()); // initial render from the current hash
return { show, active };

View file

@ -24,7 +24,8 @@
border: 1px solid var(--purple-dim);
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7);
border-radius: 4px;
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
font-family:
"JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
font-size: 0.92em;
color: var(--fg);
margin-top: 0.6em;
@ -64,8 +65,14 @@
animation: none;
}
@keyframes row-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Unified prefix column for every row kind. The glyph (` · !`)
is the first character of the row's text content; `padding-left` reserves
@ -83,7 +90,9 @@
text-indent: -1.4em;
margin: 0.1em 0;
}
.live .row + .row { border-top: 0; }
.live .row + .row {
border-top: 0;
}
/* Fixed-width icon column. Rows built with an `icon` (see terminal.js
`row()` / `details()`) put it in a `.row-glyph` element instead of as a
bare first character. `inline-block` with a fixed `width` means the icon
@ -107,10 +116,20 @@
identity (this is a turn boundary, this is a tool call) is carried by
the row's icon (``, `🔧`, ``, ) and summary text instead of colour
see docs/web-ui/terminal-rendering.md. */
.live .level-debug { color: var(--muted); }
.live .level-info { color: var(--fg); }
.live .level-warn { color: var(--amber); border-left-color: var(--amber); }
.live .level-error { color: var(--red); border-left-color: var(--red); }
.live .level-debug {
color: var(--muted);
}
.live .level-info {
color: var(--fg);
}
.live .level-warn {
color: var(--amber);
border-left-color: var(--amber);
}
.live .level-error {
color: var(--red);
border-left-color: var(--red);
}
/* `badge-pulse` itself is no longer used by any terminal row (the
turn-start `unread` count it animated is gone see term_msg.rs's
module doc), but agent.css's `.state-badge.state-thinking`/
@ -118,13 +137,23 @@
`@import "@hive/shared/terminal.css"` keep the definition here, drop
only the terminal-specific `.unread-badge` selector that used it. */
@keyframes badge-pulse {
0%, 100% { opacity: 1; text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent); }
50% { opacity: 0.7; text-shadow: 0 0 14px color-mix(in srgb, var(--amber) 95%, transparent); }
0%,
100% {
opacity: 1;
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent);
}
50% {
opacity: 0.7;
text-shadow: 0 0 14px color-mix(in srgb, var(--amber) 95%, transparent);
}
}
/* Any child block (markdown body, nested details) resets the parent
row's hanging indent so the content lays out from column 0 of the
body area. */
.live .row .md, .live .row > details { text-indent: 0; }
.live .row .md,
.live .row > details {
text-indent: 0;
}
/* "↓ N new" pill: shown when new rows arrive while the operator is
scrolled up; click to jump to bottom. Positioned by the wrapper's
`position: relative` (terminal-wrap supplies it; pages that skip the
@ -147,14 +176,18 @@
opacity: 0;
transform: translateY(6px);
pointer-events: none;
transition: opacity 160ms ease, transform 160ms ease;
transition:
opacity 160ms ease,
transform 160ms ease;
}
.tail-pill.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.tail-pill:hover { filter: brightness(1.1); }
.tail-pill:hover {
filter: brightness(1.1);
}
/* "↑ load older" pill: sits inline at the top of the log (not
absolutely positioned) so it scrolls with the content. Appears
when `has_more` is true after initial history load. */
@ -172,7 +205,9 @@
padding: 0.4em 1em;
cursor: pointer;
text-align: left;
transition: color 120ms ease, background 120ms ease;
transition:
color 120ms ease,
background 120ms ease;
}
.load-more-pill:hover:not(:disabled) {
color: var(--fg);
@ -235,10 +270,12 @@ details.row > summary > .summary-text:only-child {
text-indent: -1.4em;
}
details.row > summary > .summary-text::before {
content: '▸ ';
content: "▸ ";
color: inherit;
}
details.row[open] > summary > .summary-text::before { content: '▾ '; }
details.row[open] > summary > .summary-text::before {
content: "▾ ";
}
details.row > pre.diff-body,
details.row > pre.tool-body {
margin: 0.3em 0 0.4em 0;
@ -251,16 +288,30 @@ details.row > pre.tool-body {
max-height: 22em;
overflow-y: auto;
}
details.row > pre.tool-body { color: var(--fg); }
details.row > pre.diff-body .diff-add { color: var(--green); }
details.row > pre.diff-body .diff-del { color: var(--red); }
details.row > pre.diff-body .diff-ctx { color: var(--fg); }
details.row > pre.tool-body {
color: var(--fg);
}
details.row > pre.diff-body .diff-add {
color: var(--green);
}
details.row > pre.diff-body .diff-del {
color: var(--red);
}
details.row > pre.diff-body .diff-ctx {
color: var(--fg);
}
/* Markdown body inside a row (assistant text, send/recv message
bodies). Inline elements get muted accents; block elements
reset the parent row's hanging indent so content lays out cleanly. */
.live .row .md p { margin: 0.2em 0; }
.live .row .md p:first-child { margin-top: 0; }
.live .row .md p:last-child { margin-bottom: 0; }
.live .row .md p {
margin: 0.2em 0;
}
.live .row .md p:first-child {
margin-top: 0;
}
.live .row .md p:last-child {
margin-bottom: 0;
}
.live .row .md code {
background: rgba(255, 255, 255, 0.06);
padding: 0.05em 0.3em;
@ -281,14 +332,34 @@ details.row > pre.diff-body .diff-ctx { color: var(--fg); }
padding: 0;
border-radius: 0;
}
.live .row .md a { color: var(--cyan); text-decoration: underline; }
.live .row .md a {
color: var(--cyan);
text-decoration: underline;
}
/* Auto-linkified bare URLs in plain rows + tool-body blocks. */
.live .row a { color: var(--cyan); text-decoration: underline; }
.live .row a:hover { color: var(--fg); }
.live .row .md strong { color: inherit; font-weight: bold; }
.live .row .md em { color: inherit; font-style: italic; }
.live .row .md ul, .live .row .md ol { margin: 0.2em 0 0.2em 1.4em; padding: 0; }
.live .row .md li { margin: 0.05em 0; }
.live .row a {
color: var(--cyan);
text-decoration: underline;
}
.live .row a:hover {
color: var(--fg);
}
.live .row .md strong {
color: inherit;
font-weight: bold;
}
.live .row .md em {
color: inherit;
font-style: italic;
}
.live .row .md ul,
.live .row .md ol {
margin: 0.2em 0 0.2em 1.4em;
padding: 0;
}
.live .row .md li {
margin: 0.05em 0;
}
.live .row .md blockquote {
margin: 0.2em 0;
padding-left: 0.6em;

View file

@ -23,11 +23,11 @@ export function create(opts) {
// row/details/etc. into a detached temp element while prepending older
// history (restored before any scrollTop adjustments).
let log = opts.logEl;
if (!log) throw new Error('HiveTerminal.create: logEl is required');
if (!log) throw new Error("HiveTerminal.create: logEl is required");
const rootLog = log; // always the real DOM element — never reassigned
const renderers = opts.renderers || {};
const defaultRender = renderers._default
|| ((ev, api) => api.row('note', JSON.stringify(ev)));
const defaultRender =
renderers._default || ((ev, api) => api.row("note", JSON.stringify(ev)));
const pillAnchor = opts.pillAnchor || log.parentElement || log;
let placeholderEl = null;
@ -55,7 +55,9 @@ export function create(opts) {
let scrollAnimRaf = 0;
function isNearBottom() {
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
return (
log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX
);
}
// Snap the log to the bottom with a brief eased animation. Cancels
// any in-flight frame loop so back-to-back snaps coalesce; falls
@ -99,23 +101,23 @@ export function create(opts) {
}
function ensurePill() {
if (pill) return pill;
pill = document.createElement('button');
pill.type = 'button';
pill.className = 'tail-pill';
pill.addEventListener('click', () => snapToBottom());
pill = document.createElement("button");
pill.type = "button";
pill.className = "tail-pill";
pill.addEventListener("click", () => snapToBottom());
pillAnchor.appendChild(pill);
return pill;
}
function updatePill() {
if (unseen <= 0) {
if (pill) pill.classList.remove('visible');
if (pill) pill.classList.remove("visible");
return;
}
ensurePill();
pill.textContent = '↓ ' + unseen + ' new';
pill.classList.add('visible');
pill.textContent = "↓ " + unseen + " new";
pill.classList.add("visible");
}
log.addEventListener('scroll', () => {
log.addEventListener("scroll", () => {
// Sticky-bottom intent tracking. Outside an animation this is
// straightforward — stickToBottom = isNearBottom(). During a
// smooth-snap animation we swallow most of the event to avoid a
@ -137,11 +139,18 @@ export function create(opts) {
// MO stops calling snapToBottom() and the gate expires.
stickToBottom = false;
}
if (stickToBottom) { unseen = 0; updatePill(); }
if (stickToBottom) {
unseen = 0;
updatePill();
}
if (inAnim) return;
// Auto-fetch older history when the user scrolls near the top — no
// click required; the load-more pill stays as a visual indicator.
if (rootLog.scrollTop <= LOAD_MORE_SCROLL_PX && histHasMore && !histLoading) {
if (
rootLog.scrollTop <= LOAD_MORE_SCROLL_PX &&
histHasMore &&
!histLoading
) {
loadMore();
}
});
@ -174,8 +183,8 @@ export function create(opts) {
}
function placeholder(text) {
clearPlaceholder();
const e = document.createElement('div');
e.className = 'row note';
const e = document.createElement("div");
e.className = "row note";
e.textContent = text;
log.appendChild(e);
placeholderEl = e;
@ -186,8 +195,8 @@ export function create(opts) {
// Optional: callers that pass no `icon` keep the bare first-character
// prefix the older rows rely on.
function glyphSpan(icon) {
const g = document.createElement('span');
g.className = 'row-glyph';
const g = document.createElement("span");
g.className = "row-glyph";
g.textContent = icon;
return g;
}
@ -196,10 +205,10 @@ export function create(opts) {
// caret (CSS `.summary-text::before`) then leads the text, not the icon,
// so the icon stays aligned with flat-row icons.
function buildSummary(summary, icon) {
const s = document.createElement('summary');
if (icon != null && icon !== '') s.appendChild(glyphSpan(icon));
const st = document.createElement('span');
st.className = 'summary-text';
const s = document.createElement("summary");
if (icon != null && icon !== "") s.appendChild(glyphSpan(icon));
const st = document.createElement("span");
st.className = "summary-text";
st.textContent = summary;
s.appendChild(st);
return s;
@ -207,9 +216,9 @@ export function create(opts) {
function row(cls, text, icon) {
clearPlaceholder();
const wasNearBottom = isNearBottom();
const e = document.createElement('div');
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
if (icon != null && icon !== '') e.appendChild(glyphSpan(icon));
const e = document.createElement("div");
e.className = "row " + (cls || "") + (currentNoAnim ? " no-anim" : "");
if (icon != null && icon !== "") e.appendChild(glyphSpan(icon));
e.appendChild(linkify(text));
log.appendChild(e);
afterAppend(wasNearBottom);
@ -222,10 +231,10 @@ export function create(opts) {
function mutableRow(cls, text, icon) {
clearPlaceholder();
const wasNearBottom = isNearBottom();
const e = document.createElement('div');
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
if (icon != null && icon !== '') e.appendChild(glyphSpan(icon));
const tn = document.createTextNode(text == null ? '' : String(text));
const e = document.createElement("div");
e.className = "row " + (cls || "") + (currentNoAnim ? " no-anim" : "");
if (icon != null && icon !== "") e.appendChild(glyphSpan(icon));
const tn = document.createTextNode(text == null ? "" : String(text));
e.appendChild(tn);
log.appendChild(e);
afterAppend(wasNearBottom);
@ -240,17 +249,19 @@ export function create(opts) {
// already set `d.open = true` themselves after calling this — this only
// changes the *default* for panels that would otherwise start closed.
function wantsExpandedDefault() {
return typeof opts.expandDetails === 'function' ? !!opts.expandDetails() : !!opts.expandDetails;
return typeof opts.expandDetails === "function"
? !!opts.expandDetails()
: !!opts.expandDetails;
}
function details(cls, summary, body, icon) {
clearPlaceholder();
const wasNearBottom = isNearBottom();
const d = document.createElement('details');
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
const d = document.createElement("details");
d.className = "row " + (cls || "") + (currentNoAnim ? " no-anim" : "");
if (wantsExpandedDefault()) d.open = true;
d.appendChild(buildSummary(summary, icon));
const pre = document.createElement('pre');
pre.className = 'tool-body';
const pre = document.createElement("pre");
pre.className = "tool-body";
pre.appendChild(linkify(body));
d.appendChild(pre);
log.appendChild(d);
@ -260,18 +271,18 @@ export function create(opts) {
function detailsDiff(cls, summary, body, icon) {
clearPlaceholder();
const wasNearBottom = isNearBottom();
const d = document.createElement('details');
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
const d = document.createElement("details");
d.className = "row " + (cls || "") + (currentNoAnim ? " no-anim" : "");
if (wantsExpandedDefault()) d.open = true;
d.appendChild(buildSummary(summary, icon));
const pre = document.createElement('pre');
pre.className = 'tool-body diff-body';
for (const line of String(body).split('\n')) {
const span = document.createElement('span');
if (line.startsWith('+ ')) span.className = 'diff-add';
else if (line.startsWith('- ')) span.className = 'diff-del';
else span.className = 'diff-ctx';
span.textContent = line + '\n';
const pre = document.createElement("pre");
pre.className = "tool-body diff-body";
for (const line of String(body).split("\n")) {
const span = document.createElement("span");
if (line.startsWith("+ ")) span.className = "diff-add";
else if (line.startsWith("- ")) span.className = "diff-del";
else span.className = "diff-ctx";
span.textContent = line + "\n";
pre.appendChild(span);
}
d.appendChild(pre);
@ -281,22 +292,33 @@ export function create(opts) {
}
function api(extra) {
return Object.assign({
row, mutableRow, details, detailsDiff, placeholder, linkify,
fromHistory: false,
}, extra || {});
return Object.assign(
{
row,
mutableRow,
details,
detailsDiff,
placeholder,
linkify,
fromHistory: false,
},
extra || {},
);
}
function dispatch(ev, fromHistory) {
const r = renderers[ev.kind] || defaultRender;
try {
r(ev, api({ fromHistory }));
} catch (err) {
console.error('terminal renderer threw', ev, err);
row('note', '[render err] ' + (err && err.message ? err.message : err));
console.error("terminal renderer threw", ev, err);
row("note", "[render err] " + (err && err.message ? err.message : err));
}
if (opts.onAnyEvent) {
try { opts.onAnyEvent(ev, { fromHistory }); }
catch (err) { console.error('onAnyEvent threw', err); }
try {
opts.onAnyEvent(ev, { fromHistory });
} catch (err) {
console.error("onAnyEvent threw", err);
}
}
}
@ -317,29 +339,36 @@ export function create(opts) {
return;
}
if (!loadMoreBtn) {
loadMoreBtn = document.createElement('button');
loadMoreBtn.type = 'button';
loadMoreBtn.className = 'load-more-pill';
loadMoreBtn.addEventListener('click', loadMore);
loadMoreBtn = document.createElement("button");
loadMoreBtn.type = "button";
loadMoreBtn.className = "load-more-pill";
loadMoreBtn.addEventListener("click", loadMore);
rootLog.prepend(loadMoreBtn);
}
loadMoreBtn.textContent = '↑ load older';
loadMoreBtn.textContent = "↑ load older";
loadMoreBtn.disabled = false;
}
async function loadMore() {
if (!histHasMore || histLoading || !opts.historyUrl || histMinId === null) return;
if (!histHasMore || histLoading || !opts.historyUrl || histMinId === null)
return;
histLoading = true;
if (loadMoreBtn) { loadMoreBtn.textContent = '↑ loading…'; loadMoreBtn.disabled = true; }
if (loadMoreBtn) {
loadMoreBtn.textContent = "↑ loading…";
loadMoreBtn.disabled = true;
}
try {
const sep = opts.historyUrl.includes('?') ? '&' : '?';
const url = opts.historyUrl + sep + 'before=' + histMinId;
const sep = opts.historyUrl.includes("?") ? "&" : "?";
const url = opts.historyUrl + sep + "before=" + histMinId;
const resp = await fetch(url);
if (!resp.ok) { updateLoadMoreBtn(); return; }
if (!resp.ok) {
updateLoadMoreBtn();
return;
}
const body = await resp.json();
const events = Array.isArray(body) ? body : (body.events || []);
const events = Array.isArray(body) ? body : body.events || [];
histHasMore = body.has_more || false;
if (typeof body.min_id === 'number') histMinId = body.min_id;
if (typeof body.min_id === "number") histMinId = body.min_id;
// Resolve load-more button state before capturing the scroll
// baseline so that any button removal is already reflected in
@ -350,7 +379,7 @@ export function create(opts) {
if (events.length > 0) {
// Render into a detached element; `log` is temporarily redirected
// so that row/details/etc. append there instead of rootLog.
const tempEl = document.createElement('div');
const tempEl = document.createElement("div");
log = tempEl;
currentNoAnim = true;
for (const ev of events) dispatch(ev, true);
@ -358,23 +387,26 @@ export function create(opts) {
log = rootLog;
// Separator to mark the boundary between loaded-older and newer.
const sepEl = document.createElement('div');
sepEl.className = 'row note no-anim';
sepEl.textContent = '─── older above ───';
const sepEl = document.createElement("div");
sepEl.className = "row note no-anim";
sepEl.textContent = "─── older above ───";
tempEl.appendChild(sepEl);
// Insert before the "live" divider (i.e. right after the load-more
// button if present, else at the very top of rootLog).
const anchor = loadMoreBtn ? loadMoreBtn.nextSibling : rootLog.firstChild;
const anchor = loadMoreBtn
? loadMoreBtn.nextSibling
: rootLog.firstChild;
const beforeH = rootLog.scrollHeight;
while (tempEl.firstChild) rootLog.insertBefore(tempEl.firstChild, anchor);
while (tempEl.firstChild)
rootLog.insertBefore(tempEl.firstChild, anchor);
// Compensate scroll so the viewport stays on the same content.
// overflow-anchor: none on .live ensures the browser does not
// also auto-adjust scrollTop (which would double the delta).
rootLog.scrollTop += rootLog.scrollHeight - beforeH;
}
} catch (err) {
console.warn('loadMore failed', err);
console.warn("loadMore failed", err);
updateLoadMoreBtn();
} finally {
histLoading = false;
@ -414,21 +446,31 @@ export function create(opts) {
: new EventSource(opts.streamUrl);
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); }
catch (err) { row('note', '[parse err] ' + e.data); return; }
if (!live) { buffered.push(ev); return; }
try {
ev = JSON.parse(e.data);
} catch (err) {
row("note", "[parse err] " + e.data);
return;
}
if (!live) {
buffered.push(ev);
return;
}
dispatch(ev, false);
if (opts.onLiveEvent) {
try { opts.onLiveEvent(ev); }
catch (err) { console.error('onLiveEvent threw', err); }
try {
opts.onLiveEvent(ev);
} catch (err) {
console.error("onLiveEvent threw", err);
}
}
};
es.onerror = () => {
// SharedWorker-backed facades expose `readyState` mirroring the
// upstream EventSource state; the native EventSource exposes the
// same. Either way the CONNECTING vs. closed distinction works.
if (es.readyState === 0 /* CONNECTING */) row('note', '[reconnecting…]');
else row('note', '[disconnected]');
if (es.readyState === 0 /* CONNECTING */) row("note", "[reconnecting…]");
else row("note", "[disconnected]");
};
es.onopen = () => {
// Fires on the initial connect and on every automatic
@ -438,8 +480,11 @@ export function create(opts) {
// must re-sync here or it shows stale state until a manual
// reload.
if (opts.onStreamOpen) {
try { opts.onStreamOpen(); }
catch (err) { console.error('onStreamOpen threw', err); }
try {
opts.onStreamOpen();
} catch (err) {
console.error("onStreamOpen threw", err);
}
}
};
@ -456,15 +501,22 @@ export function create(opts) {
// broker-history seq would wrongly drop ones that fired
// between a consumer's own snapshot read and this history
// fetch. ev.seq absent/0 → no dedupe possible.
if (boundarySeq != null
&& typeof ev.seq === 'number' && ev.seq <= boundarySeq
&& historyKinds && historyKinds.has(ev.kind)) {
if (
boundarySeq != null &&
typeof ev.seq === "number" &&
ev.seq <= boundarySeq &&
historyKinds &&
historyKinds.has(ev.kind)
) {
continue;
}
dispatch(ev, false);
if (opts.onLiveEvent) {
try { opts.onLiveEvent(ev); }
catch (err) { console.error('onLiveEvent threw', err); }
try {
opts.onLiveEvent(ev);
} catch (err) {
console.error("onLiveEvent threw", err);
}
}
}
}
@ -486,12 +538,12 @@ export function create(opts) {
// Accept the envelope `{ seq, events, min_id?, has_more? }`.
// A bare array means the server hasn't been updated — treat it
// as "no dedupe possible, no pagination."
const events = Array.isArray(body) ? body : (body.events || []);
const events = Array.isArray(body) ? body : body.events || [];
const boundarySeq = Array.isArray(body) ? null : (body.seq ?? null);
// Pagination cursors — set on the outer load-more state.
if (!Array.isArray(body)) {
histHasMore = body.has_more || false;
if (typeof body.min_id === 'number') histMinId = body.min_id;
if (typeof body.min_id === "number") histMinId = body.min_id;
}
// Kinds present in the history replay — the only kinds that
// can double and therefore the only ones to seq-dedupe.
@ -499,14 +551,14 @@ export function create(opts) {
currentNoAnim = true;
for (const ev of events) dispatch(ev, true);
currentNoAnim = false;
if (events.length) row('note', '─── live (older above) ───');
else placeholder('(connected — waiting for events)');
if (events.length) row("note", "─── live (older above) ───");
else placeholder("(connected — waiting for events)");
flushBuffered(boundarySeq, historyKinds);
// Show load-older button if the server reports more history.
updateLoadMoreBtn();
if (opts.onBackfillDone) opts.onBackfillDone(events.length);
} catch (err) {
console.warn('history backfill failed', err);
console.warn("history backfill failed", err);
flushBuffered(null);
if (opts.onBackfillDone) opts.onBackfillDone(0);
}
@ -524,9 +576,10 @@ export function create(opts) {
// XSS-safety + trailing-punctuation strip.
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
export function linkify(text) {
const str = text == null ? '' : String(text);
const str = text == null ? "" : String(text);
const frag = document.createDocumentFragment();
if (str.indexOf('://') === -1) { // fast path: no URLs
if (str.indexOf("://") === -1) {
// fast path: no URLs
if (str) frag.appendChild(document.createTextNode(str));
return frag;
}
@ -537,20 +590,20 @@ export function linkify(text) {
let url = m[0];
// Don't swallow trailing punctuation that's really sentence text.
const trail = url.match(/[.,;:!?)\]}'"]+$/);
const tail = trail ? trail[0] : '';
const tail = trail ? trail[0] : "";
if (tail) url = url.slice(0, -tail.length);
if (m.index > last) {
frag.appendChild(document.createTextNode(str.slice(last, m.index)));
}
if (!url.slice(url.indexOf('://') + 3)) {
if (!url.slice(url.indexOf("://") + 3)) {
// Nothing past the scheme — not a real URL, emit verbatim.
frag.appendChild(document.createTextNode(m[0]));
} else {
const a = document.createElement('a');
a.href = url; // regex only matches https?:// — safe
const a = document.createElement("a");
a.href = url; // regex only matches https?:// — safe
a.textContent = url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.target = "_blank";
a.rel = "noopener noreferrer";
frag.appendChild(a);
if (tail) frag.appendChild(document.createTextNode(tail));
}

View file

@ -29,9 +29,21 @@
a swap. Each is pixel-identical to its prior literal under the
default palette: --crust is a darkened bg; --muted / --subtext0 are
foregroundbackground blends (two levels of dimmed text). */
--crust: color-mix(in srgb, var(--base00) 58%, #000000); /* terminal / code bg, below --bg */
--muted: color-mix(in srgb, var(--base05) 55.5%, var(--base00)); /* secondary / dimmed text */
--subtext0: color-mix(in srgb, var(--base05) 77.7%, var(--base00)); /* toolbar/status text; dimmer than --fg, lighter than --muted */
--crust: color-mix(
in srgb,
var(--base00) 58%,
#000000
); /* terminal / code bg, below --bg */
--muted: color-mix(
in srgb,
var(--base05) 55.5%,
var(--base00)
); /* secondary / dimmed text */
--subtext0: color-mix(
in srgb,
var(--base05) 77.7%,
var(--base00)
); /* toolbar/status text; dimmer than --fg, lighter than --muted */
/* `--muted`, above, is mixed toward `--base00`/`--bg` correct for
dimmed text on the page background, wrong for dimmed text sitting on

View file

@ -28,8 +28,13 @@
animation: warn-banner-pulse 2.4s ease-in-out infinite;
}
@keyframes warn-banner-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); }
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);
}
}
.warn-banner strong {
color: inherit;

View file

@ -13,10 +13,10 @@
// `level` defaults to 'warning', matching `hive-warn.js`'s own default.
// Only `error` pulses — an active incident, not a standing caveat, same
// rule `hive-warn.css`'s own comment states.
import type { ComponentChildren } from 'preact';
import './WarnBanner.css';
import type { ComponentChildren } from "preact";
import "./WarnBanner.css";
export type WarnLevel = 'info' | 'warning' | 'error';
export type WarnLevel = "info" | "warning" | "error";
export interface WarnBannerProps {
level?: WarnLevel;
@ -28,10 +28,16 @@ export interface WarnBannerProps {
children?: ComponentChildren;
}
export function WarnBanner({ level = 'warning', class: extraClass, children }: WarnBannerProps) {
const classes = ['warn-banner', `warn-banner-${level}`, extraClass].filter(Boolean).join(' ');
export function WarnBanner({
level = "warning",
class: extraClass,
children,
}: WarnBannerProps) {
const classes = ["warn-banner", `warn-banner-${level}`, extraClass]
.filter(Boolean)
.join(" ");
return (
<div class={classes} role={level === 'error' ? 'alert' : undefined}>
<div class={classes} role={level === "error" ? "alert" : undefined}>
{children}
</div>
);