diff --git a/frontend/packages/agent/build.mjs b/frontend/packages/agent/build.mjs index c5e1edfa..be0c3e3a 100644 --- a/frontend/packages/agent/build.mjs +++ b/frontend/packages/agent/build.mjs @@ -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 diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs index 3306f8fe..35427bf0 100644 --- a/frontend/packages/dashboard/build.mjs +++ b/frontend/packages/dashboard/build.mjs @@ -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 diff --git a/frontend/packages/shared/src/component-common.css b/frontend/packages/shared/src/component-common.css new file mode 100644 index 00000000..2cc892f0 --- /dev/null +++ b/frontend/packages/shared/src/component-common.css @@ -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 `` 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; +} diff --git a/frontend/packages/shared/src/component-styles.js b/frontend/packages/shared/src/component-styles.js index ce3dbc26..a488b36d 100644 --- a/frontend/packages/shared/src/component-styles.js +++ b/frontend/packages/shared/src/component-styles.js @@ -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; } diff --git a/frontend/packages/shared/src/hive-dialog.css b/frontend/packages/shared/src/hive-dialog.css new file mode 100644 index 00000000..54576974 --- /dev/null +++ b/frontend/packages/shared/src/hive-dialog.css @@ -0,0 +1,74 @@ +/* hive-dialog.css — scoped stylesheet for the 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; +} diff --git a/frontend/packages/shared/src/hive-toast.css b/frontend/packages/shared/src/hive-toast.css new file mode 100644 index 00000000..c4a6d601 --- /dev/null +++ b/frontend/packages/shared/src/hive-toast.css @@ -0,0 +1,25 @@ +/* hive-toast.css — scoped stylesheet for the 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); } diff --git a/frontend/packages/shared/src/modal.js b/frontend/packages/shared/src/modal.js index d23b02fb..9837cbc9 100644 --- a/frontend/packages/shared/src/modal.js +++ b/frontend/packages/shared/src/modal.js @@ -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; -} -`; - // — 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); } -`; - // — 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 || '';