From d79df3883df87eff4270b20bbbc5aa598faba100 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 28 Jul 2026 00:24:13 +0200 Subject: [PATCH] frontend: shadow-DOM upgrade for hive-dialog/hive-toast, drop global modal.css Follow-up to the light-DOM custom-elements pilot (mara: 'that one landed and works. i dont really like the css still being shared - id like that to be split by component, with common stuff via @include'). and now attach a shadow root and adopt a component-scoped CSSStyleSheet built from a template-string constant in modal.js (DIALOG_CSS / TOAST_CSS), plus a new shared/src/component- styles.js sheet (currently just .btn) adopted alongside it via adoptedStyleSheets -- the native equivalent of a Sass @include, no preprocessor added. Theme vars keep resolving through the shadow boundary since CSS custom properties inherit across it; only plain class rules needed the explicit move. Deleted the global shared/src/modal.css entirely and dropped its @import from both dashboard/common.css and agent/agent.css -- nothing outside modal.js renders the old .tc-* classes any more. The element is now the backdrop itself (:host carries the fixed- position/centering rules that used to be .tc-backdrop on a light-DOM div); box/title/message/content/actions all render inside its shadow root. similarly styles :host directly instead of a light- DOM div, with the message text placed straight into the shadow root (no needed since there's no external light-DOM content to project). Public API (openDialog/themedConfirm/themedPrompt/themedToast) unchanged -- no call-site changes needed anywhere in dashboard/agent. Verified with a full frontend build; nix fmt clean. --- frontend/packages/agent/src/agent.css | 7 +- frontend/packages/dashboard/src/common.css | 1 - frontend/packages/shared/package.json | 2 +- .../packages/shared/src/component-styles.js | 58 +++++ frontend/packages/shared/src/modal.css | 117 --------- frontend/packages/shared/src/modal.js | 231 ++++++++++++++---- 6 files changed, 246 insertions(+), 170 deletions(-) create mode 100644 frontend/packages/shared/src/component-styles.js delete mode 100644 frontend/packages/shared/src/modal.css diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css index 6854e3e8..28519eb4 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -6,10 +6,9 @@ agent SUB-pages (stats, screen) the same back-link nav the dashboard's standalone pages use. The live terminal page keeps its own header. */ @import "@hive/shared/chrome.css"; -/* Themed dialog/toast styles for the shared bindAsyncForms handler + any - direct themedConfirm/themedToast calls, so the per-agent UI's - confirmations and errors render in-theme instead of unstyled. */ -@import "@hive/shared/modal.css"; +/* Themed dialog/toast component CSS (modal.js) now lives in each + component's own shadow root (adoptedStyleSheets), not a global + stylesheet — nothing to @import here any more. */ /* ─── full-screen layout overrides ───────────────────────────────── The agent page mounts a full-viewport terminal under a fixed diff --git a/frontend/packages/dashboard/src/common.css b/frontend/packages/dashboard/src/common.css index ae16d272..8922df45 100644 --- a/frontend/packages/dashboard/src/common.css +++ b/frontend/packages/dashboard/src/common.css @@ -4,7 +4,6 @@ @import "@hive/shared/terminal.css"; @import "@hive/shared/tabs.css"; @import "@hive/shared/chrome.css"; -@import "@hive/shared/modal.css"; /* ─── global typography ───────────────────────────────────────────── Element-level rules shared across all three pages (index, flow, diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json index 41ee6ede..cfd034d9 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -18,7 +18,7 @@ "./forms.js": "./src/forms.js", "./dom.js": "./src/dom.js", "./modal.js": "./src/modal.js", - "./modal.css": "./src/modal.css" + "./component-styles.js": "./src/component-styles.js" }, "files": [ "src/" diff --git a/frontend/packages/shared/src/component-styles.js b/frontend/packages/shared/src/component-styles.js new file mode 100644 index 00000000..ce3dbc26 --- /dev/null +++ b/frontend/packages/shared/src/component-styles.js @@ -0,0 +1,58 @@ +// 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]`) +// 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. +// +// 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. + +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; +} + `); + } + return sheet; +} diff --git a/frontend/packages/shared/src/modal.css b/frontend/packages/shared/src/modal.css deleted file mode 100644 index aad63efd..00000000 --- a/frontend/packages/shared/src/modal.css +++ /dev/null @@ -1,117 +0,0 @@ -/* modal.css — themed dialog component styles. - Pairs with modal.js (themedConfirm / themedPrompt / themedToast), - whose `` / `` custom elements are styled - entirely by class selectors here (light DOM, no shadow root — see the - design note atop modal.js). The dialogs are raised from the shared - `bindAsyncForms` data-async / data-confirm handler (forms.js), used by - both the dashboard and the per-agent UI, so both packages' base - stylesheets (common.css / agent.css) @import this to keep the styles - present wherever a dialog can fire. - (Previously these rules lived in dashboard.css, so dialogs rendered - unstyled on standalone dashboard pages such as /core, and the per-agent - UI never had them at all — a themed-dialogs consistency fix moved them - here and wired both packages' base stylesheets to import this file.) */ - -.tc-backdrop { - 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); -} -.tc-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; -} -.tc-title { - font-weight: bold; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--subtext0); -} -.tc-message { color: var(--fg); line-height: 1.45; } -.tc-checks { display: flex; flex-direction: column; gap: 0.4em; } -.tc-checkrow { - display: flex; - align-items: flex-start; - gap: 0.5em; - cursor: pointer; - color: var(--subtext0); - font-size: 0.92em; - line-height: 1.35; -} -.tc-checkrow .tc-check { margin-top: 0.2em; flex: 0 0 auto; } -.tc-actions { - display: flex; - justify-content: flex-end; - gap: 0.6em; - margin-top: 0.2em; -} -.tc-cancel { color: var(--subtext0); } -.tc-confirm { color: var(--green); } -.tc-confirm.tc-danger { color: var(--red); } - -/* themedPrompt input field */ -.tc-promptfield { display: flex; flex-direction: column; gap: 0.4em; } -.tc-promptlabel { color: var(--subtext0); font-size: 0.9em; } -.tc-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; -} -.tc-input:focus { outline: 1px solid var(--green); } -/* themedPrompt's field is always a resizable textarea (Enter submits, - Shift+Enter inserts a newline) — sensible min-height, wrapped output. */ -.tc-textarea { - resize: vertical; - min-height: 4.5em; - line-height: 1.4; - white-space: pre-wrap; -} - -/* themedToast — non-blocking transient notifications (errors/info/ok) */ -.tc-toasts { - position: fixed; - top: 1em; - right: 1em; - z-index: 1100; - display: flex; - flex-direction: column; - gap: 0.5em; - max-width: min(28em, 92vw); - pointer-events: none; -} -.tc-toast { - 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; -} -.tc-toast-out { opacity: 0; transform: translateX(0.5em); } -.tc-toast-error { border-left-color: var(--red); } -.tc-toast-info { border-left-color: var(--purple-dim); } -.tc-toast-ok { border-left-color: var(--green); } diff --git a/frontend/packages/shared/src/modal.js b/frontend/packages/shared/src/modal.js index 1e860f08..d23b02fb 100644 --- a/frontend/packages/shared/src/modal.js +++ b/frontend/packages/shared/src/modal.js @@ -4,27 +4,110 @@ // actions and prompts match each page's chrome instead of a jarring OS // dialog. // -// Implemented as two light-DOM custom elements (``, -// ``) — no shadow root, so styling stays exactly where it -// already lived: the `.tc-*` classes in `modal.css`, imported globally by -// both packages' base stylesheets. Light DOM was the deliberate call for -// this first custom-element pilot (see the frontend components-split -// discussion on the forge issue tracker): it buys lifecycle -// encapsulation (`connectedCallback`/`disconnectedCallback` own the -// keydown listener / auto-dismiss timer instead of hand-rolled -// add/removeEventListener bookkeeping in a closure) without paying the -// shadow-DOM cost of re-importing every shared stylesheet per instance. -// If a later pilot wants real style encapsulation, shadow DOM is a -// drop-in upgrade to these same two classes — the public functional API -// below (`openDialog`/`themedConfirm`/`themedPrompt`/`themedToast`) -// wouldn't need to change either way. (Pilot for the frontend -// components-split proposal — see the forge issue tracker for context.) +// Implemented as two shadow-DOM custom elements (``, +// ``). 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. 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 // checkboxes built on top of it. import { el } from './dom.js'; +import { sharedComponentStyleSheet } from './component-styles.js'; + +// 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) { + const root = host.attachShadow({ mode: 'open' }); + const own = new CSSStyleSheet(); + own.replaceSync(ownCss); + 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 @@ -34,7 +117,9 @@ import { el } from './dom.js'; // via a `hive-dialog-close` CustomEvent (`detail` = the resolved value) // rather than exposing a resolve/reject pair directly — that keeps the // element a normal DOM node with a normal event contract instead of a -// bespoke Promise-ish object. +// bespoke Promise-ish object. The element itself *is* the backdrop +// (`:host` carries the fixed-position/centering rules); the box, title, +// message, content, and buttons all render inside its shadow root. class HiveDialog extends HTMLElement { connectedCallback() { const { @@ -43,7 +128,8 @@ class HiveDialog extends HTMLElement { danger = false, dismissable = true, } = this._opts || {}; - this.className = 'tc-backdrop'; + const root = attachShadow(this, DIALOG_CSS); + let settled = false; const done = (value) => { if (settled) return; @@ -64,7 +150,7 @@ class HiveDialog extends HTMLElement { const btnEls = buttons.map((b) => { const btn = el('button', { type: 'button', - class: 'btn tc-btn' + (b.class ? ' ' + b.class : '') + (b.danger ? ' tc-danger' : ''), + class: 'btn' + (b.class ? ' ' + b.class : '') + (b.danger ? ' danger' : ''), }, b.label); btn.addEventListener('click', () => done(b.value)); return { spec: b, btn }; @@ -72,20 +158,23 @@ class HiveDialog extends HTMLElement { // Give the dialog an accessible name: label it by its title if present, // else by its message, via `aria-labelledby` (a11y — role=dialog needs a - // name). Only the labelling element carries the id. - const labelId = 'tc-dlg-' + Math.random().toString(36).slice(2, 9); - const titleEl = title ? el('div', { class: 'tc-title', id: labelId }, title) : null; + // name). Only the labelling element carries the id. IDs are scoped to + // 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: 'tc-message' } : { class: 'tc-message', id: labelId }, message) + ? el('div', title ? { class: 'message' } : { class: 'message', id: labelId }, message) : null; - const boxAttrs = { class: 'tc-box', role: 'dialog', 'aria-modal': 'true' }; + 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: 'tc-actions' }, ...btnEls.map((b) => b.btn))); - this.append(box); + el('div', { class: 'actions' }, ...btnEls.map((b) => b.btn))); + root.append(box); this.addEventListener('click', (e) => { if (dismissable && e.target === this) done(null); @@ -104,11 +193,14 @@ customElements.define('hive-dialog', HiveDialog); // → Promise resolving to the clicked button's `value`, or `null` when the // dialog is dismissed (Escape, backdrop click, or a button whose value is // null). `content` is an optional DOM node rendered between the message -// and the buttons (checkboxes, custom fields, …). `buttons` is -// `[{ label, value, danger?, class?, autofocus? }]`, rendered -// right-aligned. Initial focus: the `autofocus` button if any, else — for -// a `danger` dialog — the first non-destructive button (so a stray Enter -// can't fire the destructive path), else the last button. +// and the buttons (checkboxes, custom fields, …) — built by the caller +// with `el()` and appended into the dialog's shadow root once mounted, +// same as any other node (a JS-created element isn't bound to a +// document/shadow-root until it's actually appended somewhere). +// `buttons` is `[{ label, value, danger?, class?, autofocus? }]`, +// rendered right-aligned. Initial focus: the `autofocus` button if any, +// else — for a `danger` dialog — the first non-destructive button (so a +// stray Enter can't fire the destructive path), else the last button. export function openDialog(opts = {}) { return new Promise((resolve) => { const dlg = document.createElement('hive-dialog'); @@ -133,14 +225,14 @@ export function themedConfirm(opts = {}) { } = opts; const boxes = checkboxes.map((cb) => { - const input = el('input', { type: 'checkbox', class: 'tc-check', name: cb.name }); + const input = el('input', { type: 'checkbox', class: 'check', name: cb.name }); if (cb.checked) input.checked = true; - const row = el('label', { class: 'tc-checkrow' }, - input, el('span', { class: 'tc-check-label' }, cb.label || cb.name)); + const row = el('label', { class: 'checkrow' }, + input, el('span', {}, cb.label || cb.name)); return { input, row }; }); const content = boxes.length - ? el('div', { class: 'tc-checks' }, ...boxes.map((b) => b.row)) + ? el('div', { class: 'checks' }, ...boxes.map((b) => b.row)) : null; return openDialog({ @@ -149,8 +241,8 @@ export function themedConfirm(opts = {}) { content, danger, buttons: [ - { label: cancelLabel, value: null, class: 'tc-cancel', autofocus: danger }, - { label: confirmLabel, value: 'confirm', danger, class: 'tc-confirm', autofocus: !danger }, + { label: cancelLabel, value: null, class: 'cancel', autofocus: danger }, + { label: confirmLabel, value: 'confirm', danger, class: 'confirm', autofocus: !danger }, ], }).then((v) => { if (v !== 'confirm') return null; @@ -172,26 +264,29 @@ export function themedPrompt(opts = {}) { title = '', message = '', label = '', placeholder = '', value = '', confirmLabel = 'ok', cancelLabel = 'cancel', } = opts; - const input = el('textarea', { class: 'tc-input tc-textarea', rows: '3', placeholder }); + const input = el('textarea', { class: 'input textarea', rows: '3', placeholder }); if (value) input.value = value; // 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. input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - input.closest('.tc-box')?.querySelector('.tc-confirm')?.click(); + input.closest('.box')?.querySelector('.confirm')?.click(); } }); - const content = el('div', { class: 'tc-promptfield' }, - label ? el('label', { class: 'tc-promptlabel' }, label) : null, + const content = el('div', { class: 'promptfield' }, + label ? el('label', { class: 'promptlabel' }, label) : null, input); const result = openDialog({ title, message, content, buttons: [ - { label: cancelLabel, value: '__cancel__', class: 'tc-cancel' }, - { label: confirmLabel, value: '__ok__', class: 'tc-confirm', autofocus: true }, + { label: cancelLabel, value: '__cancel__', class: 'cancel' }, + { label: confirmLabel, value: '__ok__', class: 'confirm', autofocus: true }, ], }).then((v) => (v === '__ok__' ? input.value : null)); // Prefer focusing the field over the OK button once the dialog has mounted. @@ -199,24 +294,51 @@ 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 // timer), rather than the closure-captured timer handle the pre-custom- // element version used. Callers set `._message`/`._opts` before append — -// same reason as `` above. +// same reason as `` above. The element itself is the visible +// toast box (`:host` carries the styling, `:host(.error)` etc. switch on +// a plain class the host carries) — the message text renders directly in +// the shadow root rather than via a ``, since there's no external +// light-DOM content to project. class HiveToast extends HTMLElement { connectedCallback() { const { type = 'info', duration } = this._opts || {}; const ms = duration != null ? duration : (type === 'error' ? 8000 : 4000); - this.className = 'tc-toast tc-toast-' + type; + const root = attachShadow(this, TOAST_CSS); + this.classList.add(type); this.setAttribute('role', type === 'error' ? 'alert' : 'status'); - this.textContent = this._message || ''; + root.textContent = this._message || ''; let removed = false; this._remove = () => { if (removed) return; removed = true; - this.classList.add('tc-toast-out'); + this.classList.add('out'); setTimeout(() => this.remove(), 200); }; this.addEventListener('click', this._remove); @@ -233,10 +355,25 @@ customElements.define('hive-toast', HiveToast); // decision (errors, validation, status). Stacks in a fixed top-right // container, auto-dismisses after `duration` ms (errors linger longer), and // can be clicked to dismiss early. `type` ∈ {'info','error','ok'}. +// The stack container is a plain positioning wrapper (not itself a +// component — no theming, no encapsulation need), so it's styled with a +// one-off inline style rather than a stylesheet. export function themedToast(message, opts = {}) { let container = document.getElementById('tc-toasts'); if (!container) { - container = el('div', { id: 'tc-toasts', class: 'tc-toasts' }); + container = document.createElement('div'); + container.id = 'tc-toasts'; + Object.assign(container.style, { + position: 'fixed', + top: '1em', + right: '1em', + zIndex: '1100', + display: 'flex', + flexDirection: 'column', + gap: '0.5em', + maxWidth: 'min(28em, 92vw)', + pointerEvents: 'none', + }); document.body.append(container); } const toast = document.createElement('hive-toast');