runtime: styles for created roots are constructed sheets (CSP-proof)

- styles.ts: adoptStyles(root, css) adopts a constructed stylesheet into a shadow root or document
  (not governed by a page's CSP, parsed once, stays with the root when its host is moved);
  <style> fallback where constructable sheets are missing or refused.
- UI kit: the kit's shadow root and each dialog's `css` use it (dialog CSS goes with the dialog).
- Plugin API: api.adoptCSS(root, css) and scope.adoptCSS (removed on dispose); injectCSS keeps its
  <style> and, when a CSP leaves it without a sheet, applies the same rules as an adopted sheet.
- tests/styles.test.ts.
This commit is contained in:
CRTkafa 2026-09-29 04:09:14 +07:00
parent 30eed908cb
commit aab3d4d64a
4 changed files with 310 additions and 8 deletions

View file

@ -8,9 +8,10 @@
import { createThemeApi, type ThemeApi, type ThemeSource } from './themeApi.ts'; import { createThemeApi, type ThemeApi, type ThemeSource } from './themeApi.ts';
import { createUiKit, type UiKit } from './ui.ts'; import { createUiKit, type UiKit } from './ui.ts';
import { adoptStyles, type RootStyles } from './styles.ts';
import { createCommandRegistry, type CommandDefinition, type CommandInfo, type CommandRegistry } from './commands.ts'; import { createCommandRegistry, type CommandDefinition, type CommandInfo, type CommandRegistry } from './commands.ts';
export type { ThemeApi, ThemeSource, UiKit, CommandDefinition, CommandInfo, CommandRegistry }; export type { ThemeApi, ThemeSource, UiKit, CommandDefinition, CommandInfo, CommandRegistry, RootStyles };
export type Dispose = () => void; export type Dispose = () => void;
@ -25,6 +26,8 @@ export type PluginStorage = {
/** Collects disposers so a plugin's `stop()` is a single `scope.dispose()`. */ /** Collects disposers so a plugin's `stop()` is a single `scope.dispose()`. */
export type PluginScope = { export type PluginScope = {
css(id: string, css: string): void; css(id: string, css: string): void;
/** `api.adoptCSS`, taken off the root on dispose. */
adoptCSS(root: ShadowRoot | Document, css: string): RootStyles;
on(target: EventTarget, type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; on(target: EventTarget, type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
observe(selector: string, callback: (element: Element) => void): void; observe(selector: string, callback: (element: Element) => void): void;
onRouteChange(callback: (href: string, previous: string) => void): void; onRouteChange(callback: (href: string, previous: string) => void): void;
@ -45,6 +48,12 @@ export type PluginScope = {
export type CorePluginApi = { export type CorePluginApi = {
injectCSS(id: string, css: string): void; injectCSS(id: string, css: string): void;
clearCSS(id: string): void; clearCSS(id: string): void;
/**
* Styles a shadow root the plugin created (or the document) with a constructable stylesheet:
* the page's Content-Security-Policy cannot refuse it, it is parsed once, and it stays with the
* root wherever its host is moved. Falls back to a `<style>` element where unsupported.
*/
adoptCSS(root: ShadowRoot | Document, css: string): RootStyles;
waitFor(selector: string, timeoutMs?: number): Promise<Element>; waitFor(selector: string, timeoutMs?: number): Promise<Element>;
/** Namespaced JSON storage in this tab's localStorage (`<storagePrefix><namespace>:<key>`). */ /** Namespaced JSON storage in this tab's localStorage (`<storagePrefix><namespace>:<key>`). */
storage(namespace: string): PluginStorage; storage(namespace: string): PluginStorage;
@ -125,6 +134,7 @@ export function createCorePluginApi(win: Window & typeof globalThis, site: Plugi
h: (...args: Parameters<UiKit['h']>) => uiKit().h(...args), h: (...args: Parameters<UiKit['h']>) => uiKit().h(...args),
}); });
const styleNodes = new Map<string, HTMLStyleElement>(); const styleNodes = new Map<string, HTMLStyleElement>();
const adoptedFallbacks = new Map<string, RootStyles>();
const report = (error: unknown) => console.error(`[${site.product}] plugin callback failed`, error); const report = (error: unknown) => console.error(`[${site.product}] plugin callback failed`, error);
const registry = createCommandRegistry({ target: doc, isTyping: event => isTypingTarget(event.target) || isTypingTarget(doc.activeElement), report }); const registry = createCommandRegistry({ target: doc, isTyping: event => isTypingTarget(event.target) || isTypingTarget(doc.activeElement), report });
const commands: CommandRegistry = Object.freeze({ const commands: CommandRegistry = Object.freeze({
@ -159,10 +169,26 @@ export function createCorePluginApi(win: Window & typeof globalThis, site: Plugi
styleNodes.set(id, node); styleNodes.set(id, node);
} }
node.textContent = css; node.textContent = css;
// A CSP that refuses inline styles leaves the element without a sheet: the same rules as an
// adopted sheet instead (CSP does not govern constructed sheets).
const blocked = 'sheet' in node && node.sheet === null && css.trim() !== '';
const fallback = adoptedFallbacks.get(id);
if (blocked) {
if (fallback) fallback.update(css);
else adoptedFallbacks.set(id, adoptStyles(doc, css));
} else if (fallback) {
fallback.remove();
adoptedFallbacks.delete(id);
}
}, },
clearCSS(id) { clearCSS(id) {
styleNodes.get(id)?.remove(); styleNodes.get(id)?.remove();
styleNodes.delete(id); styleNodes.delete(id);
adoptedFallbacks.get(id)?.remove();
adoptedFallbacks.delete(id);
},
adoptCSS(root, css) {
return adoptStyles(root, css);
}, },
waitFor(selector, timeoutMs = 15000) { waitFor(selector, timeoutMs = 15000) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -299,6 +325,11 @@ export function createCorePluginApi(win: Window & typeof globalThis, site: Plugi
api.injectCSS(id, css); api.injectCSS(id, css);
disposers.push(() => api.clearCSS(id)); disposers.push(() => api.clearCSS(id));
}, },
adoptCSS(root, css) {
const styles = api.adoptCSS(root, css);
disposers.push(() => styles.remove());
return styles;
},
on(target, type, listener, options) { on(target, type, listener, options) {
target.addEventListener(type, listener, options); target.addEventListener(type, listener, options);
disposers.push(() => target.removeEventListener(type, listener, options)); disposers.push(() => target.removeEventListener(type, listener, options));

View file

@ -0,0 +1,83 @@
// Styles for roots the runtime (or a plugin) creates: a constructable stylesheet adopted by the
// shadow root (or document) instead of a `<style>` element.
//
// Why: a site's Content-Security-Policy governs `<style>` elements (a `style-src` with a nonce or
// hash and no 'unsafe-inline' refuses them, in shadow roots too), but not constructed sheets
// (`new CSSStyleSheet()` + `replaceSync`), which are also parsed once instead of per element and
// stay adopted when their host is moved to another parent in the same document (a modal
// re-rendered by the site, a card lifted into the top layer). Engines without constructable
// sheets (or a root from another realm that refuses the sheet) get the `<style>` element.
export type RootStyles = {
/** The live sheet (insertRule/deleteRule work on it), or null when nothing could be parsed. */
readonly sheet: CSSStyleSheet | null;
/** True when the sheet is adopted (CSP-proof); false for the `<style>` fallback. */
readonly adopted: boolean;
/** Replaces the rules. */
update(css: string): void;
/** Takes the rules off the root (idempotent). */
remove(): void;
};
type StyleRoot = (ShadowRoot | Document) & { adoptedStyleSheets?: CSSStyleSheet[] };
const DOCUMENT_NODE = 9;
const isDocument = (root: StyleRoot): root is Document & StyleRoot => root.nodeType === DOCUMENT_NODE;
const documentOf = (root: StyleRoot): Document => (isDocument(root) ? root : (root as ShadowRoot).ownerDocument);
/** A sheet built in `root`'s own realm (a sheet from another document cannot be adopted). */
function construct(root: StyleRoot, css: string): CSSStyleSheet | null {
if (!root.adoptedStyleSheets) return null;
const Sheet = (documentOf(root).defaultView as (Window & typeof globalThis) | null)?.CSSStyleSheet;
if (typeof Sheet !== 'function' || typeof Sheet.prototype.replaceSync !== 'function') return null;
try {
const sheet = new Sheet();
sheet.replaceSync(css);
return sheet;
} catch {
return null;
}
}
/**
* Styles `root` with `css`. Adopted sheets come after the root's `<style>` elements in the cascade
* (like a `<style>` appended last), and several calls stack in call order.
*/
export function adoptStyles(root: ShadowRoot | Document, css: string): RootStyles {
const target = root as StyleRoot;
const sheet = construct(target, css);
if (sheet) {
let on = false;
const attach = () => {
try {
target.adoptedStyleSheets = [...(target.adoptedStyleSheets ?? []), sheet];
on = true;
} catch { on = false; }
};
attach();
if (on) {
return {
sheet,
adopted: true,
update(next) { try { sheet.replaceSync(next); } catch { /* Keeps the last good rules. */ } },
remove() {
if (!on) return;
on = false;
try { target.adoptedStyleSheets = (target.adoptedStyleSheets ?? []).filter(item => item !== sheet); } catch { /* Root gone. */ }
},
};
}
}
// Fallback: a `<style>` element as the root's last child (a document: its <head>).
const doc = documentOf(target);
const style = doc.createElement('style');
style.textContent = css;
const parent: Node = isDocument(target) ? (target.head ?? target.documentElement) : target;
parent.appendChild(style);
return {
get sheet() { return style.sheet; },
adopted: false,
update(next) { style.textContent = next; },
remove() { style.remove(); },
};
}

View file

@ -10,6 +10,7 @@
// region (danger toasts `role="alert"`), pause while hovered or focused, and stack (four at most). // region (danger toasts `role="alert"`), pause while hovered or focused, and stack (four at most).
// Motion is skipped with `prefers-reduced-motion`. Text is always set as text (never parsed as HTML). // Motion is skipped with `prefers-reduced-motion`. Text is always set as text (never parsed as HTML).
import { adoptStyles, type RootStyles } from './styles.ts';
import { themeVars, type ResolvedTheme, type ThemeApi } from './themeApi.ts'; import { themeVars, type ResolvedTheme, type ThemeApi } from './themeApi.ts';
export type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'danger'; export type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
@ -357,6 +358,7 @@ export function createUiKit(win: Window & typeof globalThis, options: UiKitOptio
const hostTag = options.hostTag ?? 'wb-ui'; const hostTag = options.hostTag ?? 'wb-ui';
const product = options.product ?? 'wayBetter'; const product = options.product ?? 'wayBetter';
const report = (error: unknown) => console.error(`[${product}] ui callback failed`, error); const report = (error: unknown) => console.error(`[${product}] ui callback failed`, error);
const isShadowRoot = (node: unknown): node is ShadowRoot => typeof win.ShadowRoot === 'function' && node instanceof win.ShadowRoot;
const reducedMotion = () => { try { return !!win.matchMedia?.('(prefers-reduced-motion: reduce)').matches; } catch { return false; } }; const reducedMotion = () => { try { return !!win.matchMedia?.('(prefers-reduced-motion: reduce)').matches; } catch { return false; } };
let host: HTMLElement | null = null; let host: HTMLElement | null = null;
@ -401,8 +403,6 @@ export function createUiKit(win: Window & typeof globalThis, options: UiKitOptio
// The host sits above the site; it takes no clicks itself (its layers and toasts do). // The host sits above the site; it takes no clicks itself (its layers and toasts do).
host.style.cssText = `all:initial;position:fixed;inset:0;z-index:${options.zIndex ?? 2147483600};pointer-events:none;display:block;`; host.style.cssText = `all:initial;position:fixed;inset:0;z-index:${options.zIndex ?? 2147483600};pointer-events:none;display:block;`;
root = typeof host.attachShadow === 'function' ? host.attachShadow({ mode: 'open' }) : host; root = typeof host.attachShadow === 'function' ? host.attachShadow({ mode: 'open' }) : host;
const style = doc.createElement('style');
style.textContent = UI_CSS;
const wrap = doc.createElement('div'); const wrap = doc.createElement('div');
wrap.className = 'wb-root'; wrap.className = 'wb-root';
layers = doc.createElement('div'); layers = doc.createElement('div');
@ -411,7 +411,15 @@ export function createUiKit(win: Window & typeof globalThis, options: UiKitOptio
toastRegion.setAttribute('aria-live', 'polite'); toastRegion.setAttribute('aria-live', 'polite');
toastRegion.setAttribute('aria-relevant', 'additions'); toastRegion.setAttribute('aria-relevant', 'additions');
wrap.append(layers, toastRegion); wrap.append(layers, toastRegion);
root.append(style, wrap); root.append(wrap);
// An adopted sheet (see styles.ts): the site's CSP cannot refuse it, and it stays with the root
// wherever the host is moved. A host without a shadow root keeps a <style> child.
if (isShadowRoot(root)) adoptStyles(root, UI_CSS);
else {
const style = doc.createElement('style');
style.textContent = UI_CSS;
root.prepend(style);
}
// Keys typed in a dialog are ours: the site's (and plugins') page shortcuts never see them. // Keys typed in a dialog are ours: the site's (and plugins') page shortcuts never see them.
for (const type of ['keydown', 'keyup', 'keypress']) root.addEventListener(type, event => event.stopPropagation()); for (const type of ['keydown', 'keyup', 'keypress']) root.addEventListener(type, event => event.stopPropagation());
(doc.body ?? doc.documentElement).appendChild(host); (doc.body ?? doc.documentElement).appendChild(host);
@ -525,11 +533,16 @@ export function createUiKit(win: Window & typeof globalThis, options: UiKitOptio
panel.appendChild(message); panel.appendChild(message);
panel.setAttribute('aria-describedby', message.id); panel.setAttribute('aria-describedby', message.id);
} }
// The dialog's own CSS, on the kit's root while the dialog is open (adopted where possible).
let ownStyles: RootStyles | null = null;
if (opts.css) { if (opts.css) {
if (root && isShadowRoot(root)) ownStyles = adoptStyles(root, opts.css);
else {
const style = doc.createElement('style'); const style = doc.createElement('style');
style.textContent = opts.css; style.textContent = opts.css;
panel.appendChild(style); panel.appendChild(style);
} }
}
const body = doc.createElement('div'); const body = doc.createElement('div');
body.className = 'wb-body'; body.className = 'wb-body';
panel.appendChild(body); panel.appendChild(body);
@ -629,7 +642,7 @@ export function createUiKit(win: Window & typeof globalThis, options: UiKitOptio
syncStack(); syncStack();
layer.setAttribute('inert', ''); layer.setAttribute('inert', '');
layer.classList.add('wb-closing'); layer.classList.add('wb-closing');
later(() => layer.remove(), reducedMotion() ? 0 : 130); later(() => { layer.remove(); ownStyles?.remove(); }, reducedMotion() ? 0 : 130);
// Dialogs opened from this one return focus where this one would have. // Dialogs opened from this one return focus where this one would have.
for (const other of stack) if (other.returnTo && layer.contains(other.returnTo)) other.returnTo = entry.returnTo; for (const other of stack) if (other.returnTo && layer.contains(other.returnTo)) other.returnTo = entry.returnTo;
// Focus never lingers in a dialog that is animating out (keys would still go to its fields). // Focus never lingers in a dialog that is animating out (keys would still go to its fields).

View file

@ -0,0 +1,175 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { adoptStyles } from '../src/styles.ts';
import { createCorePluginApi, type PluginApiSite } from '../src/pluginApi.ts';
/** A constructable sheet as far as the helper uses one. */
class FakeSheet {
static refuse = false;
css = '';
replaceSync(css: string) {
if (FakeSheet.refuse) throw new Error('refused');
this.css = css;
}
}
type FakeStyle = { tag: string; textContent: string; parent: FakeParent | null; sheet?: unknown; remove(): void };
type FakeParent = { children: FakeStyle[]; appendChild(node: FakeStyle): FakeStyle };
function parent(): FakeParent {
const node: FakeParent = {
children: [],
appendChild(child) { child.parent = node; node.children.push(child); return child; },
};
return node;
}
function fakeDocument(options: { constructable?: boolean; adopted?: boolean } = {}) {
const win: Record<string, unknown> = {};
if (options.constructable !== false) win.CSSStyleSheet = FakeSheet;
const head = parent();
const doc: Record<string, unknown> = {
nodeType: 9,
defaultView: win,
head,
documentElement: parent(),
createElement(tag: string): FakeStyle {
const node: FakeStyle = { tag, textContent: '', parent: null, remove() { if (node.parent) node.parent.children = node.parent.children.filter(c => c !== node); node.parent = null; } };
return node;
},
};
if (options.adopted !== false) doc.adoptedStyleSheets = [];
win.document = doc;
return { win, doc, head };
}
function fakeShadowRoot(doc: Record<string, unknown>, adopted = true) {
const root = parent() as FakeParent & Record<string, unknown>;
root.nodeType = 11;
root.ownerDocument = doc;
if (adopted) root.adoptedStyleSheets = [];
return root;
}
test('a shadow root adopts a constructed sheet: no <style> element, update and remove work', () => {
FakeSheet.refuse = false;
const { doc } = fakeDocument();
const root = fakeShadowRoot(doc);
const earlier = new FakeSheet();
root.adoptedStyleSheets = [earlier];
const styles = adoptStyles(root as unknown as ShadowRoot, '.a { color: red; }');
assert.equal(styles.adopted, true);
assert.equal(root.children.length, 0, 'no <style> element the page CSP could refuse');
const adopted = root.adoptedStyleSheets as FakeSheet[];
assert.equal(adopted.length, 2);
assert.equal(adopted[0], earlier, 'sheets already adopted stay first');
assert.equal(adopted[1].css, '.a { color: red; }');
assert.equal(styles.sheet, adopted[1] as unknown as CSSStyleSheet);
styles.update('.b {}');
assert.equal(adopted[1].css, '.b {}');
styles.remove();
assert.deepEqual(root.adoptedStyleSheets, [earlier]);
styles.remove();
assert.deepEqual(root.adoptedStyleSheets, [earlier], 'remove is idempotent');
});
test('without constructable sheets, or when the engine refuses one, a <style> element is the fallback', () => {
FakeSheet.refuse = false;
const noCtor = fakeDocument({ constructable: false });
const root = fakeShadowRoot(noCtor.doc);
const styles = adoptStyles(root as unknown as ShadowRoot, '.x {}');
assert.equal(styles.adopted, false);
assert.equal(root.children.length, 1);
assert.equal(root.children[0].textContent, '.x {}');
styles.update('.y {}');
assert.equal(root.children[0].textContent, '.y {}');
styles.remove();
assert.equal(root.children.length, 0);
const noAdopt = fakeDocument();
const legacy = fakeShadowRoot(noAdopt.doc, false);
assert.equal(adoptStyles(legacy as unknown as ShadowRoot, '.z {}').adopted, false);
assert.equal(legacy.children.length, 1);
FakeSheet.refuse = true;
const refused = fakeDocument();
const root2 = fakeShadowRoot(refused.doc);
assert.equal(adoptStyles(root2 as unknown as ShadowRoot, '.w {}').adopted, false);
assert.equal(root2.children.length, 1);
FakeSheet.refuse = false;
});
test('a document root: adopted sheet, or a <style> in <head> as the fallback', () => {
FakeSheet.refuse = false;
const { doc, head } = fakeDocument();
const styles = adoptStyles(doc as unknown as Document, '.d {}');
assert.equal(styles.adopted, true);
assert.equal((doc.adoptedStyleSheets as FakeSheet[]).length, 1);
assert.equal(head.children.length, 0);
const plain = fakeDocument({ adopted: false });
adoptStyles(plain.doc as unknown as Document, '.e {}');
assert.equal(plain.head.children.length, 1);
});
const SITE: PluginApiSite = {
name: 'Demo', product: 'wayBetter for Test', storagePrefix: 'demo-plugin:', styleDataKey: 'demoPlugin',
toastId: 'demo-toast', toastBackground: '#111', itemSelector: 'article.message', chordKeys: ['g'],
};
function pluginEnv(cspBlocksInline: boolean) {
FakeSheet.refuse = false;
const { win, doc, head } = fakeDocument();
const createElement = doc.createElement as (tag: string) => FakeStyle;
Object.assign(doc, {
addEventListener() {}, removeEventListener() {}, activeElement: null, querySelectorAll: () => [],
createElement(tag: string) {
const node = createElement(tag) as FakeStyle & { dataset: Record<string, string>; isConnected: boolean };
node.dataset = {};
// A real <style>: a sheet once connected, none when the CSP refuses inline styles.
Object.defineProperty(node, 'sheet', { get: () => (node.parent && !cspBlocksInline ? {} : null) });
Object.defineProperty(node, 'isConnected', { get: () => !!node.parent });
return node;
},
});
Object.assign(win, {
location: { href: 'https://site.test/home' }, localStorage: null,
addEventListener() {}, removeEventListener() {},
requestAnimationFrame: (fn: () => void) => { fn(); return 1; }, cancelAnimationFrame: () => {},
MutationObserver: class { observe() {} disconnect() {} },
});
const api = createCorePluginApi(win as unknown as Window & typeof globalThis, SITE);
return { api, doc, head };
}
test('injectCSS: an inline <style> the page CSP refuses gets its rules as an adopted sheet instead', () => {
const { api, doc, head } = pluginEnv(true);
api.injectCSS('demo', '.a {}');
assert.equal(head.children.length, 1, 'the <style> element is still there (the CSP may change)');
const adopted = doc.adoptedStyleSheets as FakeSheet[];
assert.equal(adopted.length, 1);
assert.equal(adopted[0].css, '.a {}');
api.injectCSS('demo', '.b {}');
assert.equal(adopted.length, 1, 'updated in place, not stacked');
assert.equal((doc.adoptedStyleSheets as FakeSheet[])[0].css, '.b {}');
api.clearCSS('demo');
assert.equal((doc.adoptedStyleSheets as FakeSheet[]).length, 0);
assert.equal(head.children.length, 0);
});
test('injectCSS: an allowed <style> needs no adopted copy', () => {
const { api, doc, head } = pluginEnv(false);
api.injectCSS('demo', '.a {}');
assert.equal(head.children.length, 1);
assert.equal((doc.adoptedStyleSheets as FakeSheet[]).length, 0);
});
test('scope.adoptCSS takes the sheet off the root on dispose', () => {
const { api, doc } = pluginEnv(false);
const root = fakeShadowRoot(doc);
const scope = api.scope();
const styles = scope.adoptCSS(root as unknown as ShadowRoot, '.card {}');
assert.equal(styles.adopted, true);
assert.equal((root.adoptedStyleSheets as FakeSheet[]).length, 1);
scope.dispose();
assert.equal((root.adoptedStyleSheets as FakeSheet[]).length, 0);
});