frontend: add <hive-btn> component, use it for dialog buttons

mara: 'wait the common styles is literally just the button stuff? pls
make a button component now as part of this pr and replace the usage
in the modal. replacing all usages and finally removing the btn styles
from common css is a follow up then.'

<hive-btn> (hive-btn.js) is a customized built-in <button is="hive-btn">
with its own shadow root -- extending HTMLButtonElement keeps every
native button behaviour (click/keyboard activation, :disabled, form
participation) instead of re-implementing it on a generic wrapper.
Shadow root holds only an adopted stylesheet + a <slot>, so the
button's light-DOM content (label, or asyncBtn's swapped-in spinner
span) renders through unchanged -- slotted content stays styled by the
light-DOM cascade, so the global .spinner class still applies.
Variants (cancel/confirm/danger) are a 'variant' attribute, not a CSS
class, since they're a semantic prop of the component.

Customized built-ins aren't supported in Safari/WebKit -- fine here,
the project targets recent Firefox only (same reasoning as the
original custom-elements pilot).

Wired into modal.js: HiveDialog's buttons now render as
<button is="hive-btn" variant="...">, replacing the old
component-common.css .btn copy -- deleted that file + component-
styles.js entirely (their sole purpose was giving dialog buttons a
.btn look, which hive-btn now owns properly). dom.js's el() gained
 support so it can create customized built-ins the same way it
creates everything else. hive-dialog.css dropped the now-dead
.cancel/.confirm/.confirm.danger rules.

Per mara's scoping: NOT touching the other .btn consumers across the
app (dashboard/agent submit buttons, form() helper, etc.) or removing
.btn from dashboard/common.css / agent/agent.css in this PR -- that
migration + cleanup is an explicit follow-up.

Verified with a full frontend build (grepped bundled JS for hive-btn/
variant to confirm it inlines); nix fmt clean.
This commit is contained in:
iris 2026-07-28 00:45:41 +02:00 committed by mara
commit a588ed42ce
9 changed files with 123 additions and 122 deletions

View file

@ -5,45 +5,33 @@
// dialog.
//
// Implemented as two shadow-DOM custom elements (`<hive-dialog>`,
// `<hive-toast>`). Each attaches its own shadow root and adopts a
// component-scoped `CSSStyleSheet` alongside the shared one in
// `component-styles.js` (the native equivalent of a Sass `@include`) —
// real style encapsulation, no global `.tc-*` class namespace needed any
// more, and no stylesheet to import from every page. The CSS itself
// lives in real `.css` files (`hive-dialog.css`, `hive-toast.css`,
// `component-common.css`), imported here as raw text via esbuild's
// `text` loader and turned into `CSSStyleSheet`s at runtime — not JS
// template strings, so it stays normal, editor-tooled CSS. Theme vars
// (`--bg`, `--purple`, …) keep resolving inside the shadow tree
// automatically: CSS custom properties inherit through shadow
// boundaries even though plain class rules don't. This started as a
// light-DOM pilot (see the frontend components-split discussion on the
// forge issue tracker) — light DOM validated the lifecycle-
// encapsulation win (`connectedCallback`/`disconnectedCallback` owning
// the keydown listener / auto-dismiss timer) cheaply; this is the
// shadow-DOM follow-up once real per-component style scoping was worth
// the cost.
// `<hive-toast>`), plus `<hive-btn>` (hive-btn.js) for the dialog's own
// buttons — real per-component style encapsulation, CSS in real `.css`
// files imported as raw text (see each component's own header comment
// for the design rationale: shadow-DOM-vs-light-DOM tradeoffs live atop
// the `<hive-dialog>`/`<hive-toast>` classes below, the customized-
// built-in choice lives in hive-btn.js). Other `.btn` consumers across
// the app stay on the light-DOM `.btn` class for now — migrating them
// is a separate follow-up.
//
// `openDialog` is the general primitive (any title/message/content + a row of
// buttons); `themedConfirm` is a thin cancel/confirm wrapper with optional
// checkboxes built on top of it.
import { el } from './dom.js';
import { sharedComponentStyleSheet } from './component-styles.js';
import './hive-btn.js'; // registers <hive-btn> — side-effect import, no named export needed
import dialogCss from './hive-dialog.css';
import toastCss from './hive-toast.css';
// Attach an open shadow root to `host`, adopt the shared component
// stylesheet plus `ownCssText` (a scoped stylesheet built fresh per call
// from a real .css file's contents, imported as raw text — see
// component-styles.js's header comment; only the shared one is
// cached/reused across instances), and return the shadow root for the
// caller to populate.
// Attach an open shadow root to `host`, adopt `ownCssText` (a scoped
// stylesheet built fresh per call from a real .css file's contents,
// imported as raw text), and return the shadow root for the caller to
// populate.
function attachShadow(host, ownCssText) {
const root = host.attachShadow({ mode: 'open' });
const own = new CSSStyleSheet();
own.replaceSync(ownCssText);
root.adoptedStyleSheets = [sharedComponentStyleSheet(), own];
const sheet = new CSSStyleSheet();
sheet.replaceSync(ownCssText);
root.adoptedStyleSheets = [sheet];
return root;
}
@ -86,9 +74,13 @@ class HiveDialog extends HTMLElement {
this._done = done; // exposed for close-on-escape-elsewhere callers, if ever needed
const btnEls = buttons.map((b) => {
// `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('button', {
type: 'button',
class: 'btn' + (b.class ? ' ' + b.class : '') + (b.danger ? ' danger' : ''),
type: 'button', is: 'hive-btn',
...(variant ? { variant } : {}),
}, b.label);
btn.addEventListener('click', () => done(b.value));
return { spec: b, btn };
@ -207,12 +199,13 @@ export function themedPrompt(opts = {}) {
// Enter submits (clicks the confirm button mounted by openDialog);
// Shift+Enter falls through to the textarea's default newline insert.
// `closest()`/`querySelector()` stay within the shadow tree the input
// lives in, so this resolves to the dialog's own `.box`/`.confirm`
// without leaking across instances.
// 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) {
e.preventDefault();
input.closest('.box')?.querySelector('.confirm')?.click();
input.closest('.box')?.querySelector('[variant="confirm"]')?.click();
}
});
const content = el('div', { class: 'promptfield' },