Watch
0
0
Fork
You've already forked hyperhive
0

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
43 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

Some files were not shown because too many files have changed in this diff Show more