openvibers/openvibe.shared Commits

4.86.0: status colours follow the theme; marks, room text and LIVE badges reach 4.5:1 (#240)

OP OpenVibers committed 12 days ago · c7dd951c91 · 12 files, +113 −13

* 4.86.0: status colour names (--error-text, --ok, --warn) resolve to the theme's readable status colours

* Map --accent-text onto the theme's readable accent too

* Room kit marks keep white letters at 4.5:1 for every hue

* A room's text colour and LIVE badges reach 4.5:1

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 958945a..a2fec6f 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,25 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 4.86.0 — 2026-11-01
++
++- **Status colour names every site can use.**
++ - About eighteen sites colour error and status words with `--error-text`, `--error`, `--ok` or `--warn`, and accent
++ words with `--accent-text`. No theme sets those names, so each site's fallback literal showed: `#ef4444`,
++ `#34d399` and `#fbbf24` are pale on light themes.
++ - theme-loader.js now maps them onto the theme's own readable colours (`--danger-text`, `--danger`,
++ `--success-text`, `--warning-text`, `--accent-strong`). The mapping sits in `:where(html)`, so a site that sets
++ one of these names itself keeps its own value.
++- **Room kit marks reach 4.5:1 for every hue.** `kit.mark`'s white letters sat on `hsl(hue 45% 38%)`, about
++ 3.5:1 for greens and yellows (axe on Community, Report, Tours, Shoes and Bot). Lightness is now 32%: the worst
++ hue is 4.75:1. `test/kit-mark-contrast.test.js` checks all 360.
++- **A room's text colour reaches 4.5:1 on its own chips.** `--room-strong` (room.css) was the bare room accent:
++ 3.9-4.4:1 on the room's tinted chips (Bench's benchmark ids, Exchange's listing labels). It is now the accent with a
++ quarter of the theme's text mixed in, 5.2:1 or more there, the hue unchanged.
++- **LIVE badges are white on a deeper red.** `kit.css`'s `--ovk-live` and showcase's `.sc-live` filled with the
++ theme's `--live-red` under white text: 3.8:1 on `#ef4444`. The fill is that red at 80% over black (5.5:1); every
++ built-in theme's red reaches 4.5:1 except Catppuccin's pastel pink.
++
## 4.85.0 — 2026-11-01
- **Share cards for fourteen more sites.**
ddiff --git a/README.md b/README.md
iindex a984f8b..672b52e 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.85.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.86.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex ad554c2..1bade6b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.85.0",
++ "version": "4.86.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/kit.css b/kit.css
iindex fcd5f4c..5d8c3a9 100644
---- a/kit.css
++++ b/kit.css
@@@ -29,7 +29,7 @@
--ovk-ok-text: var(--success-text, var(--ovk-ok));
--ovk-warn-text: var(--warning-text, var(--ovk-warn));
--ovk-bad-text: var(--danger-text, var(--ovk-bad));
-- --ovk-live: var(--live-red, #ef4444);
++ --ovk-live: color-mix(in srgb, var(--live-red, #ef4444) 80%, #000); /* a badge fill under white text: 5.5:1 on #ef4444's 80%, 3.8:1 bare */
/* Shape and elevation (4.81.0, plan L45 step 2): the kit's corners and shadows as tokens a look can set, so a theme
* has a shape as well as colours. The defaults are the kit's own corners and shadows; the personalities follow. */
--ov-radius-1: 10px; /* chips, inputs, rows, small buttons */
@@@ -165,7 +165,8 @@ html[data-ov-motion=reduced] { --ov-dur-1: 0s; --ov-dur-2: 0s; --ov-dur-3: 0s; -
.ovk-row-trail { display: flex; align-items: center; gap: 2px; flex: none; }
.ovk-row-trail form { display: flex; align-items: center; gap: 2px; margin: 0; }
.ovk-row-trail select { max-width: 9.5em; padding: 5px 6px; border-radius: max(0px, calc(var(--ov-radius-1) - 2px)); border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: .86rem; }
--.ovk-mark { --ovk-mark: 40px; flex: none; display: inline-grid; place-items: center; width: var(--ovk-mark); height: var(--ovk-mark); box-sizing: border-box; border-radius: 50%; object-fit: cover; background: hsl(var(--ovk-hue, 215) 45% 38%); color: #fff; font-size: calc(var(--ovk-mark) * .34); font-weight: 800; letter-spacing: .02em; line-height: 1; }
++/* 32% lightness keeps the white letters at 4.5:1 or more for every hue (38% left greens and yellows near 3.7:1). */
++.ovk-mark { --ovk-mark: 40px; flex: none; display: inline-grid; place-items: center; width: var(--ovk-mark); height: var(--ovk-mark); box-sizing: border-box; border-radius: 50%; object-fit: cover; background: hsl(var(--ovk-hue, 215) 45% 32%); color: #fff; font-size: calc(var(--ovk-mark) * .34); font-weight: 800; letter-spacing: .02em; line-height: 1; }
.ovk-mark--square { border-radius: 28%; }
.ovk-empty-line { margin: 0 0 14px; padding: 14px; border: 1px dashed var(--ovk-line); border-radius: var(--ovk-radius); color: var(--ovk-muted); }
ddiff --git a/package.json b/package.json
iindex d96f818..688645b 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.85.0",
++ "version": "4.86.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/room.css b/room.css
iindex 4afd352..f4b56ff 100644
---- a/room.css
++++ b/room.css
@@@ -26,7 +26,7 @@
--ovk-ok-text: var(--success-text, var(--ovk-ok));
--ovk-warn-text: var(--warning-text, var(--ovk-warn));
--ovk-bad-text: var(--danger-text, var(--ovk-bad));
-- --ovk-live: var(--live-red, #ef4444);
++ --ovk-live: color-mix(in srgb, var(--live-red, #ef4444) 80%, #000); /* a badge fill under white text: 5.5:1 on #ef4444's 80%, 3.8:1 bare */
/* Shape and elevation (4.81.0, plan L45 step 2): the kit's corners and shadows as tokens a look can set, so a theme
* has a shape as well as colours. The defaults are the kit's own corners and shadows; the personalities follow. */
--ov-radius-1: 10px; /* chips, inputs, rows, small buttons */
@@@ -82,11 +82,12 @@
}
/* ── Rooms (rooms.js): the room's accent in the theme's mode. rooms.style(id) sets --room-dark/--room-light on .ov-room;
-- * without them the theme's accent stays. --room-accent: icons, outlines, motifs; --room-strong: text on the page;
++ * without them the theme's accent stays. --room-accent: icons, outlines, motifs; --room-strong: text on the page (a
++ * quarter of the theme's text mixed in, so it stays at 4.5:1 on the room's tinted chips: the bare accent was 3.9-4.4:1);
* --room-fill and --room-on: a filled button and its text (dark text on a dark theme's bright fill, white on light). ── */
--.ov-room { --room-accent: var(--ovk-accent); --room-strong: var(--ovk-accent); --room-fill: var(--ovk-fill); --room-on: var(--ovk-on-fill); }
--.ov-room[style*="--room-dark"] { --room-accent: var(--room-dark); --room-strong: var(--room-dark); --room-fill: var(--room-dark); --room-on: #0b0d12; }
--:root[data-theme-mode="light"] .ov-room[style*="--room-light"] { --room-accent: var(--room-light); --room-strong: var(--room-light); --room-fill: var(--room-light); --room-on: #fff; }
++.ov-room { --room-accent: var(--ovk-accent); --room-strong: color-mix(in srgb, var(--ovk-accent) 75%, var(--ovk-text)); --room-fill: var(--ovk-fill); --room-on: var(--ovk-on-fill); }
++.ov-room[style*="--room-dark"] { --room-accent: var(--room-dark); --room-strong: color-mix(in srgb, var(--room-dark) 75%, var(--ovk-text)); --room-fill: var(--room-dark); --room-on: #0b0d12; }
++:root[data-theme-mode="light"] .ov-room[style*="--room-light"] { --room-accent: var(--room-light); --room-strong: color-mix(in srgb, var(--room-light) 75%, var(--ovk-text)); --room-fill: var(--room-light); --room-on: #fff; }
/* ── Cards (kit.card): one card for every kind of thing ── */
.ovk-card { position: relative; display: flex; flex-direction: column; gap: 10px; min-width: 0; color: var(--ovk-text); }
ddiff --git a/showcase.css b/showcase.css
iindex cfee4ed..78e14ee 100644
---- a/showcase.css
++++ b/showcase.css
@@@ -118,7 +118,7 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
.sc-thumb{position:relative;display:block;aspect-ratio:var(--sc-ar,16/9);background:linear-gradient(135deg,color-mix(in srgb,var(--accent,#3b82f6) 22%,var(--bg-tertiary,#172238)),var(--bg-tertiary,#172238))}
.sc-thumb img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transition:transform .35s ease}.sc-tile:hover .sc-thumb img{transform:scale(1.04)}
.sc-badge{position:absolute;left:10px;top:10px;padding:3px 8px;border-radius:6px;font-size:11px;font-weight:800;letter-spacing:.4px;color:#fff;background:rgba(0,0,0,.66)}
--.sc-badge.sc-live{background:var(--live-red,#ef4444)}.sc-badge.sc-live::before{content:'';display:inline-block;width:6px;height:6px;margin-right:5px;border-radius:50%;background:#fff;vertical-align:1px;animation:scPulse 1.4s ease-in-out infinite}
++.sc-badge.sc-live{background:color-mix(in srgb,var(--live-red,#ef4444) 80%,#000)}.sc-badge.sc-live::before{content:'';display:inline-block;width:6px;height:6px;margin-right:5px;border-radius:50%;background:#fff;vertical-align:1px;animation:scPulse 1.4s ease-in-out infinite}
.sc-tile-body{display:block;padding:12px 14px 14px}.sc-tile-body b{display:block;font-size:15px;line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.sc-tile-body small{display:block;margin-top:4px;font-size:12.5px;color:var(--text-muted,#7386a3)}.sc-tile-text{display:block;margin-top:6px;font-size:13.5px;line-height:1.45;color:var(--text-secondary,#96a7c2)}
.sc-spots{display:grid;gap:28px}
ddiff --git a/showcase.js b/showcase.js
iindex 9287275..267b6c1 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -538,7 +538,7 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
.sc-thumb{position:relative;display:block;aspect-ratio:var(--sc-ar,16/9);background:linear-gradient(135deg,color-mix(in srgb,var(--accent,#3b82f6) 22%,var(--bg-tertiary,#172238)),var(--bg-tertiary,#172238))}
.sc-thumb img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transition:transform .35s ease}.sc-tile:hover .sc-thumb img{transform:scale(1.04)}
.sc-badge{position:absolute;left:10px;top:10px;padding:3px 8px;border-radius:6px;font-size:11px;font-weight:800;letter-spacing:.4px;color:#fff;background:rgba(0,0,0,.66)}
--.sc-badge.sc-live{background:var(--live-red,#ef4444)}.sc-badge.sc-live::before{content:'';display:inline-block;width:6px;height:6px;margin-right:5px;border-radius:50%;background:#fff;vertical-align:1px;animation:scPulse 1.4s ease-in-out infinite}
++.sc-badge.sc-live{background:color-mix(in srgb,var(--live-red,#ef4444) 80%,#000)}.sc-badge.sc-live::before{content:'';display:inline-block;width:6px;height:6px;margin-right:5px;border-radius:50%;background:#fff;vertical-align:1px;animation:scPulse 1.4s ease-in-out infinite}
.sc-tile-body{display:block;padding:12px 14px 14px}.sc-tile-body b{display:block;font-size:15px;line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.sc-tile-body small{display:block;margin-top:4px;font-size:12.5px;color:var(--text-muted,#7386a3)}.sc-tile-text{display:block;margin-top:6px;font-size:13.5px;line-height:1.45;color:var(--text-secondary,#96a7c2)}
.sc-spots{display:grid;gap:28px}
ddiff --git a/test/kit-mark-contrast.test.js b/test/kit-mark-contrast.test.js
nnew file mode 100644
iindex 0000000..a1343da
---- /dev/null
++++ b/test/kit-mark-contrast.test.js
@@@ -0,0 +1,26 @@
++'use strict';
++// kit.mark's letters are white on hsl(hue S L). Every hue (a name's hash picks it) must keep them at WCAG AA, 4.5:1.
++// At 38% lightness greens and yellows were 3.5-4.2:1 (axe on Community, Report, Tours, Shoes and Bot, 2026-11-01).
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const css = fs.readFileSync(path.join(__dirname, '..', 'kit.css'), 'utf8');
++const m = /\.ovk-mark \{[^}]*background: hsl\(var\(--ovk-hue, \d+\) (\d+)% (\d+)%\); color: #fff;/.exec(css);
++assert.ok(m, '.ovk-mark is white on hsl(hue S% L%)');
++const S = Number(m[1]) / 100, L = Number(m[2]) / 100;
++
++function hsl(h, s, l) {
++ const c = (1 - Math.abs(2 * l - 1)) * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), k = l - c / 2;
++ const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
++ return [r + k, g + k, b + k];
++}
++const lin = (v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
++const lum = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
++let worst = { ratio: Infinity, hue: -1 };
++Array.from({ length: 360 }, (_, h) => h).forEach((h) => {
++ const ratio = 1.05 / (lum(hsl(h, S, L)) + 0.05);
++ if (ratio < worst.ratio) worst = { ratio, hue: h };
++});
++assert.ok(worst.ratio >= 4.5, `white on hue ${worst.hue} is ${worst.ratio.toFixed(2)}:1`);
++console.log(`kit mark contrast: all checks passed (worst ${worst.ratio.toFixed(2)}:1 at hue ${worst.hue})`);
ddiff --git a/test/room-text-live-badge.test.js b/test/room-text-live-badge.test.js
nnew file mode 100644
iindex 0000000..e787d5e
---- /dev/null
++++ b/test/room-text-live-badge.test.js
@@@ -0,0 +1,31 @@
++'use strict';
++// Two colours that carried text below WCAG AA (axe on the homes, 2026-11-01):
++// - a room's text colour (--room-strong) was its bare accent: 3.9-4.4:1 on the room's tinted chips (Bench, Exchange).
++// It now mixes a quarter of the theme's text in, which moves it toward contrast in either mode.
++// - LIVE badges were white on the theme's live red: 3.8:1 on #ef4444. The fill is now that red at 80% over black.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const room = fs.readFileSync(path.join(__dirname, '..', 'room.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
++const strong = [...room.matchAll(/--room-strong: ([^;]+);/g)].map((m) => m[1]);
++assert.strictEqual(strong.length, 3, 'the default room, a dark-mode room and a light-mode room');
++strong.forEach((v) => assert.match(v, /^color-mix\(in srgb, var\(--(ovk-accent|room-dark|room-light)\) 75%, var\(--ovk-text\)\)$/, v));
++
++const kit = fs.readFileSync(path.join(__dirname, '..', 'kit.css'), 'utf8');
++assert.match(kit, /--ovk-live: color-mix\(in srgb, var\(--live-red, #ef4444\) 80%, #000\);/);
++assert.match(kit, /\.ovk-badge--live \{ color: #fff; background: var\(--ovk-live\);/);
++const showcase = fs.readFileSync(path.join(__dirname, '..', 'showcase.css'), 'utf8');
++assert.match(showcase, /\.sc-badge\.sc-live\{background:color-mix\(in srgb,var\(--live-red,#ef4444\) 80%,#000\)\}/);
++
++// The fill keeps white at 4.5:1 for the built-in themes' reds (Catppuccin's pastel pink aside).
++const lin = (v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
++const ratio = (hex, k) => {
++ const [r, g, b] = [1, 3, 5].map((i) => (parseInt(hex.slice(i, i + 2), 16) / 255) * k);
++ return 1.05 / (0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) + 0.05);
++};
++const themes = fs.readFileSync(path.join(__dirname, '..', 'builtin-themes.js'), 'utf8');
++const reds = [...new Set([...themes.matchAll(/'--live-red': '(#[0-9a-f]{6})'/gi)].map((m) => m[1].toLowerCase()))];
++assert.ok(reds.length >= 3, 'the themes set --live-red');
++reds.filter((c) => c !== '#f38ba8').forEach((c) => assert.ok(ratio(c, 0.8) >= 4.5, `${c} at 80%: ${ratio(c, 0.8).toFixed(2)}:1`));
++console.log('room text and live badge: all checks passed');
ddiff --git a/test/status-aliases.test.js b/test/status-aliases.test.js
nnew file mode 100644
iindex 0000000..2228925
---- /dev/null
++++ b/test/status-aliases.test.js
@@@ -0,0 +1,18 @@
++'use strict';
++// theme-loader.js maps the status names some sites use (--error, --error-text, --ok, --warn) onto the theme's own
++// readable status colours, at zero specificity so a site's own value still wins (4.86.0). Before, no theme set them
++// and each site's fallback literal (#ef4444, #34d399, #fbbf24) showed, pale on light themes.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const SRC = fs.readFileSync(path.join(__dirname, '..', 'theme-loader.js'), 'utf8');
++const alias = (SRC.match(/const ALIAS_CSS = '([^']*)';/) || [])[1] || '';
++assert.ok(alias.startsWith(':where(html){'), 'zero specificity: a site that defines --error keeps its own');
++const pairs = [['--error', '--danger'], ['--error-text', '--danger-text'], ['--ok', '--success-text'], ['--warn', '--warning-text'], ['--accent-text', '--accent-strong']];
++pairs.forEach(([name, token]) => {
++ assert.ok(alias.includes(`${name}:var(${token})`), `${name} is the theme's ${token}`);
++ assert.ok(SRC.includes(`'${token}'`), `${token} is one of the theme keys every theme sets`);
++});
++assert.match(SRC, /\+ ALIAS_CSS \+ COMFORT_CSS;/, 'injected with the display stylesheet, on every page');
++console.log('status aliases: all checks passed');
ddiff --git a/theme-loader.js b/theme-loader.js
iindex 1890f65..50d232a 100644
---- a/theme-loader.js
++++ b/theme-loader.js
@@@ -348,6 +348,10 @@
// ── Display settings, one record for every OpenVibe site: display.get() → DISPLAY_OPTS' values; display.set(patch)
// applies here and in other tabs and saves to the account. <html data-ov-*> and --ov-text-scale before first paint.
const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'reduced'], text: ['100', '112', '125'], seasonal: ['on', 'off'], scheme: ['manual', 'auto'], contrast: ['normal', 'more'], font: ['default', 'readable'], colors: ['default', 'cb-safe'], oled: ['off', 'on'], icons: ['auto', 'line', 'solid'] };
++ // Status names some sites use (--error, --error-text, --ok, --warn, --accent-text) that no theme sets: they resolve to the theme's
++ // readable status colours instead of each site's fallback literal (pale on light themes). :where() keeps them
++ // below any site's own value (4.86.0).
++ const ALIAS_CSS = ':where(html){--error:var(--danger);--error-text:var(--danger-text);--ok:var(--success-text);--warn:var(--warning-text);--accent-text:var(--accent-strong)}';
// Comfort (4.45.0): one <html> attribute each, !important over the inline theme (Okabe-Ito colours, OLED black).
const COMFORT_CSS = 'html[data-ov-contrast=more]{--text-secondary:color-mix(in srgb,var(--text-primary) 88%,var(--bg-primary))!important;--text-muted:color-mix(in srgb,var(--text-primary) 76%,var(--bg-primary))!important;--border:color-mix(in srgb,var(--text-primary) 40%,var(--bg-primary))!important;--border-light:color-mix(in srgb,var(--text-primary) 55%,var(--bg-primary))!important}'
+ 'html[data-ov-contrast=more] :is(p,dd,td) a:not([class]){text-decoration-line:underline;text-underline-offset:2px}html[data-ov-contrast=more] :focus-visible{outline:3px solid var(--accent-strong,var(--accent))!important;outline-offset:2px}'
@@@ -369,7 +373,7 @@
for (const k in COMFORT_ATTRS) { const [attr, off] = COMFORT_ATTRS[k]; if (d[k] && d[k] !== off) el.setAttribute(attr, d[k]); else el.removeAttribute(attr); }
if (!document.getElementById('ov-display-css') && document.head) {
const st = document.createElement('style'); st.id = 'ov-display-css';
-- st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}html[data-ov-motion=reduced] *,html[data-ov-motion=reduced] *::before,html[data-ov-motion=reduced] *::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}html[data-ov-motion=reduced] .ov-icon .ovi-comet{display:none}' + COMFORT_CSS;
++ st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}html[data-ov-motion=reduced] *,html[data-ov-motion=reduced] *::before,html[data-ov-motion=reduced] *::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}html[data-ov-motion=reduced] .ov-icon .ovi-comet{display:none}' + ALIAS_CSS + COMFORT_CSS;
document.head.appendChild(st);
}
try { window.dispatchEvent(new CustomEvent('ov:display', { detail: d })); } catch (e) { /* */ }