openvibers/openvibe.shared Commits
4.93.0: content labels, each viewer's own choice (ov-labels.js, kit.labels) (#249)
Changes
@@@ -4,6 +4,23 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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`).@@@ -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 deployedconsumer 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` namesthe tag it pins.@@@ -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)",@@@ -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@@@ -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); }@@@ -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,@@@ -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); },nnew file mode 100644@@@ -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);@@@ -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",@@@ -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(', ')} };`;nnew file mode 100644@@@ -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); });
@@ -4,6 +4,23 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 4.92.0 — 2026-11-02
- **The page starts the footer it renders** (`shell.page`, `shell.scripts`).
- **The page starts the footer it renders** (`shell.page`, `shell.scripts`).
@@ -2,7 +2,7 @@
@@ -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.
> 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
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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.92.0",
+ "version": "4.93.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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)",
@@ -31,6 +31,7 @@ const BROWSER = Object.freeze([
@@ -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)
'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-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-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)
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'history.js', // cross-site history
'history.js', // cross-site history
'sso-client.js', // link hand-off, FedCM
'sso-client.js', // link hand-off, FedCM
@@ -416,3 +416,10 @@ html[data-ov-motion=reduced] .ovk-skel { animation: none; }
@@ -416,3 +416,10 @@ html[data-ov-motion=reduced] .ovk-skel { animation: none; }
#navbar-mount, .openvibe-navbar { view-transition-name: ov-navbar; }
#navbar-mount, .openvibe-navbar { view-transition-name: ov-navbar; }
.ovnav-appbar { view-transition-name: ov-appbar; }
.ovnav-appbar { view-transition-name: ov-appbar; }
@media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }
@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); }
@@ -348,6 +348,26 @@
@@ -348,6 +348,26 @@
// (axe scrollable-region-focusable, 2026-11-01 sweep).
// (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>`;
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. */
/** 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>`;
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). */
/** A notice in the page: kind 'ok' | 'error' | 'info' | 'warn'; errors are announced (role=alert). */
@@ -849,7 +869,7 @@
@@ -849,7 +869,7 @@
skeleton,
skeleton,
esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,
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,
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,
enhance, VERSION: 2,
num, steps, table, chart, rank, meter, drop, receipt, page,
num, steps, table, chart, rank, meter, drop, receipt, page,
toolHead, toolInput, tries, answer, family, tool,
toolHead, toolInput, tries, answer, family, tool,
@@ -478,7 +478,7 @@
@@ -478,7 +478,7 @@
const NAV_ICONS_REV = 'a571e2967b16'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = 'a571e2967b16'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// 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
// 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
// (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">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -526,6 +526,23 @@
@@ -526,6 +526,23 @@
});
});
glyphWatch.observe(document.body, { childList: true, subtree: true });
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) {
function sibling(name) {
const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);
const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);
return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`;
return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`;
@@ -1699,7 +1716,7 @@
@@ -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,
// 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).
// 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 = `
nav.innerHTML = `
${brandHTML(brand)}
${brandHTML(brand)}
<div class="openvibe-navbar-links">
<div class="openvibe-navbar-links">
@@ -2189,6 +2206,8 @@
@@ -2189,6 +2206,8 @@
set activity(_v) { /* island.js binds itself; nothing to store */ },
set activity(_v) { /* island.js binds itself; nothing to store */ },
setToken(token) { _config.token = token; },
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. */
/** 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); },
openDock(tab) { if (_config.token && _config.dock !== false) openDock(tab); },
new file mode 100644
new file mode 100644
@@ -0,0 +1,162 @@
@@ -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);
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -54,6 +54,7 @@
@@ -54,6 +54,7 @@
"./openvibe-sw": "./openvibe-sw.js",
"./openvibe-sw": "./openvibe-sw.js",
"./ov-icons": "./ov-icons.js",
"./ov-icons": "./ov-icons.js",
"./ov-glyphs": "./ov-glyphs.js",
"./ov-glyphs": "./ov-glyphs.js",
+ "./ov-labels": "./ov-labels.js",
"./ov-look": "./ov-look.js",
"./ov-look": "./ov-look.js",
"./ov-fx": "./ov-fx.js",
"./ov-fx": "./ov-fx.js",
"./look-boot": "./look-boot.js",
"./look-boot": "./look-boot.js",
@@ -9,7 +9,7 @@ const path = require('path');
@@ -9,7 +9,7 @@ const path = require('path');
const { hashAsset } = require('../assets');
const { hashAsset } = require('../assets');
const ROOT = path.join(__dirname, '..');
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 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(', ')} };`;
const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -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); });