frontend: move shadow-DOM component CSS to real .css files, not JS strings

mara: 'i dont like sharing css via js, thats not how it should be done.'

Replaced the DIALOG_CSS/TOAST_CSS template-string constants in modal.js
and the inline CSS text in component-styles.js with three real .css
files (hive-dialog.css, hive-toast.css, component-common.css),
imported as raw text via esbuild's 'text' loader and turned into
CSSStyleSheet objects at runtime (same replaceSync() call as before --
only where the CSS text comes from changed). Both packages' build.mjs
gained a '.css': 'text' loader entry on their JS-bundling step; this
doesn't collide with the separate page-stylesheet bundling ('css'
loader), which is a different esbuild invocation over different entry
points.

No behavior change -- same adoptedStyleSheets wiring, same rules,
same output. Verified with a full frontend build (grepped the bundled
JS to confirm the CSS text inlines correctly); nix fmt clean.
This commit is contained in:
iris 2026-07-28 00:30:38 +02:00 committed by mara
commit bce666a9b2
7 changed files with 197 additions and 151 deletions

View file

@ -41,6 +41,10 @@ await build({
target: ['es2022'],
sourcemap: true,
logLevel: 'info',
// `@hive/shared/modal.js` imports its shadow-DOM component CSS as raw
// text (see dashboard/build.mjs's matching comment) — same reasoning
// applies here.
loader: { '.css': 'text' },
});
// Bundle the CSS. `colors.css` re-exports the standalone base16 palette

View file

@ -74,6 +74,15 @@ await build({
target: ['es2022'],
sourcemap: true,
logLevel: 'info',
// `@hive/shared/modal.js` imports its shadow-DOM component CSS
// (hive-dialog.css, hive-toast.css, component-common.css) as raw text
// via a plain `import css from './foo.css'` — the `text` loader turns
// that into a string constant at bundle time instead of erroring on an
// unrecognised extension. None of these JS entries import a `.css`
// file any other way, so this doesn't collide with the separate
// page-stylesheet bundling below (`loader: { '.css': 'css' }`), which
// runs as its own esbuild invocation over different entry points.
loader: { '.css': 'text' },
});
// Stream-worker entry (#448). Lives in a separate bundle: SharedWorker

View file

@ -0,0 +1,46 @@
/* component-common.css the shared stylesheet adopted alongside every
shadow-DOM custom element's own scoped CSS (`adoptedStyleSheets`), the
native equivalent of a Sass `@include`. Loaded as raw text at build time
(esbuild's `text` loader see build.mjs) and turned into a
`CSSStyleSheet` via `replaceSync()` in component-styles.js; a real .css
file, not a JS template string, so it gets normal editor tooling and
stays syntactically obvious as CSS.
NOTE: `.btn` here is a near-duplicate of the app-wide `.btn` base rule
in dashboard/src/common.css and agent/src/agent.css, which already
differ slightly from each other. Shadow DOM can't see either a
shadow component that wants the same button chrome needs its own copy,
so this is now a third copy. Picked dashboard's version (the fuller
one) as canonical for shadow components going forward. Unifying all
three into one source of truth is a separate follow-up (see
hyperhive's forge issue tracker: the `<hive-btn>` component proposal),
not bundled into whichever component conversion first needed a
button. */
.btn {
font-family: inherit;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.1em;
background: transparent;
-webkit-appearance: none;
appearance: none;
border: 1px solid;
padding: 0.25em 0.8em;
cursor: pointer;
text-shadow: 0 0 4px currentColor;
box-shadow: 0 0 0 0 currentColor;
transition: box-shadow 0.15s ease;
}
.btn:hover {
background: color-mix(in srgb, var(--fg) 6%, transparent);
text-shadow: 0 0 10px currentColor;
box-shadow: 0 0 10px -2px currentColor;
}
.btn:disabled,
.btn[disabled] {
opacity: 0.32;
cursor: not-allowed;
text-shadow: none;
box-shadow: none;
}

View file

