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:
parent
5d24bedd60
commit
39b95c2ede
43 changed files with 10090 additions and 6085 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue