openvibers/openvibe.shared Commits

4.93.0: content labels, each viewer's own choice (ov-labels.js, kit.labels) (#249)

OP OpenVibers committed 11 days ago · 7d4728877f · 11 files, +311 −7

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 41830d3..ebeba15 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,23 @@ 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.93.0 — 2026-11-02
++
++- **Content labels, each viewer's own choice** (plan L1, `common.content-labels@1`, `network.label-prefs@1`).
++ - **`ov-labels.js`**: a page marks what carries labels with `data-ov-labels="nudity,18+"`, and optionally the media
++ to cover (`data-ov-labels-media`) and the poster (`data-ov-labels-owner`). The navbar loads the file the first time
++ such an element appears, as it does `ov-glyphs.js`.
++ - It reads the person's choices once from Network (`GET /api/v1/me/label-prefs`, with the navbar's token, cached
++ five minutes) and applies them. **show**: as it is. **blur**: the media is covered by a calm button naming the
++ labels with "Show" (a playing video pauses), until tapped. **hide**: not shown.
++ - Signed out, everything shows (the web default). The poster always sees their own. Things that arrive later (a
++ feed's next page) are covered too.
++ - **`kit.labels(labels)`**: the chips beside a title, in words ("Strong language", "Flashing lights"), 18+ in the
++ warning colour. Only the labels in force: a suggestion is the creator's to confirm and is never shown to viewers.
++ **`kit.labelsAttr(labels)`** writes the matching `data-ov-labels` attribute.
++ - **`OpenVibeNavbar.session`**: who is signed in and the Network token, read only, for files loaded beside the
++ navbar.
++
## 4.92.0 — 2026-11-02
- **The page starts the footer it renders** (`shell.page`, `shell.scripts`).
ddiff --git a/README.md b/README.md
iindex ed61e98..fed5f9f 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.92.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.93.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 c7f7a0d..17da15a 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.92.0",
++ "version": "4.93.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/files.js b/files.js
iindex 8f6b922..10dd83f 100644
---- a/files.js
++++ b/files.js
@@@ -31,6 +31,7 @@ const BROWSER = Object.freeze([
'brands.js', // brand logos (Simple Icons, CC0), fetched by ov-glyphs.js the first time a page shows one (4.72.0)
'ov-look.js', // the Look panel: themes, the season, more on openvibe.design (navbar.js loads it on first open)
'ov-fx.js', // the network's effects (data-fx): glint, glow, lift, press, ripple, tilt, reveal, count, confetti
++ 'ov-labels.js', // content labels (L1): each viewer's show / blur / hide; navbar.js loads it when a page has [data-ov-labels]
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'history.js', // cross-site history
'sso-client.js', // link hand-off, FedCM
ddiff --git a/kit.css b/kit.css
iindex 5d8c3a9..41cb7c8 100644
---- a/kit.css
++++ b/kit.css
@@@ -416,3 +416,10 @@ html[data-ov-motion=reduced] .ovk-skel { animation: none; }
#navbar-mount, .openvibe-navbar { view-transition-name: ov-navbar; }
.ovnav-appbar { view-transition-name: ov-appbar; }
@media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }
++
++/* Content labels (L1): quiet chips beside a title, the adult one in the warning colour. They say what a thing carries;
++ each viewer's own choice (ov-labels.js) decides whether it shows, blurs or hides. */
++.ovk-labels { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 0; padding: 0; list-style: none; }
++.ovk-label { display: inline-flex; align-items: center; min-height: 20px; padding: 0 8px; border-radius: 999px; font-size: .7rem; font-weight: 700; letter-spacing: .02em;
++ color: var(--text-secondary, #a8b3c7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); border: 1px solid var(--border, rgba(255,255,255,.1)); }
++.ovk-label--adult { color: var(--warning-text, #fbbf24); border-color: color-mix(in srgb, var(--warning-text, #fbbf24) 45%, transparent); background: color-mix(in srgb, var(--warning-text, #fbbf24) 9%, transparent); }
ddiff --git a/kit.js b/kit.js
iindex d7d83ce..ea3427f 100644
---- a/kit.js
++++ b/kit.js
@@@ -348,6 +348,26 @@
// (axe scrollable-region-focusable, 2026-11-01 sweep).
return `<dl class="ovk-stats" aria-label="${esc(label)}" tabindex="0">${shown.map(([v, l, href]) => `<div${href ? ' class="ovk-stat--link"' : ''}><dt>${href ? `<a href="${esc(url(href))}">${esc(l)}</a>` : esc(l)}</dt><dd>${esc(fmt(v))}</dd></div>`).join('')}</dl>`;
}
++ /**
++ * Content labels (L1, common.content-labels@1) as small chips beside a title: what a stream, recording, picture or
++ * post is labelled as. Only the labels in force: an AI or viewer suggestion is the creator's to confirm, never shown
++ * to viewers. The page also marks the labelled thing itself (data-ov-labels="…") so ov-labels.js applies each
++ * viewer's own choice; these chips only say what it carries.
++ * kit.labels(['language', '18+']) or kit.labels({ labels: [...], suggested: [...] })
++ */
++ const LABEL_NAMES = { nudity: 'Nudity', language: 'Strong language', graphic: 'Graphic', '18+': '18+', flashing: 'Flashing lights', spoilers: 'Spoilers' };
++ function labels(list, { label = 'Content labels' } = {}) {
++ const arr = Array.isArray(list) ? list : (list && Array.isArray(list.labels) ? list.labels : []);
++ const shown = [...new Set(arr)].filter((l) => Object.prototype.hasOwnProperty.call(LABEL_NAMES, l));
++ if (!shown.length) return '';
++ return `<ul class="ovk-labels" aria-label="${esc(label)}">${shown.map((l) => `<li class="ovk-label${l === '18+' ? ' ovk-label--adult' : ''}" data-label="${esc(l)}">${esc(LABEL_NAMES[l])}</li>`).join('')}</ul>`;
++ }
++ /** The value for data-ov-labels on the labelled element (empty when there is none). */
++ const labelsAttr = (list) => {
++ const arr = Array.isArray(list) ? list : (list && Array.isArray(list.labels) ? list.labels : []);
++ const shown = [...new Set(arr)].filter((l) => Object.prototype.hasOwnProperty.call(LABEL_NAMES, l));
++ return shown.length ? ` data-ov-labels="${esc(shown.join(','))}"` : '';
++ };
/** What an empty list says: a title, one line, and the one thing to do next. */
const empty = ({ title = '', text = '', action: act = null, icon: ic = '' } = {}) => `<div class="ovk-empty">${ic ? icon(ic) : ''}${title ? `<p class="ovk-empty-title">${esc(title)}</p>` : ''}${text ? `<p>${esc(text)}</p>` : ''}${act ? button({ href: act.href, label: act.label, kind: act.kind || 'primary', icon: act.icon || '' }) : ''}</div>`;
/** A notice in the page: kind 'ok' | 'error' | 'info' | 'warn'; errors are announced (role=alert). */
@@@ -849,7 +869,7 @@
skeleton,
esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,
head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
-- switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,
++ switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton,
enhance, VERSION: 2,
num, steps, table, chart, rank, meter, drop, receipt, page,
toolHead, toolInput, tries, answer, family, tool,
ddiff --git a/navbar.js b/navbar.js
iindex 33279ab..90162d2 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -478,7 +478,7 @@
const NAV_ICONS_REV = 'a571e2967b16'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': 'bb9d0a92e0f2', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db' };
++ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': 'bb9d0a92e0f2', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': '1e528aa9c97b' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@@ -526,6 +526,23 @@
});
glyphWatch.observe(document.body, { childList: true, subtree: true });
}
++ // ov-labels.js (content labels, L1), when a page shows something labelled: now, or the first time one appears.
++ let labelWatch = null;
++ function loadLabels() {
++ if (root.OVLabels || document.getElementById('ov-labels-loader')) return true;
++ if (!document.querySelector('[data-ov-labels]')) return false;
++ try { const sc = document.createElement('script'); sc.id = 'ov-labels-loader'; sc.src = siblingV('ov-labels.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }
++ return true;
++ }
++ function watchLabels() {
++ if (labelWatch || !document.querySelector || loadLabels() || typeof MutationObserver !== 'function' || !document.body) return;
++ let soon = 0;
++ labelWatch = new MutationObserver(() => {
++ if (soon) return;
++ soon = setTimeout(() => { soon = 0; if (loadLabels() && labelWatch) { labelWatch.disconnect(); labelWatch = true; } }, 250);
++ });
++ labelWatch.observe(document.body, { childList: true, subtree: true });
++ }
function sibling(name) {
const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);
return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`;
@@@ -1699,7 +1716,7 @@
}
// The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome: now,
// or the first time the page's own script adds one (L54 K4: a page with no icon at first pays nothing).
-- if (_config.glyphs !== false) watchGlyphs();
++ if (_config.glyphs !== false) watchGlyphs(); watchLabels();
nav.innerHTML = `
${brandHTML(brand)}
<div class="openvibe-navbar-links">
@@@ -2189,6 +2206,8 @@
set activity(_v) { /* island.js binds itself; nothing to store */ },
setToken(token) { _config.token = token; },
++ /** Who is signed in and the token for Network (read only): for the files loaded beside the navbar (ov-labels.js). */
++ get session() { return { user: _config.user || null, token: _config.token || null, apiBase: _config.apiBase }; },
/** Open the Dock (your agents and activity), on a tab: 'agent' | 'activity'. Signed-in people only. */
openDock(tab) { if (_config.token && _config.dock !== false) openDock(tab); },
ddiff --git a/ov-labels.js b/ov-labels.js
nnew file mode 100644
iindex 0000000..a9ec331
---- /dev/null
++++ b/ov-labels.js
@@@ -0,0 +1,162 @@
++/* openvibe-shared/ov-labels.js: content labels (plan L1, 2026-10-31), each viewer's own choice applied in the page.
++ *
++ * A page marks what carries labels: data-ov-labels="nudity,18+" on a card, a player or a picture. Optionally:
++ * data-ov-labels-media on the child to cover (the thumbnail, the <video>), else the marked element itself;
++ * data-ov-labels-owner the poster's subject (usr_…): their own content always shows to them.
++ * The navbar loads this file the first time such an element appears (navbar.js, like ov-glyphs.js). It reads the
++ * person's choices once from Network (GET /api/v1/me/label-prefs, network.label-prefs@1) with the navbar's token:
++ * show as it is (the web default, and what a signed-out visitor gets);
++ * blur the media is covered by a calm "Show" button naming the labels, until tapped (a playing video pauses);
++ * hide the element is not shown.
++ * Labels are choices, never bans: nothing is removed from the page for anyone else, and nothing here asks for
++ * anything. window.OVLabels: { apply(root?), prefs(), NAMES, LABELS }.
++ */
++(function (root) {
++ 'use strict';
++ if (root.OVLabels) return;
++ const LABELS = ['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers'];
++ const NAMES = { nudity: 'Nudity', language: 'Strong language', graphic: 'Graphic', '18+': '18+', flashing: 'Flashing lights', spoilers: 'Spoilers' };
++ const SEL = '[data-ov-labels]';
++ const CACHE = 'ov_label_prefs';
++ const CACHE_MS = 5 * 60 * 1000;
++ let prefs = null;
++ let me = null;
++ let loading = null;
++
++ const CSS = `
++[data-ov-labels-state="hide"]{display:none!important}
++.ov-labels-cover{position:relative;overflow:hidden}
++.ov-labels-cover>:not(.ov-labels-gate){filter:blur(26px) saturate(.6);transform:scale(1.04)}
++.ov-labels-gate{all:unset;box-sizing:border-box;position:absolute;inset:0;z-index:3;display:grid;place-content:center;justify-items:center;gap:6px;padding:12px;cursor:pointer;text-align:center;
++ font:600 .86rem/1.3 system-ui,sans-serif;color:#fff;background:color-mix(in srgb,#05070c 46%,transparent)}
++.ov-labels-gate b{font-weight:700;font-size:.78rem;letter-spacing:.02em;opacity:.86}
++.ov-labels-gate span{display:inline-flex;align-items:center;min-height:36px;padding:0 16px;border-radius:999px;background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.28)}
++.ov-labels-gate:hover span,.ov-labels-gate:focus-visible span{background:rgba(255,255,255,.26)}
++.ov-labels-gate:focus-visible{outline:2px solid var(--accent,#60a5fa);outline-offset:-4px}
++@media (prefers-reduced-motion:reduce){.ov-labels-cover>:not(.ov-labels-gate){transform:none}}`;
++
++ function injectCSS() {
++ if (document.getElementById('ov-labels-css')) return;
++ const st = document.createElement('style');
++ st.id = 'ov-labels-css';
++ st.textContent = CSS;
++ (document.head || document.documentElement).appendChild(st);
++ }
++
++ const parse = (el) => String(el.getAttribute('data-ov-labels') || '').split(',').map((s) => s.trim()).filter((l) => LABELS.includes(l));
++
++ /** What this viewer sees for these labels: the strictest of their choices. */
++ function decide(labels, el) {
++ if (!prefs || !labels.length) return 'show';
++ const owner = el.getAttribute('data-ov-labels-owner');
++ if (owner && me && owner === me) return 'show';
++ let out = 'show';
++ for (const l of labels) {
++ if (prefs[l] === 'hide') return 'hide';
++ if (prefs[l] === 'blur') out = 'blur';
++ }
++ return out;
++ }
++
++ function cover(el, labels) {
++ const media = el.querySelector('[data-ov-labels-media]') || el;
++ if (media.querySelector(':scope > .ov-labels-gate')) return;
++ media.classList.add('ov-labels-cover');
++ if (getComputedStyle(media).position === 'static') media.style.position = 'relative';
++ const gate = document.createElement('button');
++ gate.type = 'button';
++ gate.className = 'ov-labels-gate';
++ const what = document.createElement('b');
++ what.textContent = labels.map((l) => NAMES[l]).join(' · ');
++ const show = document.createElement('span');
++ show.textContent = 'Show';
++ gate.append(what, show);
++ gate.setAttribute('aria-label', `Labelled ${what.textContent}. Show it`);
++ gate.addEventListener('click', (e) => {
++ e.preventDefault();
++ e.stopPropagation();
++ el.setAttribute('data-ov-labels-state', 'shown');
++ media.classList.remove('ov-labels-cover');
++ gate.remove();
++ });
++ media.prepend(gate);
++ const v = media.tagName === 'VIDEO' ? media : media.querySelector('video');
++ if (v && !v.paused) { try { v.pause(); } catch { /* */ } }
++ }
++
++ function uncover(el) {
++ const media = el.querySelector('[data-ov-labels-media]') || el;
++ media.classList.remove('ov-labels-cover');
++ const gate = media.querySelector(':scope > .ov-labels-gate');
++ if (gate) gate.remove();
++ }
++
++ /** Apply the choices to every marked element under root (the page by default). A revealed one stays revealed. */
++ function apply(scope) {
++ const base = scope && scope.querySelectorAll ? scope : document;
++ const list = base.matches && base.matches(SEL) ? [base] : [...base.querySelectorAll(SEL)];
++ for (const el of list) {
++ if (el.getAttribute('data-ov-labels-state') === 'shown') continue;
++ const labels = parse(el);
++ const state = decide(labels, el);
++ el.setAttribute('data-ov-labels-state', state);
++ if (state === 'blur') cover(el, labels); else uncover(el);
++ }
++ }
++
++ function readCache(subject) {
++ try {
++ const c = JSON.parse(sessionStorage.getItem(CACHE) || 'null');
++ return c && c.subject === subject && Date.now() - c.at < CACHE_MS ? c.prefs : null;
++ } catch { return null; }
++ }
++
++ async function load(session) {
++ const token = session && session.token;
++ const user = session && session.user;
++ me = user && (user.subject_id || user.subject || null);
++ if (!token) { prefs = null; apply(); return; }
++ const cached = readCache(me);
++ if (cached) { prefs = cached; apply(); return; }
++ if (loading) return loading;
++ const base = String((session && session.apiBase) || 'https://openvibe.network').replace(/\/+$/, '');
++ loading = fetch(`${base}/api/v1/me/label-prefs`, { headers: { Authorization: `Bearer ${token}` }, credentials: 'omit' })
++ .then((r) => (r.ok ? r.json() : null))
++ .then((body) => {
++ const p = body && body.prefs;
++ prefs = p && typeof p === 'object' ? p : null;
++ try { if (prefs) sessionStorage.setItem(CACHE, JSON.stringify({ subject: me, at: Date.now(), prefs })); } catch { /* */ }
++ })
++ .catch(() => { prefs = null; })
++ .finally(() => { loading = null; apply(); });
++ return loading;
++ }
++
++ function start() {
++ injectCSS();
++ // The navbar may have signed in before this file arrived (its session getter), or will say so (its event).
++ const N = root.OpenVibeNavbar;
++ const s = N && N.session;
++ if (s && s.token) load(s); else apply();
++ document.addEventListener('openvibe-navbar-auth', (e) => {
++ const d = (e && e.detail) || {};
++ try { sessionStorage.removeItem(CACHE); } catch { /* */ }
++ load({ token: d.token, user: d.user, apiBase: N && N.session && N.session.apiBase });
++ });
++ // Labelled things that arrive later (a feed's next page, a rail that loads as it scrolls).
++ if (typeof MutationObserver === 'function' && document.body) {
++ let soon = false;
++ const own = (n) => n.nodeType !== 1 || (n.classList && n.classList.contains('ov-labels-gate'));
++ new MutationObserver((records) => {
++ // Its own covers going in or out are not news.
++ if (records.every((r) => [...r.addedNodes, ...r.removedNodes].every(own))) return;
++ if (soon) return;
++ soon = true;
++ setTimeout(() => { soon = false; apply(); }, 120);
++ }).observe(document.body, { childList: true, subtree: true });
++ }
++ }
++
++ root.OVLabels = { apply, prefs: () => (prefs ? { ...prefs } : null), NAMES, LABELS };
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true }); else start();
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/package.json b/package.json
iindex d8034a8..5224723 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.92.0",
++ "version": "4.93.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -54,6 +54,7 @@
"./openvibe-sw": "./openvibe-sw.js",
"./ov-icons": "./ov-icons.js",
"./ov-glyphs": "./ov-glyphs.js",
++ "./ov-labels": "./ov-labels.js",
"./ov-look": "./ov-look.js",
"./ov-fx": "./ov-fx.js",
"./look-boot": "./look-boot.js",
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex 51b4ce0..0605b10 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -9,7 +9,7 @@ const path = require('path');
const { hashAsset } = require('../assets');
const ROOT = path.join(__dirname, '..');
--const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js'];
++const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js'];
const revs = () => Object.fromEntries(FILES.map((f) => [f, hashAsset(fs.readFileSync(path.join(ROOT, f)))]));
const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;
ddiff --git a/test/labels.test.js b/test/labels.test.js
nnew file mode 100644
iindex 0000000..fe908ee
---- /dev/null
++++ b/test/labels.test.js
@@@ -0,0 +1,77 @@
++'use strict';
++// Content labels (L1): kit.labels says what a thing carries; ov-labels.js applies each viewer's own choice from
++// Network (show, blur until tapped, hide). Signed out, everything shows; a poster always sees their own.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { parseHTML } = require('linkedom');
++const kit = require('../kit.js');
++
++// ── kit.labels ─────────────────────────────────────────────────────────────────────────────────────────────────
++assert.strictEqual(kit.labels([]), '', 'no labels, no list');
++assert.strictEqual(kit.labels({ labels: [], suggested: ['nudity'] }), '', 'a suggestion is the creator\'s to confirm: never shown');
++const chips = kit.labels(['language', '18+', 'language', 'made-up']);
++assert.ok(chips.startsWith('<ul class="ovk-labels" aria-label="Content labels">'), chips);
++assert.ok(chips.includes('<li class="ovk-label" data-label="language">Strong language</li>'), 'named in words');
++assert.ok(chips.includes('<li class="ovk-label ovk-label--adult" data-label="18+">18+</li>'), '18+ in the warning colour');
++assert.strictEqual((chips.match(/<li/g) || []).length, 2, 'no repeats, nothing outside the set');
++assert.strictEqual(kit.labelsAttr({ labels: ['nudity', 'graphic'] }), ' data-ov-labels="nudity,graphic"');
++assert.strictEqual(kit.labelsAttr(null), '');
++
++// ── ov-labels.js ───────────────────────────────────────────────────────────────────────────────────────────────
++const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-labels.js'), 'utf8');
++const PAGE = `<div id="a" data-ov-labels="nudity"><img data-ov-labels-media src="a.jpg"><h3>A</h3></div>
++<div id="b" data-ov-labels="18+"><img src="b.jpg"></div>
++<div id="c" data-ov-labels="language"><img src="c.jpg"></div>
++<div id="d" data-ov-labels="18+" data-ov-labels-owner="usr_me"><img src="d.jpg"></div>
++<div id="e"><img src="e.jpg"></div>`;
++
++async function page({ session, prefs }) {
++ // linkedom's window falls back to the process's globals: each page starts without the last one's module.
++ delete globalThis.OVLabels;
++ const { window, document, MutationObserver } = parseHTML(`<!doctype html><html><head></head><body>${PAGE}</body></html>`);
++ delete window.OVLabels;
++ const store = new Map();
++ const sessionStorage = { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) };
++ const calls = [];
++ const fetch = async (url, opts) => { calls.push({ url, auth: opts && opts.headers && opts.headers.Authorization }); return { ok: true, json: async () => ({ prefs }) }; };
++ window.OpenVibeNavbar = { session };
++ window.getComputedStyle = () => ({ position: 'static' });
++ new Function('window', 'document', 'fetch', 'sessionStorage', 'MutationObserver', 'setTimeout', 'getComputedStyle', SRC)(
++ window, document, fetch, sessionStorage, MutationObserver, setTimeout, window.getComputedStyle);
++ await new Promise((r) => setTimeout(r, 300));
++ return { window, document, calls };
++}
++
++(async () => {
++ // Signed out: nothing is asked and everything shows (the web default).
++ {
++ const { document, calls } = await page({ session: { token: null, user: null } });
++ assert.strictEqual(calls.length, 0, 'no token, no request');
++ for (const id of ['a', 'b', 'c', 'd']) assert.strictEqual(document.getElementById(id).getAttribute('data-ov-labels-state'), 'show', `${id} shows`);
++ assert.ok(!document.querySelector('.ov-labels-gate'), 'no cover');
++ }
++ // Signed in: nudity blurred, 18+ hidden, language shown; their own 18+ post shows to them.
++ {
++ const prefs = { nudity: 'blur', language: 'show', graphic: 'show', '18+': 'hide', flashing: 'show', spoilers: 'show' };
++ const { document, calls } = await page({ session: { token: 'tok', user: { subject_id: 'usr_me' }, apiBase: 'https://openvibe.network' }, prefs });
++ assert.strictEqual(calls.length, 1, 'the choices are asked once');
++ assert.strictEqual(calls[0].url, 'https://openvibe.network/api/v1/me/label-prefs');
++ assert.strictEqual(calls[0].auth, 'Bearer tok');
++ const a = document.getElementById('a');
++ assert.strictEqual(a.getAttribute('data-ov-labels-state'), 'blur');
++ const media = a.querySelector('[data-ov-labels-media]');
++ assert.ok(media.classList.contains('ov-labels-cover'), 'the marked media is covered, not the whole card');
++ const gate = media.querySelector('.ov-labels-gate');
++ assert.ok(gate && gate.textContent.includes('Nudity') && gate.textContent.includes('Show'), 'the cover names the label and offers Show');
++ assert.strictEqual(document.getElementById('b').getAttribute('data-ov-labels-state'), 'hide', '18+ hidden');
++ assert.strictEqual(document.getElementById('c').getAttribute('data-ov-labels-state'), 'show', 'language shown');
++ assert.strictEqual(document.getElementById('d').getAttribute('data-ov-labels-state'), 'show', 'the poster sees their own');
++ assert.ok(!document.getElementById('e').hasAttribute('data-ov-labels-state'), 'an unlabelled thing is untouched');
++ // Show: uncovered, and it stays uncovered when the page applies again.
++ gate.dispatchEvent(new (document.defaultView || globalThis).Event('click', { bubbles: true, cancelable: true }));
++ assert.strictEqual(a.getAttribute('data-ov-labels-state'), 'shown');
++ assert.ok(!media.classList.contains('ov-labels-cover') && !media.querySelector('.ov-labels-gate'), 'revealed');
++ }
++ console.log('labels: all passed');
++})().catch((e) => { console.error(e); process.exit(1); });