@ -1,58 +1,30 @@
// Shared, adopted-alongside-component-specific stylesheets for shadow-DOM
// custom elements — the native equivalent of a Sass `@include`: a
// `CSSStyleSheet` is parsed once and adopted by reference
// (`shadowRoot.adoptedStyleSheets = [sharedButtonStyleSheet(), ownSheet]`)
// (`shadowRoot.adoptedStyleSheets = [sharedComponentStyleSheet(), ownSheet]`)
// into as many shadow roots as want it, rather than each component
// duplicating the rule text or re-fetching an external stylesheet.
//
// Start small: `.btn` is the one rule shadow-DOM components have needed so
// far (the dialog buttons in modal.js). Add more shared rules here as more
// components need them — don't grow this into a full reset/utility layer
// preemptively.
// The CSS itself lives in `component-common.css` — a real stylesheet, not
// a JS template string — imported here as raw text via esbuild's `text`
// loader (see both packages' `build.mjs`) and turned into a
// `CSSStyleSheet` at module-init time. Component-scoped stylesheets
// (`hive-dialog.css`, `hive-toast.css` in modal.js) follow the same
// pattern.
//
// NOTE: this `.btn` is a near-duplicate of the app-wide `.btn` base rule in
// dashboard/src/common.css and agent/src/agent.css, which already differ
// slightly from each other (agent's is a simpler subset). Shadow DOM can't
// see either — a shadow component that wants the same button chrome needs
// its own copy, so this is now a third copy. Picked dashboard's version
// (the fuller one) as canonical for shadow components going forward.
// Unifying all three into one source of truth is a separate follow-up, not
// bundled into whichever component conversion first needed a button.
// Start small: `.btn` is the one rule shadow-DOM components have needed so
// far (the dialog buttons in modal.js). Add more shared rules to
// component-common.css as more components need them — don't grow this
// into a full reset/utility layer preemptively.
import componentCommonCss from './component-common.css';
let sheet = null;
export function sharedComponentStyleSheet() {
if (!sheet) {
sheet = new CSSStyleSheet();
sheet.replaceSync(`
.btn {
font-family: inherit;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.1em;
background: transparent;
-webkit-appearance: none;
appearance: none;
border: 1px solid;
padding: 0.25em 0.8em;
cursor: pointer;
text-shadow: 0 0 4px currentColor;
box-shadow: 0 0 0 0 currentColor;
transition: box-shadow 0.15s ease;
}
.btn:hover {
background: color-mix(in srgb, var(--fg) 6%, transparent);
text-shadow: 0 0 10px currentColor;
box-shadow: 0 0 10px -2px currentColor;
}
.btn:disabled,
.btn[disabled] {
opacity: 0.32;
cursor: not-allowed;
text-shadow: none;
box-shadow: none;
}
`);
sheet.replaceSync(componentCommonCss);
}
return sheet;
}

View file

@ -0,0 +1,74 @@
/* hive-dialog.css scoped stylesheet for the <hive-dialog> shadow-DOM
custom element (modal.js). Loaded as raw text at build time (esbuild's
`text` loader) and adopted via `adoptedStyleSheets` alongside
component-common.css. `:host` styles the element itself, which *is*
the fixed-position backdrop no separate light-DOM backdrop div. */
:host {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1em;
background: color-mix(in srgb, var(--crust) 60%, transparent);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
}
.box {
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
box-shadow: 0 8px 40px -8px var(--crust);
padding: 1.2em 1.4em;
max-width: min(32em, 92vw);
display: flex;
flex-direction: column;
gap: 0.9em;
}
.title {
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--subtext0);
}
.message { color: var(--fg); line-height: 1.45; }
.checks { display: flex; flex-direction: column; gap: 0.4em; }
.checkrow {
display: flex;
align-items: flex-start;
gap: 0.5em;
cursor: pointer;
color: var(--subtext0);
font-size: 0.92em;
line-height: 1.35;
}
.checkrow .check { margin-top: 0.2em; flex: 0 0 auto; }
.actions {
display: flex;
justify-content: flex-end;
gap: 0.6em;
margin-top: 0.2em;
}
.cancel { color: var(--subtext0); }
.confirm { color: var(--green); }
.confirm.danger { color: var(--red); }
.promptfield { display: flex; flex-direction: column; gap: 0.4em; }
.promptlabel { color: var(--subtext0); font-size: 0.9em; }
.input {
font-family: inherit;
font-size: 1em;
width: 100%;
box-sizing: border-box;
background: var(--crust);
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em;
}
.input:focus { outline: 1px solid var(--green); }
.textarea {
resize: vertical;
min-height: 4.5em;
line-height: 1.4;
white-space: pre-wrap;
}

View file

@ -0,0 +1,25 @@
/* hive-toast.css scoped stylesheet for the <hive-toast> shadow-DOM
custom element (modal.js). Loaded as raw text at build time (esbuild's
`text` loader) and adopted via `adoptedStyleSheets`. `:host` styles the
element itself, which *is* the visible toast box; `:host(.error)` etc.
switch on a plain class the host element carries (set in JS). */
:host {
display: block;
pointer-events: auto;
cursor: pointer;
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
border-left-width: 3px;
box-shadow: 0 4px 20px -6px var(--crust);
padding: 0.6em 0.9em;
font-size: 0.9em;
color: var(--fg);
white-space: pre-wrap;
opacity: 1;
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); }

View file

@ -9,15 +9,20 @@
// 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. 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.
// 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.
//
// `openDialog` is the general primitive (any title/message/content + a row of
// buttons); `themedConfirm` is a thin cancel/confirm wrapper with optional
@ -25,90 +30,23 @@
import { el } from './dom.js';
import { sharedComponentStyleSheet } from './component-styles.js';
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 `ownCss` (a scoped stylesheet built fresh per call —
// only the shared one is cached/reused across instances), and return the
// shadow root for the caller to populate.
function attachShadow(host, ownCss) {
// 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.
function attachShadow(host, ownCssText) {
const root = host.attachShadow({ mode: 'open' });
const own = new CSSStyleSheet();
own.replaceSync(ownCss);
own.replaceSync(ownCssText);
root.adoptedStyleSheets = [sharedComponentStyleSheet(), own];
return root;
}
const DIALOG_CSS = `
:host {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1em;
background: color-mix(in srgb, var(--crust) 60%, transparent);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
}
.box {
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
box-shadow: 0 8px 40px -8px var(--crust);
padding: 1.2em 1.4em;
max-width: min(32em, 92vw);
display: flex;
flex-direction: column;
gap: 0.9em;
}
.title {
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--subtext0);
}
.message { color: var(--fg); line-height: 1.45; }
.checks { display: flex; flex-direction: column; gap: 0.4em; }
.checkrow {
display: flex;
align-items: flex-start;
gap: 0.5em;
cursor: pointer;
color: var(--subtext0);
font-size: 0.92em;
line-height: 1.35;
}
.checkrow .check { margin-top: 0.2em; flex: 0 0 auto; }
.actions {
display: flex;
justify-content: flex-end;
gap: 0.6em;
margin-top: 0.2em;
}
.cancel { color: var(--subtext0); }
.confirm { color: var(--green); }
.confirm.danger { color: var(--red); }
.promptfield { display: flex; flex-direction: column; gap: 0.4em; }
.promptlabel { color: var(--subtext0); font-size: 0.9em; }
.input {
font-family: inherit;
font-size: 1em;
width: 100%;
box-sizing: border-box;
background: var(--crust);
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em;
}
.input:focus { outline: 1px solid var(--green); }
.textarea {
resize: vertical;
min-height: 4.5em;
line-height: 1.4;
white-space: pre-wrap;
}
`;
// <hive-dialog> — the backdrop + box custom element behind `openDialog`.
// Not exported; constructed and configured by `openDialog` only. Callers
// set `._opts` before `append()`ing it (custom elements can't take
@ -128,7 +66,7 @@ class HiveDialog extends HTMLElement {
danger = false, dismissable = true,
} = this._opts || {};
const root = attachShadow(this, DIALOG_CSS);
const root = attachShadow(this, dialogCss);
let settled = false;
const done = (value) => {
@ -294,28 +232,6 @@ export function themedPrompt(opts = {}) {
return result;
}
const TOAST_CSS = `
:host {
display: block;
pointer-events: auto;
cursor: pointer;
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
border-left-width: 3px;
box-shadow: 0 4px 20px -6px var(--crust);
padding: 0.6em 0.9em;
font-size: 0.9em;
color: var(--fg);
white-space: pre-wrap;
opacity: 1;
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); }
`;
// <hive-toast> — one transient notification entry. Owns its own
// auto-dismiss timer via connectedCallback/disconnectedCallback (cleared
// on removal so a toast dismissed early by click doesn't leave a stray
@ -330,7 +246,7 @@ class HiveToast extends HTMLElement {
connectedCallback() {
const { type = 'info', duration } = this._opts || {};
const ms = duration != null ? duration : (type === 'error' ? 8000 : 4000);
const root = attachShadow(this, TOAST_CSS);
const root = attachShadow(this, toastCss);
this.classList.add(type);
this.setAttribute('role', type === 'error' ? 'alert' : 'status');
root.textContent = this._message || '';