openvibers/openvibe.shared Commits
Shared 4.244.0: every name wears what its person wears (§Z.3) (#530)
Changes
@@@ -1,5 +1,20 @@# Changelog++## 4.244.0 — 2026-11-13++++**Every name wears what its person wears** (the owner, 11-13: "make sure user name decoration … applies everywhere").++++- **Names wear their effect, their hat and their badge everywhere.** The name of a person in a card, in a message, in a++ comment, in the menu, in the account list, in the profile, in the people picker and in the share sheet wears what that++ person wears (OpenVibe.Inventory through items.js; items.css): the effect on the letters, the hat beside the name, the++ badge before it. Before this, only the feed dressed a name.++- **One call for every renderer.** `OV.names(scope)` (also `OpenVibeNavbar.names(scope)`) gives every name under a part of++ the page what its person wears. It loads items.js once a page, and only when a name carries a subject; it never throws,++ so a name stays plain when the Inventory cannot be reached.++- **Only a real subject is used.** A name is dressed only when the data carries that person's own `usr_…` subject (a++ subject, subject_id, user_subject, author_subject or owner_subject). Nothing is invented: a renderer whose API does not++ carry a subject today leaves the name plain until that API adds it.++## 4.243.0 — 2026-11-13**One + everywhere, and a menu that knows who is looking** (the owner, 11-13: "I don't really like the plus …@@@ -5,7 +5,7 @@**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt**Install and test:** `npm ci && npm test`--**Status:** alpha, v4.243.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.244.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.243.0",++ "version": "4.244.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)",@@@ -15,6 +15,20 @@const ANON_KEY = 'openvibe_anon_token';const ANON_TOKENS_KEY = 'openvibe_anon_tokens'; // Map<anonId, sessionToken>const MAX_ACCOUNTS = 10;++ // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the session carries is what is used.++ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++ const subjectOf = (data) => {++ if (!data || typeof data !== 'object') return null;++ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {++ const v = data[key];++ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;++ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;++ }++ return null;++ };++ const subjectAttr = (data) => { const s = subjectOf(data); return s ? ` data-ov-subject="${s}"` : ''; };++ // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.++ const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };let _config = { apiBase: 'https://openvibe.network', onSwitch: null };let _panelEl = null;@@@ -101,6 +115,8 @@email: user.email || null,is_anon: isAnon,anon_number: anonNum,++ // §Z.3: the subject too, so the name wears its effect when the list is drawn again later.++ subject: subjectOf(user),token,added_at: Date.now(),};@@@ -471,7 +487,7 @@<div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}"><img src="${a.avatar_url || '/data/avatars/default.png'}" alt="" width="40" height="40" decoding="async"><div class="info">-- <div class="name">${a.display_name || a.username}</div>++ <div class="name"><span${subjectAttr(a)}>${a.display_name || a.username}</span></div><div class="sub">${a.email || `@${a.username}`}</div></div>${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove account">\u2715</span>` : ''}@@@ -508,6 +524,8 @@overlay.appendChild(panel);document.body.appendChild(overlay);_panelEl = overlay;++ // §Z.3: every name in the list wears its effect, hat and badge.++ wearNames(panel);// Wire eventspanel.querySelectorAll('.openvibe-switcher-item[data-id]').forEach(el => {@@@ -114,7 +114,7 @@// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';-- const VIBE_REV = { 'ov-composer.js': 'ce2ed7a5d349' };++ const VIBE_REV = { 'ov-composer.js': '5bd54df16c8f' };function joinComposer(form) {if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain boxform.setAttribute('data-ovc-no-files', '');@@@ -84,7 +84,7 @@}// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.-- const DOCK_REV = { 'dock-vibe.js': 'd8d4ea13d12a', 'dock-code.js': 'cb97274524d0', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '759f07eebc81', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '1b6262204e91', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };++ const DOCK_REV = { 'dock-vibe.js': 'e41403255108', 'dock-code.js': 'cb97274524d0', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '759f07eebc81', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '1b6262204e91', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };function load(src, test) {return new Promise((ok) => {if (test()) return ok(true);@@@ -334,6 +334,21 @@const scopes = () => Array.isArray(o.scopes) ? o.scopes : null;const canWriteChat = () => !scopes() || scopes().includes('chat.write');const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };++ // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the data carries is what is used.++ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++ const subjectOf = (data) => {++ if (!data || typeof data !== 'object') return null;++ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject', 'sender_subject', 'sender_subject_id']) {++ const v = data[key];++ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;++ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;++ }++ return null;++ };++ // The attribute a name wears, when the data carries a subject. Never invented.++ const setSubject = (node, data) => { const s = subjectOf(data); if (s && node && node.setAttribute) node.setAttribute('data-ov-subject', s); return node; };++ // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.++ const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };const toChat = (href) => { const a = h('a', 'ovdk-ib ovdk-away'); a.href = href; a.target = '_blank'; a.rel = 'noopener'; a.title = 'Open in Chat'; a.setAttribute('aria-label', 'Open in Chat'); a.setAttribute('data-ov-tip', 'Open in Chat'); a.innerHTML = svg('<path d="M13.5 5.5h5v5M18.5 5.5l-7.5 7.5M17 13.5v4a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 5 17.5v-9A1.5 1.5 0 0 1 6.5 7h4"/>'); return a; };async function chatCall(path, init = {}) {@@@ -376,6 +391,21 @@}const others = (c) => (c.participants || []).filter((p) => p.username !== me());const titleOf = (c) => c.name || others(c).map((p) => p.display_name || p.username).join(', ') || 'Just you';++ // §Z.3: the conversation's title with each person's name in a span of its own, so every name wears its effect.++ const titleInto = (el, c) => {++ if (!c) { el.textContent = 'Conversation'; return el; }++ if (c.name) { el.textContent = c.name; return el; }++ const people = others(c);++ if (!people.length) { el.textContent = 'Just you'; return el; }++ el.textContent = '';++ people.forEach((p, i) => {++ if (i) el.appendChild(document.createTextNode(', '));++ const s = setSubject(h('span', ''), p); s.textContent = p.display_name || p.username || '';++ el.appendChild(s);++ });++ return el;++ };++ const titleEl = (c) => titleInto(h('b', ''), c);async function chatList() {clearTimeout(chats.timer);if (chats.edits) { chats.edits.stop(); chats.edits = null; }@@@ -420,7 +450,7 @@const unread = Number(c.unread_count) || 0;const mine = (c.participants || []).some((p) => p.id === c.last_sender_id && p.username === me());const body = h('span', 'ovdk-chat-b');-- body.append(h('b', '', titleOf(c)), h('span', 'ovdk-chat-p', c.last_message ? `${mine ? 'You: ' : ''}${String(c.last_message).replace(/\s+/g, ' ').slice(0, 140)}` : 'No messages yet'));++ body.append(titleEl(c), h('span', 'ovdk-chat-p', c.last_message ? `${mine ? 'You: ' : ''}${String(c.last_message).replace(/\s+/g, ' ').slice(0, 140)}` : 'No messages yet'));const meta = h('span', 'ovdk-chat-m');meta.append(h('time', '', when(c.last_message_at || c.updated_at)));if (unread) meta.append(h('b', 'ovdk-unread', unread > 99 ? '99+' : String(unread)));@@@ -433,6 +463,8 @@scroll.append(ul);}panel.append(scroll);++ // §Z.3: every name in the conversation list wears its effect, hat and badge.++ wearNames(panel);}/** "edited", one quiet word after a message's words; when, in its title. */function editedMark(at) {@@@ -455,7 +487,11 @@function bubble(m, prev) {const mine = m.username === me();const li = h('li', `ovdk-msg${mine ? ' is-mine' : ''}${prev && prev.username === m.username ? ' is-cont' : ''}`);-- if (!mine && !(prev && prev.username === m.username)) li.append(h('span', 'ovdk-who', m.display_name || m.username || ''));++ if (!mine && !(prev && prev.username === m.username)) {++ // §Z.3: the name wears its effect, hat and badge (the message's subject, when it carries one).++ const who = setSubject(h('span', 'ovdk-who'), m); who.textContent = m.display_name || m.username || '';++ li.append(who);++ }li.dataset.id = String(m.id);const words = h('span', 'ovdk-text', String(m.message || ''));if (m.edited_at) words.append(editedMark(m.edited_at));@@@ -483,10 +519,11 @@const head = h('div', 'ovdk-thread-h');const back = h('button', 'ovdk-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your chats'); back.append(icon(G.back));back.addEventListener('click', () => { save({ chat: null }); chatList(); });-- const title = h('b', '', conv ? titleOf(conv) : 'Conversation');++ // §Z.3: the title's names wear their effect, hat and badge (each person's name in a span of its own).++ const title = titleInto(h('b', ''), conv);head.append(back, title, toChat('https://openvibe.chat/messages'));// Opened from the Dock's state on another page: the list is not here yet, so the title comes with it.-- if (!conv) chatCall('/api/dm/conversations').then((b) => { chats.list = b.conversations || []; const c = chats.list.find((x) => String(x.id) === String(id)); if (c && chats.open === String(id)) title.textContent = titleOf(c); }).catch(() => { /* the title stays */ });++ if (!conv) chatCall('/api/dm/conversations').then((b) => { chats.list = b.conversations || []; const c = chats.list.find((x) => String(x.id) === String(id)); if (c && chats.open === String(id)) titleInto(title, c); }).catch(() => { /* the title stays */ });const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);const form = h('form', 'ovdk-send');@@@ -572,6 +609,8 @@await pull(list, scroll, true);load('dock-chat-edits.js', () => !!root.OpenVibeDockChatEdits).then((ok) => { if (ok && chats.open === String(id)) chats.edits = root.OpenVibeDockChatEdits({ id: String(id), apply: (m) => applyEdit(list, m), tokenOf, chatBase, active: () => chats.open === String(id) && panelActive('chats') }); });if (canWriteChat() && !(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)) setTimeout(() => { try { box.focus({ preventScroll: true }); if (draft) box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } }, 0);++ // §Z.3: every name in the room (the title and the messages) wears its effect, hat and badge.++ wearNames(panel);}/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */async function pull(list, scroll, first) {@@@ -585,6 +624,8 @@if (first) list.textContent = '';const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;got.forEach((m) => { if (chats.msgs.some((x) => x.id === m.id)) return; list.append(bubble(m, chats.msgs[chats.msgs.length - 1])); chats.msgs.push(m); chats.last = Math.max(chats.last, Number(m.id) || 0); });++ // §Z.3: every name in the room wears its effect, hat and badge.++ wearNames(list);if (first && !chats.msgs.length) list.append(h('li', 'ovdk-none', 'No messages yet. Say hello.'));if (first || atEnd) scroll.scrollTop = scroll.scrollHeight;if (got.length || first) chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/read`, { method: 'POST', body: '{}' }).then(readUnread).catch(() => { /* */ });@@@ -47,6 +47,20 @@'use strict';const root = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : {});++ // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the session carries is what is used.++ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++ const subjectOf = (data) => {++ if (!data || typeof data !== 'object') return null;++ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {++ const v = data[key];++ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;++ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;++ }++ return null;++ };++ const subjectAttr = (data) => { const s = subjectOf(data); return s ? ` data-ov-subject="${s}"` : ''; };++ // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.++ const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };// Where this file was served from: shipped.js is loaded from the same place (the site's own// /shared/ copy, or openvibe.network), so the site's CSP already allows it.const OWN_SRC = (function () { try { return document.currentScript && document.currentScript.src ? document.currentScript.src : null; } catch { return null; } })();@@@ -361,7 +375,7 @@{ name: 'Themes', url: `${NETWORK_URL}/themes`, icon: 'themes' },{ name: 'Tools', url: 'https://openvibe.tools', icon: 'tools' },];-- return `<span class="ovf-account-who"><span class="ovf-avatar">${avatar}</span> Signed in as <strong>${name}</strong> across OpenVibe</span>++ return `<span class="ovf-account-who"><span class="ovf-avatar">${avatar}</span> Signed in as <strong${subjectAttr(user)}>${name}</strong> across OpenVibe</span><span class="ovf-account-links">${quick.map(q => `<a href="${esc(q.url)}"${/^https?:/.test(q.url) ? ' target="_blank" rel="noopener"' : ''}>${icon(q.icon)} ${esc(q.name)}</a>`).join('')}</span>`;}@@@ -389,6 +403,8 @@if (!user || !user.username) return;host.innerHTML = accountHTML(user, cfg.service || detectService());host.hidden = false;++ // §Z.3: the name in the signed-in row wears its effect, hat and badge.++ wearNames(host);}// ── Styles (injected once) ─────────────────────────────────@@@ -682,7 +698,7 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity// and the first line of what a press opens (a finger never sees a tooltip).const head = `<span class="ovf-who-dot" aria-hidden="true"></span><span class="ovf-who-n" data-ov-tip="In the last 30 minutes"${parts ? ` data-ov-tip-sub="${esc(parts)}"` : ''}><b>${fmtN(o.total)}</b> online</span>${o.people.length ? `<span class="ovf-who-faces" aria-hidden="true">${o.people.slice(0, 5).map((p) => face(p, 24)).join('')}</span>` : ''}`;const when = `<p class="ovf-who-when">In the last 30 minutes${parts ? ` · ${parts}` : ''}</p>`;-- const list = o.people.map((p) => `<li><a href="${NETWORK_URL}/@${encodeURIComponent(p.username)}">${face(p, 26)}<span>${esc(p.name)}</span>${p.site ? `<small>${esc(p.site)}</small>` : ''}</a></li>`).join('');++ const list = o.people.map((p) => `<li><a href="${NETWORK_URL}/@${encodeURIComponent(p.username)}">${face(p, 26)}<span${subjectAttr(p)}>${esc(p.name)}</span>${p.site ? `<small>${esc(p.site)}</small>` : ''}</a></li>`).join('');who = `<details class="ovf-who"><summary>${head}</summary>${when}${list ? `<ul class="ovf-who-list">${list}</ul>` : ''}${o.more > 0 ? `<p class="ovf-who-more">and ${fmtN(o.more)} more</p>` : ''}</details>`;}// Then a row of small tiles, each opening what it counts: live (only while someone is), the counts, this@@@ -693,7 +709,7 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacityconst live = o.live;if (live && live.channels > 0) {tiles.push(tile('https://openvibe.live/', ' is-live', `<span class="ovf-dot" aria-hidden="true"></span><b>${fmtN(live.channels)}</b><span>live now</span>${live.watching > 0 ? `<small>${fmtN(live.watching)} watching</small>` : ''}`));-- live.first.forEach((c) => tiles.push(tile(c.url, ' is-channel', `<span>${esc(c.name)}</span>`)));++ live.first.forEach((c) => tiles.push(tile(c.url, ' is-channel', `<span>${esc(c.name)}</span>`))); // a channel is nobody: no effect to wear}o.counts.forEach((c) => tiles.push(row(c)));o.items.forEach((i) => tiles.push(row(i)));@@@ -25,6 +25,23 @@ function safeLinkify(node, value) {}function timeValue(message) { let raw = String(message.created_at || message.at || '').replace(' ', 'T'); if (raw && !/(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)) raw += 'Z'; const d = new Date(raw); return Number.isNaN(d.getTime()) ? null : d; }function safeAvatar(value) { try { const u = new URL(String(value || '')); return u.protocol === 'https:' && !u.port && u.hostname.toLowerCase() === 'openvibe.media' ? u.href : ''; } catch { return ''; } }++// §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the message carries is what is used.++// The names carry an ovm- prefix: this file is a plain browser script, so a top-level const of a plain name would++// clash with another script's (two scripts cannot both declare the same top-level const). See navbar.js's OV.names.++const OVM_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const ovmSubjectOf = (data) => {++ if (!data || typeof data !== 'object') return null;++ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject', 'sender_subject', 'sender_subject_id']) {++ const v = data[key];++ if (typeof v === 'string' && OVM_SUBJECT_RE.test(v)) return v;++ if (v && typeof v === 'object' && typeof v.id === 'string' && OVM_SUBJECT_RE.test(v.id)) return v.id;++ }++ return null;++};++// The attribute a name wears, when the data carries a subject. Never invented.++const ovmSetSubject = (node, data) => { const s = ovmSubjectOf(data); if (s && node && node.setAttribute) node.setAttribute('data-ov-subject', s); return node; };++// Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.++const ovmWearNames = (scope) => { try { const OV = (typeof window !== 'undefined' && window.OV) || (typeof globalThis !== 'undefined' && globalThis.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stays plain */ } };/** A steady hue per person: their name in the room and their initial's circle keep one colour everywhere. */function hueOf(id) { let x = 7; for (const c of String(id || '')) x = (x * 31 + c.codePointAt(0)) % 360; return x; }function dayLabel(date, now) {@@@ -114,7 +131,9 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(if (view === 'room') {const row = document.createElement('li'); row.className = 'ovm-room'; row.dataset.id = String(message.id || '');const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(date)); } row.appendChild(time);-- const author = document.createElement('span'); author.className = 'ovm-room-author'; author.style.setProperty('--ovm-h', String(hueOf(sender))); text(author, message.display_name || message.username || 'Someone'); row.appendChild(author);++ // §Z.3: the name wears its effect, hat and badge (the message's subject, when it carries one).++ const author = ovmSetSubject(document.createElement('span'), message); author.className = 'ovm-room-author'; author.style.setProperty('--ovm-h', String(hueOf(sender))); text(author, message.display_name || message.username || 'Someone');++ row.appendChild(author);const body = document.createElement('span'); body.className = 'ovm-room-text'; safeLinkify(body, message.message || message.text); row.appendChild(body); root.appendChild(row);if (message.edited_at) edited(body, message.edited_at);const ids = typeof offer === 'function' ? offer(message, { mine }) : offer;@@@ -126,7 +145,13 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(else if (!mine && !same) { const spacer = document.createElement('span'); spacer.className = 'ovm-avatar is-initial'; spacer.setAttribute('aria-hidden', 'true'); spacer.style.setProperty('--ovm-h', String(hueOf(sender))); text(spacer, String(message.display_name || message.username || '?').trim().charAt(0).toUpperCase()); group.appendChild(spacer); }else if (!mine) { const gap = document.createElement('span'); gap.className = 'ovm-avatar is-gap'; gap.setAttribute('aria-hidden', 'true'); group.appendChild(gap); } // a run keeps the avatar's columnconst body = document.createElement('div'); body.className = 'ovm-group-body';-- if (!mine && !same) { const author = document.createElement('div'); author.className = 'ovm-author'; text(author, message.display_name || message.username || 'Someone'); body.appendChild(author); }++ if (!mine && !same) {++ const author = document.createElement('div'); author.className = 'ovm-author';++ // §Z.3: the name wears its effect, hat and badge; it sits in its own span, so the hat stands beside it.++ const who = ovmSetSubject(document.createElement('span'), message); text(who, message.display_name || message.username || 'Someone');++ author.appendChild(who);++ body.appendChild(author);++ }const bubble = document.createElement('div'); bubble.className = 'ovm-bubble';if (message.reply_to) { const quote = document.createElement('span'); quote.className = 'ovm-reply'; text(quote, message.reply_to.message || message.reply_to.text || 'Reply'); bubble.appendChild(quote); }const content = document.createElement('span'); content.className = 'ovm-text'; const value = String(message.message || message.text || '');@@@ -145,6 +170,8 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(previous = { sender, stamp };});if (offer) tapToAct(root);++ // §Z.3: every name in the room wears its effect, hat and badge.++ ovmWearNames(root);return root;}const api = { render, actions, edited, tapToAct };@@@ -624,7 +624,7 @@* serves its own pinned copy at /shared/ gets its own), else the network's copy.*/// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '7d5076132825', 'dock.js': '24539743248d', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '0f6074850291', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'f2e7c61e0934', 'soft.js': '98ab9b8080b1', 'ov-run.js': 'a9008daa5af5', 'ov-composer.js': 'ce2ed7a5d349' };++ const SIBLING_REV = { 'ov-mark.js': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': '0a397ea380cc', 'ov-share.css': 'd609a71f611a', 'search.js': '7d5076132825', 'dock.js': '8f26f280607f', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '0f6074850291', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'f2e7c61e0934', 'soft.js': '98ab9b8080b1', 'ov-run.js': 'a9008daa5af5', 'ov-composer.js': '5bd54df16c8f', 'items.js': '79a792035407', 'items.css': 'e917ec14cd4d' };const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;// ov-tip.js loads at the first hover over a tooltip's owner.@@@ -719,6 +719,66 @@} catch { /* */ }return true;}++ // §Z.3 (11-13), the owner: "make sure user name decoration … applies everywhere". One call for every renderer:++ // OV.names(scope) gives every [data-ov-subject] under the scope what its person wears (items.js; items.css). It++ // returns at once when the scope has no subject, loads items.js (and items.css) once a page the first time a name++ // needs it, and never throws: a name stays plain when anything fails.++ // A person's subject (usr_…): the only key a name effect hangs on. Never invented: what the data carries is used.++ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++ const subjectOf = (data) => {++ if (!data) return null;++ if (typeof data === 'string') return SUBJECT_RE.test(data) ? data : null;++ if (typeof data !== 'object') return null;++ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject', 'sender_subject', 'sender_subject_id']) {++ const v = data[key];++ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;++ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;++ }++ return null;++ };++ // The attribute a name wears: data-ov-subject="usr_…", or nothing when the data carries no subject.++ const subjectAttr = (data) => { const s = subjectOf(data); return s ? ` data-ov-subject="${s}"` : ''; };++ let itemsReady = null;++ /** items.css and items.js, once a page, the first time a name needs them. */++ function items(then) {++ try {++ if (!document.querySelector('link[data-ov-items-css]')) {++ const l = document.createElement('link');++ l.rel = 'stylesheet'; l.href = siblingV('items.css'); l.setAttribute('data-ov-items-css', '');++ document.head.appendChild(l);++ }++ } catch { /* names stay plain */ }++ if (root.OpenVibeItems && typeof root.OpenVibeItems.decorate === 'function') { then(); return; }++ if (!itemsReady) {++ itemsReady = new Promise((resolve) => {++ try {++ const sc = document.createElement('script');++ sc.id = 'ov-items-loader'; sc.src = siblingV('items.js'); sc.async = true;++ sc.onload = () => resolve(true); sc.onerror = () => resolve(false);++ document.head.appendChild(sc);++ } catch { resolve(false); }++ });++ }++ itemsReady.then((ok) => { if (ok) then(); });++ }++ /** Every person's name under the scope wears its effect, hat and badge (items.js; items.css). 0 when there is nothing to wear. */++ function names(scope) {++ try {++ const node = scope || document;++ if (!node || typeof node.querySelector !== 'function') return 0;++ if (!node.querySelector('[data-ov-subject]')) return 0;++ const Items = root.OpenVibeItems;++ if (Items && typeof Items.decorate === 'function') { items(() => { }); return Items.decorate(node); }++ items(() => { try { if (root.OpenVibeItems) root.OpenVibeItems.decorate(node); } catch { /* names stay plain */ } });++ return 0;++ } catch { return 0; }++ }++ // §Z.3: the names the page itself drew (a server-rendered comment, a footer's signed-in row) wear their effect too.++ if (typeof document !== 'undefined' && document.addEventListener) {++ const sweep = () => { try { names(document); } catch { /* names stay plain */ } };++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', sweep, { once: true });++ else sweep();++ }// ov-share.js and ov-share.css (4.114.0, plan track Q): the one Share sheet, fetched the first time someone presses a// [data-ov-share-sheet] button, so a site gets it by putting the button on its page and nothing loads before that.function shareClick(e) {@@@ -1701,7 +1761,8 @@if (!u || u.is_anon || u.id == null || !u.username) return;try {const seen = readList(SEEN_KEY).filter((a) => a && String(a.id) !== String(u.id));-- seen.unshift({ id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null });++ // §Z.3: the subject too, so the name wears its effect when it is drawn again later.++ seen.unshift({ id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, subject: subjectOf(u) });localStorage.setItem(SEEN_KEY, JSON.stringify(seen.slice(0, 10)));} catch { /* storage unavailable */ }}@@@ -2360,7 +2421,7 @@<div class="openvibe-navbar-dropdown-header"><a class="ud-me" href="${escapeAttr(meHref)}" tabindex="-1" aria-hidden="true">${avatarImg(u, 72, '', '')}</a><div class="info">-- <a class="name ud-me" href="${escapeAttr(meHref)}" title="${isAnon ? 'Your account' : 'Your profile'}">${escapeHtml(u.display_name || u.username)}</a>++ <a class="name ud-me" href="${escapeAttr(meHref)}" title="${isAnon ? 'Your account' : 'Your profile'}"${subjectAttr(u)}>${escapeHtml(u.display_name || u.username)}</a><div class="email">${isAnon ? `Anonymous #${u.anon_number || '?'}` : '@' + escapeHtml(u.username || '')}</div>${barWallet ? '' : menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div><div class="ud-wallet ud-vibes" id="openvibe-vibes" hidden></div>'}</div>@@@ -2371,7 +2432,7 @@</div></div>${_config.accounts === false || (!others.length && !switcher) ? '' : `<div class="openvibe-navbar-dropdown-accounts" role="group" aria-label="Switch account">-- ${others.map(a => { const t = switcher ? 'div' : 'a'; return `<${t} class="account-item" ${switcher ? `data-account-id="${escapeAttr(String(a.id))}" role="button" tabindex="0"` : `href="${escapeAttr(switchHref(a))}"`} title="Switch to ${escapeAttr(a.display_name || a.username || '')}">${avatarImg(a, 48, '', '')}<span>${escapeHtml(a.display_name || a.username || '')}${a.is_anon ? ' (anon)' : ''}</span></${t}>`; }).join('')}++ ${others.map(a => { const t = switcher ? 'div' : 'a'; return `<${t} class="account-item" ${switcher ? `data-account-id="${escapeAttr(String(a.id))}" role="button" tabindex="0"` : `href="${escapeAttr(switchHref(a))}"`} title="Switch to ${escapeAttr(a.display_name || a.username || '')}">${avatarImg(a, 48, '', '')}<span><span${subjectAttr(a)}>${escapeHtml(a.display_name || a.username || '')}</span>${a.is_anon ? ' (anon)' : ''}</span></${t}>`; }).join('')}${switcher && !isAnon ? `<div class="account-item ud-anon" data-account-id="anon" role="button" tabindex="0" title="Browse without your name"><span class="icon">${navIcon('fa-user-secret')}</span><span>Anonymous</span></div>` : ''}</div>`}<div class="openvibe-navbar-dropdown-menu">@@@ -2386,6 +2447,8 @@</div>`;dropdown.addEventListener('click', (e) => { const t = e.target.closest && e.target.closest('[data-use]'); if (t) countUse(t.getAttribute('data-use')); });++ // §Z.3: the names in the menu wear what their people wear (items.js; items.css).++ names(dropdown);// 4.188.0: not a popup under the picture. It rests in the bar, out of sight (so a site that looks for its// sign-out button or its own item still finds it), and the menu's You tab hosts it while the menu is open// (openSearch's account(); onClose brings it back).@@@ -2801,6 +2864,8 @@const OpenVibeNavbar = {/** 4.243.0 (§Z.2): who is looking, for the menu, a site's own nav, mods and apps; also OV.viewer(). */viewer() { return viewer(); },++ /** 4.244.0 (§Z.3): every person's name under the scope wears its effect, hat and badge; also OV.names(scope). */++ names(scope) { return names(scope); },/** 4.228.0: the person's Network token the site gave init() (or null), for Shared's own calls to the Network* (the composer's settings sync and Caption it). The site already put it in the page; this only reads it. */token() { return _config.token || null; },@@@ -2996,5 +3061,10 @@root.OV = root.OV || {};if (typeof root.OV.viewer !== 'function') root.OV.viewer = () => viewer();}++ // §Z.3: OV.names(scope) — every person's name under the scope wears its effect, hat and badge. It is here in the++ // browser and in Node (a site that renders names server-side asks for it the same way), so a renderer's call never++ // depends on which of the two loaded the navbar.++ root.OV = root.OV || {};++ if (typeof root.OV.names !== 'function') root.OV.names = (scope) => names(scope);})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);@@@ -585,6 +585,21 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc};const svg = (d, cls) => `<svg${cls ? ` class="${cls}"` : ''} viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };++ // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the data carries is what is used.++ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++ const subjectOf = (data) => {++ if (!data || typeof data !== 'object') return null;++ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {++ const v = data[key];++ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;++ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;++ }++ return null;++ };++ // The attribute a name wears, when the data carries a subject. Never invented.++ const setSubject = (node, data) => { const s = subjectOf(data); if (s && node && node.setAttribute) node.setAttribute('data-ov-subject', s); return node; };++ // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.++ const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };const store = {get(k) { try { return JSON.parse(root.localStorage.getItem(k) || 'null'); } catch { return null; } },set(k, v) { try { if (v == null) root.localStorage.removeItem(k); else root.localStorage.setItem(k, JSON.stringify(v)); } catch { /* private mode */ } },@@@ -1529,8 +1544,10 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovcconst av = src ? el('img', 'ovc-av') : el('span', 'ovc-av');if (src) { av.src = src; av.alt = ''; av.loading = 'lazy'; } else av.textContent = initial(o.user);av.setAttribute('aria-hidden', 'true');-- const nm = el('span', 'ovc-head-n'); nm.textContent = String(o.user.display_name || o.user.username || '');++ // §Z.3: the name wears its effect, hat and badge (the account's subject, when it carries one).++ const nm = setSubject(el('span', 'ovc-head-n'), o.user); nm.textContent = String(o.user.display_name || o.user.username || '');head.append(av, nm);++ wearNames(head);}const ta = el('textarea');ta.name = o.name || 'text'; ta.rows = p.rows; ta.maxLength = o.maxLength || p.max;@@@ -110,6 +110,21 @@const same = (a, b) => !!a && !!b && sid(a) === sid(b);const handle = (p) => (p && p.username ? `@${p.username}` : '');const nameOf = (p) => (p && (p.display_name || p.username)) || 'Someone';++ // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the data carries is what is used.++ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++ const subjectOf = (data) => {++ if (!data || typeof data !== 'object') return null;++ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {++ const v = data[key];++ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;++ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;++ }++ return null;++ };++ // The attribute a name wears, when the data carries a subject. Never invented.++ const setSubject = (node, data) => { const s = subjectOf(data); if (s && node && node.setAttribute) node.setAttribute('data-ov-subject', s); return node; };++ // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.++ const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };function when(iso, withYear) {const d = new Date(iso);if (!iso || isNaN(d)) return '';@@@ -559,6 +574,8 @@box.appendChild(foot(st));box.appendChild(el('p', `ov-share-toast${st.toastWords ? ' is-on' : ''}${st.toastQuiet ? ' is-quiet' : ''}`, { role: 'status', 'aria-live': 'polite' }, st.toastWords || ''));st.dlg.replaceChildren(box);++ // §Z.3: every name in the sheet wears its effect, hat and badge.++ wearNames(st.dlg);// A panel just opened (QR code, Embed, Mastodon) comes into view in the sheet's scroll.if (st.panel && st.panel !== st.shownPanel) {const pnl = st.dlg.querySelector('.ov-share-panel');@@@ -737,7 +754,8 @@const ul = el('ul', 'ov-share-fps', { 'aria-label': 'Fingerprints' });for (const m of known) {const li = el('li', 'ov-share-fprow');-- li.appendChild(el('span', 'ov-share-fpwho', null, handle(m) || nameOf(m)));++ // §Z.3: the person's name in the sheet wears its effect, hat and badge (the member's subject).++ li.appendChild(setSubject(el('span', 'ov-share-fpwho', null, handle(m) || nameOf(m)), m));li.appendChild(el('span', 'ov-share-fp', null, m.key_fingerprint));if (m.fingerprint_changed) li.appendChild(el('span', 'ov-share-tag is-warn', null, 'key changed'));ul.appendChild(li);@@@ -954,7 +972,9 @@li.appendChild(avatar(p));const words = el('div', 'ov-share-words');const top = el('div', 'ov-share-name');-- top.appendChild(el('span', 'ov-share-display', null, nameOf(p)));++ // §Z.3: the name wears its effect, hat and badge (the member's subject, when it carries one).++ const shown = setSubject(el('span', 'ov-share-display', null, nameOf(p)), p);++ top.appendChild(shown);if (x.you || (st.server && st.server.you && p.subject && same(p.subject, st.server.you.subject))) top.appendChild(el('span', 'ov-share-tag', null, 'You'));if (x.member && x.member.isNew) top.appendChild(el('span', 'ov-share-tag is-new', null, 'New'));words.appendChild(top);@@@ -1029,7 +1049,11 @@const li = el('li', 'ov-share-row');li.appendChild(avatar(r));const words = el('div', 'ov-share-words');-- words.appendChild(el('div', 'ov-share-name', null, nameOf(r)));++ // §Z.3: the name wears its effect, hat and badge; it sits in its own span, so the hat stands beside it.++ const asked = el('div', 'ov-share-name');++ const askedName = setSubject(el('span', null, null, nameOf(r)), r);++ asked.appendChild(askedName);++ words.appendChild(asked);const sub = el('div', 'ov-share-sub');if (r.username) sub.appendChild(el('span', null, null, handle(r)));if (r.requested_at) sub.appendChild(el('span', null, null, `asked ${when(r.requested_at)}`));@@@ -1057,7 +1081,8 @@b.appendChild(iconEl('give'));const p = el('p');p.appendChild(doc.createTextNode('Waiting for '));-- p.appendChild(el('strong', null, null, handle(t.to) || nameOf(t.to)));++ // §Z.3: the person's name wears its effect, hat and badge (the subject the offer carries, when it has one).++ p.appendChild(setSubject(el('strong', null, null, handle(t.to) || nameOf(t.to)), t.to));p.appendChild(doc.createTextNode(` to accept ownership${t.expires_at ? `. The offer ends ${when(t.expires_at)}` : ''}.`));b.appendChild(p);const c = el('button', 'ov-share-btn is-small is-quiet', { type: 'button', 'data-k': 'cancel-give' }, 'Cancel offer');@@@ -1257,7 +1282,8 @@if (state === 'accepted') p.textContent = 'It is yours now. You decide who else can open it.';else if (state === 'declined') p.textContent = 'You declined. Nothing changed.';else {-- p.appendChild(el('strong', null, null, handle(o.from) || nameOf(o.from)));++ // §Z.3: the person's name wears its effect, hat and badge (the subject the offer carries, when it has one).++ p.appendChild(setSubject(el('strong', null, null, handle(o.from) || nameOf(o.from)), o.from));p.appendChild(doc.createTextNode(` wants to give you ${o.title ? `“${o.title}”` : 'this'}. `));p.appendChild(doc.createTextNode(o.keep_access ? 'They would keep view access. ' : 'They would lose access. '));if (o.expires_at) p.appendChild(doc.createTextNode(`The offer ends ${when(o.expires_at)}.`));@@@ -24,7 +24,7 @@// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);-- const THREAD_REV = { 'ov-composer.js': 'ce2ed7a5d349', 'ov-combo.js': '8001a3b19c2f' };++ const THREAD_REV = { 'ov-composer.js': '5bd54df16c8f', 'ov-combo.js': '8001a3b19c2f' };var selfSrc = (document.currentScript && document.currentScript.src) || '';// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.function loadCombo() {@@@ -255,6 +255,10 @@};root.OVThread = api;++ // §Z.3: every person's name under the scope wears its effect, hat and badge (items.js; items.css). Never throws.++ function wearNames(scope) {++ try { if (root.OV && typeof root.OV.names === 'function') root.OV.names(scope || document); } catch (e) { /* names stay plain */ }++ }function start() {loadCombo();qa(document, '[data-ovt], [data-ovc-thread]').forEach(function (box) {@@@ -479,6 +483,8 @@if (sbBtn) navLead(box, sbBtn, 0);enhance(box);++ // §Z.3 (11-13): the names the server drew (the comment authors) wear their effect, hat and badge.++ wearNames(box);if (!location.hash && q(box, '[data-ovt-thread] > li')) toBottom(false);});}@@@ -76,6 +76,21 @@ html[data-ov-motion=off] .ovu-live::before, html[data-ov-motion=off] .ovu-wait i}const mounts = new WeakMap();const bindings = new WeakMap();++ // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the card carries is what is used.++ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++ const subjectOf = (data) => {++ if (!data || typeof data !== 'object') return null;++ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {++ const v = data[key];++ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;++ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;++ }++ return null;++ };++ // The attribute a name wears, when the data carries a subject. Never invented.++ const setSubject = (node, data) => { const s = subjectOf(data); if (s && node && node.setAttribute) node.setAttribute('data-ov-subject', s); return node; };++ // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.++ const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };const usernameOf = value => typeof value === 'string' && /^[A-Za-z0-9_]{3,24}$/.test(value.replace(/^@/, '')) ? value.replace(/^@/, '') : null;function safeUrl(value) {try {@@@ -111,10 +126,17 @@ html[data-ov-motion=off] .ovu-live::before, html[data-ov-motion=off] .ovu-wait iif (avatar) { const img = doc.createElement('img'); img.src = avatar; img.alt = ''; img.decoding = 'async'; img.addEventListener('error', () => { img.remove(); face.textContent = person.username[0].toUpperCase(); }); face.appendChild(img); }else face.textContent = person.username[0].toUpperCase();const names = element(doc, 'div', undefined, 'ovu-names');-- names.appendChild(element(doc, 'h2', typeof person.display_name === 'string' && person.display_name.trim() ? person.display_name : person.username, 'ovu-name'));++ // §Z.3: the name wears its effect, hat and badge. The name text sits in its own span, so the hat stands beside++ // the name (not on a line of its own) and the heading keeps its own block.++ const title = element(doc, 'h2', undefined, 'ovu-name');++ const who = setSubject(element(doc, 'span', typeof person.display_name === 'string' && person.display_name.trim() ? person.display_name : person.username), person);++ title.appendChild(who);++ names.appendChild(title);names.appendChild(element(doc, 'p', `@${person.username}`, 'ovu-handle'));head.append(face, names);section.appendChild(head);++ // §Z.3: the name in the card wears its effect, hat and badge.++ wearNames(section);if (live) {const a = link(doc, 'Live now', person.live.url);if (a) { a.className = 'ovu-live'; if (typeof person.live.where === 'string' && person.live.where) a.appendChild(element(doc, 'small', `on ${person.live.where}`)); section.appendChild(a); }@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.243.0",++ "version": "4.244.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.243.0",++ "version": "4.244.0","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.243.0",++ "version": "4.244.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -27,6 +27,22 @@const api = () => String((root.OpenVibeProfilePanelConfig && root.OpenVibeProfilePanelConfig.api) || 'https://openvibe.network').replace(/\/+$/, '');const seen = new Map(); // username \u2192 Promise(profile | null), once a page++ // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the profile carries is what is used.++ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++ const subjectOf = (data) => {++ if (!data || typeof data !== 'object') return null;++ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {++ const v = data[key];++ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;++ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;++ }++ return null;++ };++ // The attribute a name wears, when the data carries a subject. Never invented.++ const setSubject = (node, data) => { const s = subjectOf(data); if (s && node && node.setAttribute) node.setAttribute('data-ov-subject', s); return node; };++ // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.++ const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };++function load(username) {if (!seen.has(username)) {// At most eight seconds, and a failure is not kept: a request held while Network restarted used to stay in@@@ -100,10 +116,14 @@pic.alt = ''; pic.width = 48; pic.height = 48;head.appendChild(pic);const who = el('span', 'ovpp-who');-- who.appendChild(el('b', null, p.display_name || p.username));++ // §Z.3: the name wears its effect, hat and badge (the profile's subject, when it carries one).++ const nameEl = setSubject(el('b', null, p.display_name || p.username), p);++ who.appendChild(nameEl);who.appendChild(el('small', null, `@${p.username}`));head.appendChild(who);card.appendChild(head);++ // §Z.3: the name in the panel wears its effect, hat and badge.++ wearNames(card);if (p.private) {card.appendChild(el('p', 'ovpp-note', 'This profile is private.'));target.replaceChildren(card);@@@ -27,7 +27,10 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov// 2026-11-10: soft pages (intent prefetch, cross-document view transitions); not loaded by navbar.js yet.'soft.js',// 4.224.0: Vibe's runs in the menu, and the one composer they write with.-- 'ov-run.js', 'ov-composer.js'];++ 'ov-run.js', 'ov-composer.js',++ // 4.244.0 (§Z.3): what people wear on their names (items.js; items.css), loaded by the navbar the first time a page++ // draws a name that carries a subject.++ 'items.js', 'items.css'];// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-code.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js', 'dock-chat-edits.js'] },'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },nnew file mode 100644@@@ -0,0 +1,186 @@++'use strict';++// §Z.3 (11-13), the owner: "make sure user name decoration … applies everywhere". A person's name wears what their++// person wears (OpenVibe.Inventory through items.js; items.css) — their effect, their hat, their badge — wherever++// Shared draws the name: a card, a message, a comment, the menu, the account list, the share sheet.++//++// Two halves:++// 1. a sweep over every renderer that draws a person's name: each place that writes a display_name/username into++// markup must set data-ov-subject on that same element, so the name can be found and dressed. The few places++// that are not a name (an initial letter, an @handle, a comparison, a key) are in a skip list, one reason each.++// 2. the behaviour of the one call every renderer makes: OV.names(scope) loads items.js once a page and calls++// OpenVibeItems.decorate(scope); with no subject under the scope it does nothing at all, and it never throws.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const ROOT = path.join(__dirname, '..');++++// ── 1. the sweep ────────────────────────────────────────────────────────────────────────────────────────────────────++// Every renderer that draws a person's name.++const FILES = [++ 'navbar.js', 'ov-user.js', 'user-card.js', 'profile-panel.js', 'messages.js', 'thread.js', 'search.js',++ 'dock.js', 'dock-share.js', 'ov-share.js', 'account-switcher.js', 'footer.js', 'ov-composer.js', 'ov-thread.js',++];++// A line that writes a person's name (or a handle) into markup.++const NAME = /display_name|\busername\b|\bname\(c\)|\bnameOf\(|\w*NameMarkup\(/;++const WRITES = /(textContent|innerHTML|outerHTML|appendChild|insertAdjacentHTML|\btext\(|\bel\(|\belement\(|\bh\(|\bhtml\(|\bsetSubject\(|\bovmSetSubject\(|\bsubjectAttr\(|\w*NameMarkup\()/;++// The same element carrying its person's subject.++const SUBJECT = /(data-ov-subject|\bsetSubject\(|\bovmSetSubject\(|\bsubjectAttr\(|\w*NameMarkup\()/;++// The places that write something of a person that is not the name: each with its reason.++const SKIP = [++ { file: 'ov-user.js', at: /person\.username\[0\]/, reason: 'the picture\'s initial letter — a letter, not the name' },++ { file: 'ov-user.js', at: /@\$\{person\.username\}/, reason: 'the @handle under the name — a handle, not the name' },++ { file: 'profile-panel.js', at: /@\$\{p\.username\}/, reason: 'the @handle under the name — a handle, not the name' },++ { file: 'messages.js', at: /message\.display_name \|\| message\.username \|\| '\?'/, reason: 'the picture\'s initial letter — a letter, not the name' },++ { file: 'dock.js', at: /prev\.username === m\.username/, reason: 'a comparison that picks a class, not a name in markup' },++ { file: 'dock-share.js', at: /found\[0\]\.username/, reason: 'a sentence that names a handle to say nobody is called that' },++ { file: 'ov-share.js', at: /s\.owner\.username/, reason: 'the owner\'s handle in a line of the sheet' },++ { file: 'ov-share.js', at: /sugg-\$\{p\.username\}/, reason: 'a chip\'s key — the handle as a key' },++ { file: 'ov-share.js', at: /give-\$\{p\.username\}/, reason: 'a chip\'s key — the handle as a key' },++ { file: 'ov-share.js', at: /handle\(p\)|handle\(r\)/, reason: 'the @handle under the name — a handle, not the name' },++];++++// A label is not a name people read: an aria-label, a title, an alt or a placeholder says the name to a machine.++const LABEL = (line) => line++ .replace(/'aria-label':\s*`[^`]*`/g, '')++ .replace(/setAttribute\(\s*'aria-label',\s*`[^`]*`\s*\)/g, '')++ .replace(/\baria-label="[^"]*"/g, '')++ .replace(/\btitle="[^"]*"/g, '')++ .replace(/\balt="[^"]*"/g, '')++ .replace(/\bplaceholder="[^"]*"/g, '');++++for (const file of FILES) {++ const src = fs.readFileSync(path.join(ROOT, file), 'utf8');++ const lines = src.split('\n');++ let names = 0;++ lines.forEach((line, i) => {++ const t = line.trim();++ if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return; // a comment says nothing into markup++ const said = LABEL(line); // labels are not names people read++ if (!NAME.test(said) || !WRITES.test(said)) return;++ const where = `${file}:${i + 1}`;++ const skip = SKIP.find((s) => s.file === file && s.at.test(line));++ if (skip) {++ assert.ok(skip.reason && skip.reason.length > 10, `${where}: a skip without a reason`);++ return;++ }++ assert.ok(SUBJECT.test(line), `${where}: a person's name is written into markup without data-ov-subject — ${t.slice(0, 120)}`);++ names++;++ });++ // A renderer that draws people's names must actually dress them (never lose the tagging by accident).++ if (['search.js', 'dock-share.js', 'ov-thread.js'].includes(file)) continue; // these draw no person's name++ assert.ok(names > 0, `${file}: no name carries a subject any more`);++}++++// The sweep knows what it reads: a file that disappears is a failure, not a silent pass.++assert.ok(fs.existsSync(path.join(ROOT, 'items.js')), 'items.js is what dresses a name');++assert.ok(fs.existsSync(path.join(ROOT, 'items.css')), 'items.css is what draws the effect, the hat and the badge');++++// ── 2. the behaviour: OV.names(scope) ─────────────────────────────────────────────────────────────────────────++// The stub style of test/viewer-model.test.js: a browser made of plain objects, no network.++function stubElement(tag) {++ return {++ tagName: tag || '', style: {}, dataset: {}, attrs: {}, children: [], className: '', id: '', innerHTML: '', textContent: '',++ classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },++ setAttribute(k, v) { this.attrs[k] = String(v); }, getAttribute(k) { return this.attrs[k] ?? null; },++ appendChild(c) { this.children.push(c); return c; }, prepend(c) { this.children.unshift(c); }, remove() {},++ addEventListener() {}, removeEventListener() {}, dispatchEvent() {},++ querySelector() { return null; }, querySelectorAll() { return []; },++ };++}++function load() {++ const scripts = [];++ const links = [];++ global.location = { hostname: 'openvibe.games', href: 'https://openvibe.games/', pathname: '/', protocol: 'https:' };++ global.window = { location: global.location };++ try { Object.defineProperty(global, 'navigator', { value: { userAgent: 'test' }, configurable: true }); } catch { /* */ }++ const html = stubElement('html');++ html.style = { setProperty() {}, removeProperty() {} };++ global.document = {++ cookie: '', title: 'T', readyState: 'complete', visibilityState: 'visible', documentElement: html, body: stubElement('body'),++ head: { appendChild(n) { if (String(n.tagName).toLowerCase() === 'script') scripts.push(n); else links.push(n); return n; } },++ getElementById: () => null,++ querySelector: (sel) => (sel === 'link[data-ov-items-css]' ? (links[0] || null) : null),++ querySelectorAll: () => [],++ createElement: (tag) => stubElement(tag),++ addEventListener() {}, removeEventListener() {}, dispatchEvent() {},++ };++ global.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i && i.detail; } };++ global.localStorage = { getItem: () => null, setItem() {}, removeItem() {} };++ global.sessionStorage = { getItem: () => null, setItem() {}, removeItem() {} };++ global.matchMedia = (q) => ({ matches: false });++ global.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) });++ delete global.OV;++ delete global.OpenVibeItems;++ delete require.cache[require.resolve(path.join(ROOT, 'navbar.js'))];++ const navbar = require(path.join(ROOT, 'navbar.js'));++ return { navbar, scripts, links };++}++const tick = () => new Promise((resolve) => setImmediate(resolve));++// A scope: a part of a page. `withSubject` says whether a name with a subject is under it.++const scope = (withSubject) => ({++ querySelector: (sel) => (withSubject && sel === '[data-ov-subject]' ? {} : null),++ querySelectorAll: () => [],++});++++(async () => {++ // A scope with no subject: nothing is dressed, and items.js is not even fetched.++ {++ const { navbar, scripts } = load();++ let dressed = 0;++ global.OpenVibeItems = { decorate: () => { dressed++; return 0; } };++ assert.strictEqual(navbar.names(scope(false)), 0, 'a scope with no subject dresses nothing');++ assert.strictEqual(dressed, 0, 'decorate is not called for a scope with no subject');++ assert.strictEqual(scripts.filter((s) => /items\.js/.test(s.src)).length, 0, 'items.js is not loaded for a scope with no subject');++ }++++ // A scope with a subject: items.js is loaded once, and decorate is called with the scope.++ {++ const { navbar, scripts, links } = load();++ const seen = [];++ global.OpenVibeItems = undefined; // items.js has not arrived yet++ const one = scope(true), two = scope(true);++ assert.strictEqual(navbar.names(one), 0, 'the names wait for items.js');++ assert.strictEqual(navbar.names(two), 0, 'the names wait for items.js');++ const loaded = scripts.filter((s) => /items\.js/.test(s.src));++ assert.strictEqual(loaded.length, 1, 'items.js is loaded once a page, not once a name');++ assert.ok(links.some((l) => /items\.css/.test(l.href)), 'items.css is loaded beside it');++ global.OpenVibeItems = { decorate: (s) => { seen.push(s); return 1; } };++ loaded[0].onload();++ await tick();++ assert.deepStrictEqual(seen, [one, two], 'decorate is called with the scope that was asked for');++ }++++ // items.js is already there: the names are dressed at once, with no second copy of the script.++ {++ const { navbar, scripts } = load();++ const seen = [];++ global.OpenVibeItems = { decorate: (s) => { seen.push(s); return 1; } };++ const one = scope(true);++ assert.strictEqual(navbar.names(one), 1, 'the names are dressed at once');++ assert.deepStrictEqual(seen, [one], 'decorate is called with the scope');++ assert.strictEqual(scripts.filter((s) => /items\.js/.test(s.src)).length, 0, 'items.js is not loaded again');++ }++++ // The one call every renderer makes: OV.names(scope) — the same thing, under its short name.++ {++ const { navbar } = load();++ assert.strictEqual(typeof navbar.names, 'function', 'OpenVibeNavbar.names(scope) is the one call');++ assert.strictEqual(typeof global.OV.names, 'function', 'OV.names(scope) is the one call');++ const seen = [];++ global.OpenVibeItems = { decorate: (s) => { seen.push(s); return 1; } };++ const one = scope(true);++ global.OV.names(one);++ assert.deepStrictEqual(seen, [one], 'OV.names decorates the scope');++ }++++ // It never throws: a name stays plain when anything fails.++ {++ const { navbar } = load();++ global.OpenVibeItems = { decorate: () => { throw new Error('the inventory is down'); } };++ assert.doesNotThrow(() => navbar.names(scope(true)), 'a failing inventory leaves the name plain');++ assert.doesNotThrow(() => navbar.names(null), 'no scope is no problem');++ assert.doesNotThrow(() => navbar.names({}), 'a scope without a query is no problem');++ }++++ console.log('names-decorated: ok');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');const ROOT = path.join(__dirname, '..');const NAVBAR_PATH = path.join(ROOT, 'navbar.js');const ICONS = require('../nav-icons');--const NAVBAR_BROTLI_BUDGET = 53.6 * 1024; // 53.3 KB with the viewer model (4.243.0, 2026-11-13, plan §Z.2: OV.viewer(), data-ov-viewer and the openvibe-viewer event); before, 52.3 KB with the menu button drawn from the menu (4.240.0, 2026-11-13: the owner's "regenerate this icon … based on what the menu actually looks like … glow"); before: // 51.6 KB with menu addresses (data-ov-menu, #ov-menu=, menuAddress; 4.229.0, 2026-11-12: the owner's "other tools and buttons open specific areas of the menu"; budgets grow for a feature); before that: 50.9 KB with Back to the menu, the links' warm-up and token() (4.229.0, 2026-11-12: the owner's back button and fast links; budgets grow for a feature); before that: 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)++const NAVBAR_BROTLI_BUDGET = 54.4 * 1024; // 54.2 KB with the names wearing what their people wear (4.244.0, 2026-11-13, plan §Z.3: OV.names(scope) and the items loader); before: 53.3 KB with the viewer model (4.243.0, 2026-11-13, plan §Z.2: OV.viewer(), data-ov-viewer and the openvibe-viewer event); before, 52.3 KB with the menu button drawn from the menu (4.240.0, 2026-11-13: the owner's "regenerate this icon … based on what the menu actually looks like … glow"); before: // 51.6 KB with menu addresses (data-ov-menu, #ov-menu=, menuAddress; 4.229.0, 2026-11-12: the owner's "other tools and buttons open specific areas of the menu"; budgets grow for a feature); before that: 50.9 KB with Back to the menu, the links' warm-up and token() (4.229.0, 2026-11-12: the owner's back button and fast links; budgets grow for a feature); before that: 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)// ── size budget and generated block ──────────────────────────{@@@ -309,6 +309,23 @@ const safeHttps = (value) => { try { const url = new URL(String(value)); returnconst author = (c) => c.author || c;const username = (c) => author(c).username || c.username || '';const name = (c) => author(c).display_name || c.display_name || username(c) || c.anon_name || 'Member';++// §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the answer carries is what is used.++const OVC_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const ovcSubjectOf = (c) => {++ if (!c || typeof c !== 'object') return null;++ const a = author(c) || {};++ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {++ const v = a[key] != null ? a[key] : c[key];++ if (typeof v === 'string' && OVC_SUBJECT_RE.test(v)) return v;++ if (v && typeof v === 'object' && typeof v.id === 'string' && OVC_SUBJECT_RE.test(v.id)) return v.id;++ }++ return null;++};++// §Z.3: the name wears its effect, hat and badge. The name text sits in its own span, so the hat stands beside it.++const ovcNameMarkup = (who, words) => {++ const subject = ovcSubjectOf(who);++ return `<span${subject ? ` data-ov-subject="${subject}"` : ''}>${esc(words)}</span>`;++};const date = (iso) => {const ms = Date.parse(String(iso || ''));if (!Number.isFinite(ms)) return { short: '', full: '' };@@@ -368,7 +385,7 @@ function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowedconst when = date(c.created_at);const targetId = ID.test(String(c.reply_to_id || '')) ? String(c.reply_to_id) : null;const topId = root || id;-- const target = targetId && c.reply_to ? `<a class="ovc-target" href="${query(base, sort, { replies: topId }, `comment-${targetId}`)}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="14" height="14" style="margin-right:4px;vertical-align:-2px"><path d="M9.2 5.8L4 11l5.1 5.1M4 11h10.3a6.2 6.2 0 0 1 6.2 6.2v1"/></svg>@${esc(c.reply_to.display_name || 'Member')}</a>` : '';++ const target = targetId && c.reply_to ? `<a class="ovc-target" href="${query(base, sort, { replies: topId }, `comment-${targetId}`)}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="14" height="14" style="margin-right:4px;vertical-align:-2px"><path d="M9.2 5.8L4 11l5.1 5.1M4 11h10.3a6.2 6.2 0 0 1 6.2 6.2v1"/></svg>@${ovcNameMarkup(c.reply_to, c.reply_to.display_name || 'Member')}</a>` : '';const controls = !gone ? `<div class="ovc-actions">${signedIn ? `<a class="ovk-btn ovk-btn--quiet" href="${query(base, sort, { reply: id }, `comment-${id}`)}" data-ovc-reply="${id}">Reply</a>` : ''}${c.can_delete && signedIn ? `<form method="post" action="${esc(remove)}"><input type="hidden" name="comment_id" value="${id}"><button class="ovk-btn ovk-btn--quiet" type="submit">Delete</button></form>` : ''}</div>` : '';let replies = '';if (!root) {@@@ -376,7 +393,7 @@ function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowedconst more = Math.max(0, Number(c.reply_count || 0) - rows.length);replies = `<div class="ovc-replies" data-ovc-replies="${id}">${rows.map((r) => comment(r, id)).join('')}${more ? `<a class="ovc-more ovk-btn ovk-btn--quiet" data-ovc-more="${id}" href="${query(base, sort, { replies: id })}">${more} more ${more === 1 ? 'reply' : 'replies'}</a>` : ''}</div>`;}-- return `<article class="ovc-comment" id="comment-${id}"><header>${avatar ? `<img src="${esc(avatar)}" alt="" width="32" height="32" loading="lazy">` : ''}${profile ? `<a href="${profile}">${esc(name(c))}</a>` : `<span>${esc(name(c))}</span>`}${when.short ? `<time datetime="${esc(c.created_at)}" title="${esc(when.full)}">${esc(when.short)}</time>` : ''}</header>${target}${gone ? '<p>Comment removed</p>' : `<p>${esc(c.message || '')}</p>${attachments(c)}`}${controls}${validReply === id ? `<div data-ovc-composer-slot>${composer_html(id)}</div>` : ''}${replies}</article>`;++ return `<article class="ovc-comment" id="comment-${id}"><header>${avatar ? `<img src="${esc(avatar)}" alt="" width="32" height="32" loading="lazy">` : ''}${profile ? `<a href="${profile}">${ovcNameMarkup(c, name(c))}</a>` : ovcNameMarkup(c, name(c))}${when.short ? `<time datetime="${esc(c.created_at)}" title="${esc(when.full)}">${esc(when.short)}</time>` : ''}</header>${target}${gone ? '<p>Comment removed</p>' : `<p>${esc(c.message || '')}</p>${attachments(c)}`}${controls}${validReply === id ? `<div data-ovc-composer-slot>${composer_html(id)}</div>` : ''}${replies}</article>`;};const hasReplySlot = validReply && comments.some((c) => String(c.id) === validReply || (validExpanded === String(c.id) && Array.isArray(expandedReplies) ? expandedReplies : c.replies || []).some((r) => String(r.id) === validReply));// Top and Newest only when there is something to put in order.@@@ -14,6 +14,20 @@let _cache = new Map();const _menuItems = new Map();const escapeHtml = value => String(value == null ? '' : value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);++ // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the card carries is what is used.++ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++ const subjectOf = (data) => {++ if (!data || typeof data !== 'object') return null;++ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {++ const v = data[key];++ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;++ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;++ }++ return null;++ };++ const subjectAttr = (data) => { const s = subjectOf(data); return s ? ` data-ov-subject="${s}"` : ''; };++ // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.++ const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };function injectStyles() {if (document.getElementById('openvibe-usercard-styles')) return;@@@ -155,7 +169,7 @@<img class="openvibe-usercard-avatar" src="${avatarUrl}" alt="${user.username}" width="56" height="56" decoding="async" onerror="this.style.display='none'"><div class="openvibe-usercard-body"><div class="openvibe-usercard-name">-- <span class="${nameClass}" style="${nameColor}">${user.display_name || user.username}</span>++ <span class="${nameClass}" style="${nameColor}"${subjectAttr(user)}>${escapeHtml(user.display_name || user.username)}</span>${roleBadge}</div><div class="openvibe-usercard-username">@${user.username}${data.anon_number ? ` · Anon #${data.anon_number}` : ''}</div>@@@ -180,6 +194,8 @@document.body.appendChild(overlay);document.body.appendChild(card);_cardEl = { card, overlay };++ // §Z.3: the name in the card wears its effect, hat and badge.++ wearNames(card);// Action handlerscard.querySelectorAll('[data-act]').forEach(btn => {
@@ -1,5 +1,20 @@
@@ -1,5 +1,20 @@
# Changelog
# Changelog
+## 4.244.0 — 2026-11-13
+
+**Every name wears what its person wears** (the owner, 11-13: "make sure user name decoration … applies everywhere").
+
+- **Names wear their effect, their hat and their badge everywhere.** The name of a person in a card, in a message, in a
+ comment, in the menu, in the account list, in the profile, in the people picker and in the share sheet wears what that
+ person wears (OpenVibe.Inventory through items.js; items.css): the effect on the letters, the hat beside the name, the
+ badge before it. Before this, only the feed dressed a name.
+- **One call for every renderer.** `OV.names(scope)` (also `OpenVibeNavbar.names(scope)`) gives every name under a part of
+ the page what its person wears. It loads items.js once a page, and only when a name carries a subject; it never throws,
+ so a name stays plain when the Inventory cannot be reached.
+- **Only a real subject is used.** A name is dressed only when the data carries that person's own `usr_…` subject (a
+ subject, subject_id, user_subject, author_subject or owner_subject). Nothing is invented: a renderer whose API does not
+ carry a subject today leaves the name plain until that API adds it.
+
## 4.243.0 — 2026-11-13
## 4.243.0 — 2026-11-13
**One + everywhere, and a menu that knows who is looking** (the owner, 11-13: "I don't really like the plus …
**One + everywhere, and a menu that knows who is looking** (the owner, 11-13: "I don't really like the plus …
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.243.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.244.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.243.0",
+ "version": "4.244.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)",
@@ -15,6 +15,20 @@
@@ -15,6 +15,20 @@
const ANON_KEY = 'openvibe_anon_token';
const ANON_KEY = 'openvibe_anon_token';
const ANON_TOKENS_KEY = 'openvibe_anon_tokens'; // Map<anonId, sessionToken>
const ANON_TOKENS_KEY = 'openvibe_anon_tokens'; // Map<anonId, sessionToken>
const MAX_ACCOUNTS = 10;
const MAX_ACCOUNTS = 10;
+ // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the session carries is what is used.
+ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+ const subjectOf = (data) => {
+ if (!data || typeof data !== 'object') return null;
+ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {
+ const v = data[key];
+ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;
+ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;
+ }
+ return null;
+ };
+ const subjectAttr = (data) => { const s = subjectOf(data); return s ? ` data-ov-subject="${s}"` : ''; };
+ // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.
+ const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };
let _config = { apiBase: 'https://openvibe.network', onSwitch: null };
let _config = { apiBase: 'https://openvibe.network', onSwitch: null };
let _panelEl = null;
let _panelEl = null;
@@ -101,6 +115,8 @@
@@ -101,6 +115,8 @@
email: user.email || null,
email: user.email || null,
is_anon: isAnon,
is_anon: isAnon,
anon_number: anonNum,
anon_number: anonNum,
+ // §Z.3: the subject too, so the name wears its effect when the list is drawn again later.
+ subject: subjectOf(user),
token,
token,
added_at: Date.now(),
added_at: Date.now(),
};
};
@@ -471,7 +487,7 @@
@@ -471,7 +487,7 @@
<div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">
<div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">
<img src="${a.avatar_url || '/data/avatars/default.png'}" alt="" width="40" height="40" decoding="async">
<img src="${a.avatar_url || '/data/avatars/default.png'}" alt="" width="40" height="40" decoding="async">
<div class="info">
<div class="info">
- <div class="name">${a.display_name || a.username}</div>
+ <div class="name"><span${subjectAttr(a)}>${a.display_name || a.username}</span></div>
<div class="sub">${a.email || `@${a.username}`}</div>
<div class="sub">${a.email || `@${a.username}`}</div>
</div>
</div>
${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove account">\u2715</span>` : ''}
${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove account">\u2715</span>` : ''}
@@ -508,6 +524,8 @@
@@ -508,6 +524,8 @@
overlay.appendChild(panel);
overlay.appendChild(panel);
document.body.appendChild(overlay);
document.body.appendChild(overlay);
_panelEl = overlay;
_panelEl = overlay;
+ // §Z.3: every name in the list wears its effect, hat and badge.
+ wearNames(panel);
// Wire events
// Wire events
panel.querySelectorAll('.openvibe-switcher-item[data-id]').forEach(el => {
panel.querySelectorAll('.openvibe-switcher-item[data-id]').forEach(el => {
@@ -114,7 +114,7 @@
@@ -114,7 +114,7 @@
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
- const VIBE_REV = { 'ov-composer.js': 'ce2ed7a5d349' };
+ const VIBE_REV = { 'ov-composer.js': '5bd54df16c8f' };
function joinComposer(form) {
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
form.setAttribute('data-ovc-no-files', '');
@@ -84,7 +84,7 @@
@@ -84,7 +84,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
- const DOCK_REV = { 'dock-vibe.js': 'd8d4ea13d12a', 'dock-code.js': 'cb97274524d0', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '759f07eebc81', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '1b6262204e91', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
+ const DOCK_REV = { 'dock-vibe.js': 'e41403255108', 'dock-code.js': 'cb97274524d0', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '759f07eebc81', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '1b6262204e91', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
function load(src, test) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -334,6 +334,21 @@
@@ -334,6 +334,21 @@
const scopes = () => Array.isArray(o.scopes) ? o.scopes : null;
const scopes = () => Array.isArray(o.scopes) ? o.scopes : null;
const canWriteChat = () => !scopes() || scopes().includes('chat.write');
const canWriteChat = () => !scopes() || scopes().includes('chat.write');
const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
+ // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the data carries is what is used.
+ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+ const subjectOf = (data) => {
+ if (!data || typeof data !== 'object') return null;
+ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject', 'sender_subject', 'sender_subject_id']) {
+ const v = data[key];
+ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;
+ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;
+ }
+ return null;
+ };
+ // The attribute a name wears, when the data carries a subject. Never invented.
+ const setSubject = (node, data) => { const s = subjectOf(data); if (s && node && node.setAttribute) node.setAttribute('data-ov-subject', s); return node; };
+ // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.
+ const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };
const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
const toChat = (href) => { const a = h('a', 'ovdk-ib ovdk-away'); a.href = href; a.target = '_blank'; a.rel = 'noopener'; a.title = 'Open in Chat'; a.setAttribute('aria-label', 'Open in Chat'); a.setAttribute('data-ov-tip', 'Open in Chat'); a.innerHTML = svg('<path d="M13.5 5.5h5v5M18.5 5.5l-7.5 7.5M17 13.5v4a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 5 17.5v-9A1.5 1.5 0 0 1 6.5 7h4"/>'); return a; };
const toChat = (href) => { const a = h('a', 'ovdk-ib ovdk-away'); a.href = href; a.target = '_blank'; a.rel = 'noopener'; a.title = 'Open in Chat'; a.setAttribute('aria-label', 'Open in Chat'); a.setAttribute('data-ov-tip', 'Open in Chat'); a.innerHTML = svg('<path d="M13.5 5.5h5v5M18.5 5.5l-7.5 7.5M17 13.5v4a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 5 17.5v-9A1.5 1.5 0 0 1 6.5 7h4"/>'); return a; };
async function chatCall(path, init = {}) {
async function chatCall(path, init = {}) {
@@ -376,6 +391,21 @@
@@ -376,6 +391,21 @@
}
}
const others = (c) => (c.participants || []).filter((p) => p.username !== me());
const others = (c) => (c.participants || []).filter((p) => p.username !== me());
const titleOf = (c) => c.name || others(c).map((p) => p.display_name || p.username).join(', ') || 'Just you';
const titleOf = (c) => c.name || others(c).map((p) => p.display_name || p.username).join(', ') || 'Just you';
+ // §Z.3: the conversation's title with each person's name in a span of its own, so every name wears its effect.
+ const titleInto = (el, c) => {
+ if (!c) { el.textContent = 'Conversation'; return el; }
+ if (c.name) { el.textContent = c.name; return el; }
+ const people = others(c);
+ if (!people.length) { el.textContent = 'Just you'; return el; }
+ el.textContent = '';
+ people.forEach((p, i) => {
+ if (i) el.appendChild(document.createTextNode(', '));
+ const s = setSubject(h('span', ''), p); s.textContent = p.display_name || p.username || '';
+ el.appendChild(s);
+ });
+ return el;
+ };
+ const titleEl = (c) => titleInto(h('b', ''), c);
async function chatList() {
async function chatList() {
clearTimeout(chats.timer);
clearTimeout(chats.timer);
if (chats.edits) { chats.edits.stop(); chats.edits = null; }
if (chats.edits) { chats.edits.stop(); chats.edits = null; }
@@ -420,7 +450,7 @@
@@ -420,7 +450,7 @@
const unread = Number(c.unread_count) || 0;
const unread = Number(c.unread_count) || 0;
const mine = (c.participants || []).some((p) => p.id === c.last_sender_id && p.username === me());
const mine = (c.participants || []).some((p) => p.id === c.last_sender_id && p.username === me());
const body = h('span', 'ovdk-chat-b');
const body = h('span', 'ovdk-chat-b');
- body.append(h('b', '', titleOf(c)), h('span', 'ovdk-chat-p', c.last_message ? `${mine ? 'You: ' : ''}${String(c.last_message).replace(/\s+/g, ' ').slice(0, 140)}` : 'No messages yet'));
+ body.append(titleEl(c), h('span', 'ovdk-chat-p', c.last_message ? `${mine ? 'You: ' : ''}${String(c.last_message).replace(/\s+/g, ' ').slice(0, 140)}` : 'No messages yet'));
const meta = h('span', 'ovdk-chat-m');
const meta = h('span', 'ovdk-chat-m');
meta.append(h('time', '', when(c.last_message_at || c.updated_at)));
meta.append(h('time', '', when(c.last_message_at || c.updated_at)));
if (unread) meta.append(h('b', 'ovdk-unread', unread > 99 ? '99+' : String(unread)));
if (unread) meta.append(h('b', 'ovdk-unread', unread > 99 ? '99+' : String(unread)));
@@ -433,6 +463,8 @@
@@ -433,6 +463,8 @@
scroll.append(ul);
scroll.append(ul);
}
}
panel.append(scroll);
panel.append(scroll);
+ // §Z.3: every name in the conversation list wears its effect, hat and badge.
+ wearNames(panel);
}
}
/** "edited", one quiet word after a message's words; when, in its title. */
/** "edited", one quiet word after a message's words; when, in its title. */
function editedMark(at) {
function editedMark(at) {
@@ -455,7 +487,11 @@
@@ -455,7 +487,11 @@
function bubble(m, prev) {
function bubble(m, prev) {
const mine = m.username === me();
const mine = m.username === me();
const li = h('li', `ovdk-msg${mine ? ' is-mine' : ''}${prev && prev.username === m.username ? ' is-cont' : ''}`);
const li = h('li', `ovdk-msg${mine ? ' is-mine' : ''}${prev && prev.username === m.username ? ' is-cont' : ''}`);
- if (!mine && !(prev && prev.username === m.username)) li.append(h('span', 'ovdk-who', m.display_name || m.username || ''));
+ if (!mine && !(prev && prev.username === m.username)) {
+ // §Z.3: the name wears its effect, hat and badge (the message's subject, when it carries one).
+ const who = setSubject(h('span', 'ovdk-who'), m); who.textContent = m.display_name || m.username || '';
+ li.append(who);
+ }
li.dataset.id = String(m.id);
li.dataset.id = String(m.id);
const words = h('span', 'ovdk-text', String(m.message || ''));
const words = h('span', 'ovdk-text', String(m.message || ''));
if (m.edited_at) words.append(editedMark(m.edited_at));
if (m.edited_at) words.append(editedMark(m.edited_at));
@@ -483,10 +519,11 @@
@@ -483,10 +519,11 @@
const head = h('div', 'ovdk-thread-h');
const head = h('div', 'ovdk-thread-h');
const back = h('button', 'ovdk-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your chats'); back.append(icon(G.back));
const back = h('button', 'ovdk-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your chats'); back.append(icon(G.back));
back.addEventListener('click', () => { save({ chat: null }); chatList(); });
back.addEventListener('click', () => { save({ chat: null }); chatList(); });
- const title = h('b', '', conv ? titleOf(conv) : 'Conversation');
+ // §Z.3: the title's names wear their effect, hat and badge (each person's name in a span of its own).
+ const title = titleInto(h('b', ''), conv);
head.append(back, title, toChat('https://openvibe.chat/messages'));
head.append(back, title, toChat('https://openvibe.chat/messages'));
// Opened from the Dock's state on another page: the list is not here yet, so the title comes with it.
// Opened from the Dock's state on another page: the list is not here yet, so the title comes with it.
- if (!conv) chatCall('/api/dm/conversations').then((b) => { chats.list = b.conversations || []; const c = chats.list.find((x) => String(x.id) === String(id)); if (c && chats.open === String(id)) title.textContent = titleOf(c); }).catch(() => { /* the title stays */ });
+ if (!conv) chatCall('/api/dm/conversations').then((b) => { chats.list = b.conversations || []; const c = chats.list.find((x) => String(x.id) === String(id)); if (c && chats.open === String(id)) titleInto(title, c); }).catch(() => { /* the title stays */ });
const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');
const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');
const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);
const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);
const form = h('form', 'ovdk-send');
const form = h('form', 'ovdk-send');
@@ -572,6 +609,8 @@
@@ -572,6 +609,8 @@
await pull(list, scroll, true);
await pull(list, scroll, true);
load('dock-chat-edits.js', () => !!root.OpenVibeDockChatEdits).then((ok) => { if (ok && chats.open === String(id)) chats.edits = root.OpenVibeDockChatEdits({ id: String(id), apply: (m) => applyEdit(list, m), tokenOf, chatBase, active: () => chats.open === String(id) && panelActive('chats') }); });
load('dock-chat-edits.js', () => !!root.OpenVibeDockChatEdits).then((ok) => { if (ok && chats.open === String(id)) chats.edits = root.OpenVibeDockChatEdits({ id: String(id), apply: (m) => applyEdit(list, m), tokenOf, chatBase, active: () => chats.open === String(id) && panelActive('chats') }); });
if (canWriteChat() && !(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)) setTimeout(() => { try { box.focus({ preventScroll: true }); if (draft) box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } }, 0);
if (canWriteChat() && !(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)) setTimeout(() => { try { box.focus({ preventScroll: true }); if (draft) box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } }, 0);
+ // §Z.3: every name in the room (the title and the messages) wears its effect, hat and badge.
+ wearNames(panel);
}
}
/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */
/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */
async function pull(list, scroll, first) {
async function pull(list, scroll, first) {
@@ -585,6 +624,8 @@
@@ -585,6 +624,8 @@
if (first) list.textContent = '';
if (first) list.textContent = '';
const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;
const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;
got.forEach((m) => { if (chats.msgs.some((x) => x.id === m.id)) return; list.append(bubble(m, chats.msgs[chats.msgs.length - 1])); chats.msgs.push(m); chats.last = Math.max(chats.last, Number(m.id) || 0); });
got.forEach((m) => { if (chats.msgs.some((x) => x.id === m.id)) return; list.append(bubble(m, chats.msgs[chats.msgs.length - 1])); chats.msgs.push(m); chats.last = Math.max(chats.last, Number(m.id) || 0); });
+ // §Z.3: every name in the room wears its effect, hat and badge.
+ wearNames(list);
if (first && !chats.msgs.length) list.append(h('li', 'ovdk-none', 'No messages yet. Say hello.'));
if (first && !chats.msgs.length) list.append(h('li', 'ovdk-none', 'No messages yet. Say hello.'));
if (first || atEnd) scroll.scrollTop = scroll.scrollHeight;
if (first || atEnd) scroll.scrollTop = scroll.scrollHeight;
if (got.length || first) chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/read`, { method: 'POST', body: '{}' }).then(readUnread).catch(() => { /* */ });
if (got.length || first) chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/read`, { method: 'POST', body: '{}' }).then(readUnread).catch(() => { /* */ });
@@ -47,6 +47,20 @@
@@ -47,6 +47,20 @@
'use strict';
'use strict';
const root = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : {});
const root = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : {});
+ // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the session carries is what is used.
+ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+ const subjectOf = (data) => {
+ if (!data || typeof data !== 'object') return null;
+ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {
+ const v = data[key];
+ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;
+ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;
+ }
+ return null;
+ };
+ const subjectAttr = (data) => { const s = subjectOf(data); return s ? ` data-ov-subject="${s}"` : ''; };
+ // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.
+ const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };
// Where this file was served from: shipped.js is loaded from the same place (the site's own
// Where this file was served from: shipped.js is loaded from the same place (the site's own
// /shared/ copy, or openvibe.network), so the site's CSP already allows it.
// /shared/ copy, or openvibe.network), so the site's CSP already allows it.
const OWN_SRC = (function () { try { return document.currentScript && document.currentScript.src ? document.currentScript.src : null; } catch { return null; } })();
const OWN_SRC = (function () { try { return document.currentScript && document.currentScript.src ? document.currentScript.src : null; } catch { return null; } })();
@@ -361,7 +375,7 @@
@@ -361,7 +375,7 @@
{ name: 'Themes', url: `${NETWORK_URL}/themes`, icon: 'themes' },
{ name: 'Themes', url: `${NETWORK_URL}/themes`, icon: 'themes' },
{ name: 'Tools', url: 'https://openvibe.tools', icon: 'tools' },
{ name: 'Tools', url: 'https://openvibe.tools', icon: 'tools' },
];
];
- return `<span class="ovf-account-who"><span class="ovf-avatar">${avatar}</span> Signed in as <strong>${name}</strong> across OpenVibe</span>
+ return `<span class="ovf-account-who"><span class="ovf-avatar">${avatar}</span> Signed in as <strong${subjectAttr(user)}>${name}</strong> across OpenVibe</span>
<span class="ovf-account-links">${quick.map(q => `<a href="${esc(q.url)}"${/^https?:/.test(q.url) ? ' target="_blank" rel="noopener"' : ''}>${icon(q.icon)} ${esc(q.name)}</a>`).join('')}</span>`;
<span class="ovf-account-links">${quick.map(q => `<a href="${esc(q.url)}"${/^https?:/.test(q.url) ? ' target="_blank" rel="noopener"' : ''}>${icon(q.icon)} ${esc(q.name)}</a>`).join('')}</span>`;
}
}
@@ -389,6 +403,8 @@
@@ -389,6 +403,8 @@
if (!user || !user.username) return;
if (!user || !user.username) return;
host.innerHTML = accountHTML(user, cfg.service || detectService());
host.innerHTML = accountHTML(user, cfg.service || detectService());
host.hidden = false;
host.hidden = false;
+ // §Z.3: the name in the signed-in row wears its effect, hat and badge.
+ wearNames(host);
}
}
// ── Styles (injected once) ─────────────────────────────────
// ── Styles (injected once) ─────────────────────────────────
@@ -682,7 +698,7 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
@@ -682,7 +698,7 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
// and the first line of what a press opens (a finger never sees a tooltip).
// and the first line of what a press opens (a finger never sees a tooltip).
const head = `<span class="ovf-who-dot" aria-hidden="true"></span><span class="ovf-who-n" data-ov-tip="In the last 30 minutes"${parts ? ` data-ov-tip-sub="${esc(parts)}"` : ''}><b>${fmtN(o.total)}</b> online</span>${o.people.length ? `<span class="ovf-who-faces" aria-hidden="true">${o.people.slice(0, 5).map((p) => face(p, 24)).join('')}</span>` : ''}`;
const head = `<span class="ovf-who-dot" aria-hidden="true"></span><span class="ovf-who-n" data-ov-tip="In the last 30 minutes"${parts ? ` data-ov-tip-sub="${esc(parts)}"` : ''}><b>${fmtN(o.total)}</b> online</span>${o.people.length ? `<span class="ovf-who-faces" aria-hidden="true">${o.people.slice(0, 5).map((p) => face(p, 24)).join('')}</span>` : ''}`;
const when = `<p class="ovf-who-when">In the last 30 minutes${parts ? ` · ${parts}` : ''}</p>`;
const when = `<p class="ovf-who-when">In the last 30 minutes${parts ? ` · ${parts}` : ''}</p>`;
- const list = o.people.map((p) => `<li><a href="${NETWORK_URL}/@${encodeURIComponent(p.username)}">${face(p, 26)}<span>${esc(p.name)}</span>${p.site ? `<small>${esc(p.site)}</small>` : ''}</a></li>`).join('');
+ const list = o.people.map((p) => `<li><a href="${NETWORK_URL}/@${encodeURIComponent(p.username)}">${face(p, 26)}<span${subjectAttr(p)}>${esc(p.name)}</span>${p.site ? `<small>${esc(p.site)}</small>` : ''}</a></li>`).join('');
who = `<details class="ovf-who"><summary>${head}</summary>${when}${list ? `<ul class="ovf-who-list">${list}</ul>` : ''}${o.more > 0 ? `<p class="ovf-who-more">and ${fmtN(o.more)} more</p>` : ''}</details>`;
who = `<details class="ovf-who"><summary>${head}</summary>${when}${list ? `<ul class="ovf-who-list">${list}</ul>` : ''}${o.more > 0 ? `<p class="ovf-who-more">and ${fmtN(o.more)} more</p>` : ''}</details>`;
}
}
// Then a row of small tiles, each opening what it counts: live (only while someone is), the counts, this
// Then a row of small tiles, each opening what it counts: live (only while someone is), the counts, this
@@ -693,7 +709,7 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
@@ -693,7 +709,7 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
const live = o.live;
const live = o.live;
if (live && live.channels > 0) {
if (live && live.channels > 0) {
tiles.push(tile('https://openvibe.live/', ' is-live', `<span class="ovf-dot" aria-hidden="true"></span><b>${fmtN(live.channels)}</b><span>live now</span>${live.watching > 0 ? `<small>${fmtN(live.watching)} watching</small>` : ''}`));
tiles.push(tile('https://openvibe.live/', ' is-live', `<span class="ovf-dot" aria-hidden="true"></span><b>${fmtN(live.channels)}</b><span>live now</span>${live.watching > 0 ? `<small>${fmtN(live.watching)} watching</small>` : ''}`));
- live.first.forEach((c) => tiles.push(tile(c.url, ' is-channel', `<span>${esc(c.name)}</span>`)));
+ live.first.forEach((c) => tiles.push(tile(c.url, ' is-channel', `<span>${esc(c.name)}</span>`))); // a channel is nobody: no effect to wear
}
}
o.counts.forEach((c) => tiles.push(row(c)));
o.counts.forEach((c) => tiles.push(row(c)));
o.items.forEach((i) => tiles.push(row(i)));
o.items.forEach((i) => tiles.push(row(i)));
@@ -25,6 +25,23 @@ function safeLinkify(node, value) {
@@ -25,6 +25,23 @@ function safeLinkify(node, value) {
}
}
function timeValue(message) { let raw = String(message.created_at || message.at || '').replace(' ', 'T'); if (raw && !/(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)) raw += 'Z'; const d = new Date(raw); return Number.isNaN(d.getTime()) ? null : d; }
function timeValue(message) { let raw = String(message.created_at || message.at || '').replace(' ', 'T'); if (raw && !/(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)) raw += 'Z'; const d = new Date(raw); return Number.isNaN(d.getTime()) ? null : d; }
function safeAvatar(value) { try { const u = new URL(String(value || '')); return u.protocol === 'https:' && !u.port && u.hostname.toLowerCase() === 'openvibe.media' ? u.href : ''; } catch { return ''; } }
function safeAvatar(value) { try { const u = new URL(String(value || '')); return u.protocol === 'https:' && !u.port && u.hostname.toLowerCase() === 'openvibe.media' ? u.href : ''; } catch { return ''; } }
+// §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the message carries is what is used.
+// The names carry an ovm- prefix: this file is a plain browser script, so a top-level const of a plain name would
+// clash with another script's (two scripts cannot both declare the same top-level const). See navbar.js's OV.names.
+const OVM_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ovmSubjectOf = (data) => {
+ if (!data || typeof data !== 'object') return null;
+ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject', 'sender_subject', 'sender_subject_id']) {
+ const v = data[key];
+ if (typeof v === 'string' && OVM_SUBJECT_RE.test(v)) return v;
+ if (v && typeof v === 'object' && typeof v.id === 'string' && OVM_SUBJECT_RE.test(v.id)) return v.id;
+ }
+ return null;
+};
+// The attribute a name wears, when the data carries a subject. Never invented.
+const ovmSetSubject = (node, data) => { const s = ovmSubjectOf(data); if (s && node && node.setAttribute) node.setAttribute('data-ov-subject', s); return node; };
+// Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.
+const ovmWearNames = (scope) => { try { const OV = (typeof window !== 'undefined' && window.OV) || (typeof globalThis !== 'undefined' && globalThis.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stays plain */ } };
/** A steady hue per person: their name in the room and their initial's circle keep one colour everywhere. */
/** A steady hue per person: their name in the room and their initial's circle keep one colour everywhere. */
function hueOf(id) { let x = 7; for (const c of String(id || '')) x = (x * 31 + c.codePointAt(0)) % 360; return x; }
function hueOf(id) { let x = 7; for (const c of String(id || '')) x = (x * 31 + c.codePointAt(0)) % 360; return x; }
function dayLabel(date, now) {
function dayLabel(date, now) {
@@ -114,7 +131,9 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(
@@ -114,7 +131,9 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(
if (view === 'room') {
if (view === 'room') {
const row = document.createElement('li'); row.className = 'ovm-room'; row.dataset.id = String(message.id || '');
const row = document.createElement('li'); row.className = 'ovm-room'; row.dataset.id = String(message.id || '');
const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(date)); } row.appendChild(time);
const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(date)); } row.appendChild(time);
- const author = document.createElement('span'); author.className = 'ovm-room-author'; author.style.setProperty('--ovm-h', String(hueOf(sender))); text(author, message.display_name || message.username || 'Someone'); row.appendChild(author);
+ // §Z.3: the name wears its effect, hat and badge (the message's subject, when it carries one).
+ const author = ovmSetSubject(document.createElement('span'), message); author.className = 'ovm-room-author'; author.style.setProperty('--ovm-h', String(hueOf(sender))); text(author, message.display_name || message.username || 'Someone');
+ row.appendChild(author);
const body = document.createElement('span'); body.className = 'ovm-room-text'; safeLinkify(body, message.message || message.text); row.appendChild(body); root.appendChild(row);
const body = document.createElement('span'); body.className = 'ovm-room-text'; safeLinkify(body, message.message || message.text); row.appendChild(body); root.appendChild(row);
if (message.edited_at) edited(body, message.edited_at);
if (message.edited_at) edited(body, message.edited_at);
const ids = typeof offer === 'function' ? offer(message, { mine }) : offer;
const ids = typeof offer === 'function' ? offer(message, { mine }) : offer;
@@ -126,7 +145,13 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(
@@ -126,7 +145,13 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(
else if (!mine && !same) { const spacer = document.createElement('span'); spacer.className = 'ovm-avatar is-initial'; spacer.setAttribute('aria-hidden', 'true'); spacer.style.setProperty('--ovm-h', String(hueOf(sender))); text(spacer, String(message.display_name || message.username || '?').trim().charAt(0).toUpperCase()); group.appendChild(spacer); }
else if (!mine && !same) { const spacer = document.createElement('span'); spacer.className = 'ovm-avatar is-initial'; spacer.setAttribute('aria-hidden', 'true'); spacer.style.setProperty('--ovm-h', String(hueOf(sender))); text(spacer, String(message.display_name || message.username || '?').trim().charAt(0).toUpperCase()); group.appendChild(spacer); }
else if (!mine) { const gap = document.createElement('span'); gap.className = 'ovm-avatar is-gap'; gap.setAttribute('aria-hidden', 'true'); group.appendChild(gap); } // a run keeps the avatar's column
else if (!mine) { const gap = document.createElement('span'); gap.className = 'ovm-avatar is-gap'; gap.setAttribute('aria-hidden', 'true'); group.appendChild(gap); } // a run keeps the avatar's column
const body = document.createElement('div'); body.className = 'ovm-group-body';
const body = document.createElement('div'); body.className = 'ovm-group-body';
- if (!mine && !same) { const author = document.createElement('div'); author.className = 'ovm-author'; text(author, message.display_name || message.username || 'Someone'); body.appendChild(author); }
+ if (!mine && !same) {
+ const author = document.createElement('div'); author.className = 'ovm-author';
+ // §Z.3: the name wears its effect, hat and badge; it sits in its own span, so the hat stands beside it.
+ const who = ovmSetSubject(document.createElement('span'), message); text(who, message.display_name || message.username || 'Someone');
+ author.appendChild(who);
+ body.appendChild(author);
+ }
const bubble = document.createElement('div'); bubble.className = 'ovm-bubble';
const bubble = document.createElement('div'); bubble.className = 'ovm-bubble';
if (message.reply_to) { const quote = document.createElement('span'); quote.className = 'ovm-reply'; text(quote, message.reply_to.message || message.reply_to.text || 'Reply'); bubble.appendChild(quote); }
if (message.reply_to) { const quote = document.createElement('span'); quote.className = 'ovm-reply'; text(quote, message.reply_to.message || message.reply_to.text || 'Reply'); bubble.appendChild(quote); }
const content = document.createElement('span'); content.className = 'ovm-text'; const value = String(message.message || message.text || '');
const content = document.createElement('span'); content.className = 'ovm-text'; const value = String(message.message || message.text || '');
@@ -145,6 +170,8 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(
@@ -145,6 +170,8 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(
previous = { sender, stamp };
previous = { sender, stamp };
});
});
if (offer) tapToAct(root);
if (offer) tapToAct(root);
+ // §Z.3: every name in the room wears its effect, hat and badge.
+ ovmWearNames(root);
return root;
return root;
}
}
const api = { render, actions, edited, tapToAct };
const api = { render, actions, edited, tapToAct };
@@ -624,7 +624,7 @@
@@ -624,7 +624,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
*/
// 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': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '7d5076132825', 'dock.js': '24539743248d', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '0f6074850291', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'f2e7c61e0934', 'soft.js': '98ab9b8080b1', 'ov-run.js': 'a9008daa5af5', 'ov-composer.js': 'ce2ed7a5d349' };
+ const SIBLING_REV = { 'ov-mark.js': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': '0a397ea380cc', 'ov-share.css': 'd609a71f611a', 'search.js': '7d5076132825', 'dock.js': '8f26f280607f', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '0f6074850291', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'f2e7c61e0934', 'soft.js': '98ab9b8080b1', 'ov-run.js': 'a9008daa5af5', 'ov-composer.js': '5bd54df16c8f', 'items.js': '79a792035407', 'items.css': 'e917ec14cd4d' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
// ov-tip.js loads at the first hover over a tooltip's owner.
@@ -719,6 +719,66 @@
@@ -719,6 +719,66 @@
} catch { /* */ }
} catch { /* */ }
return true;
return true;
}
}
+ // §Z.3 (11-13), the owner: "make sure user name decoration … applies everywhere". One call for every renderer:
+ // OV.names(scope) gives every [data-ov-subject] under the scope what its person wears (items.js; items.css). It
+ // returns at once when the scope has no subject, loads items.js (and items.css) once a page the first time a name
+ // needs it, and never throws: a name stays plain when anything fails.
+ // A person's subject (usr_…): the only key a name effect hangs on. Never invented: what the data carries is used.
+ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+ const subjectOf = (data) => {
+ if (!data) return null;
+ if (typeof data === 'string') return SUBJECT_RE.test(data) ? data : null;
+ if (typeof data !== 'object') return null;
+ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject', 'sender_subject', 'sender_subject_id']) {
+ const v = data[key];
+ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;
+ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;
+ }
+ return null;
+ };
+ // The attribute a name wears: data-ov-subject="usr_…", or nothing when the data carries no subject.
+ const subjectAttr = (data) => { const s = subjectOf(data); return s ? ` data-ov-subject="${s}"` : ''; };
+ let itemsReady = null;
+ /** items.css and items.js, once a page, the first time a name needs them. */
+ function items(then) {
+ try {
+ if (!document.querySelector('link[data-ov-items-css]')) {
+ const l = document.createElement('link');
+ l.rel = 'stylesheet'; l.href = siblingV('items.css'); l.setAttribute('data-ov-items-css', '');
+ document.head.appendChild(l);
+ }
+ } catch { /* names stay plain */ }
+ if (root.OpenVibeItems && typeof root.OpenVibeItems.decorate === 'function') { then(); return; }
+ if (!itemsReady) {
+ itemsReady = new Promise((resolve) => {
+ try {
+ const sc = document.createElement('script');
+ sc.id = 'ov-items-loader'; sc.src = siblingV('items.js'); sc.async = true;
+ sc.onload = () => resolve(true); sc.onerror = () => resolve(false);
+ document.head.appendChild(sc);
+ } catch { resolve(false); }
+ });
+ }
+ itemsReady.then((ok) => { if (ok) then(); });
+ }
+ /** Every person's name under the scope wears its effect, hat and badge (items.js; items.css). 0 when there is nothing to wear. */
+ function names(scope) {
+ try {
+ const node = scope || document;
+ if (!node || typeof node.querySelector !== 'function') return 0;
+ if (!node.querySelector('[data-ov-subject]')) return 0;
+ const Items = root.OpenVibeItems;
+ if (Items && typeof Items.decorate === 'function') { items(() => { }); return Items.decorate(node); }
+ items(() => { try { if (root.OpenVibeItems) root.OpenVibeItems.decorate(node); } catch { /* names stay plain */ } });
+ return 0;
+ } catch { return 0; }
+ }
+ // §Z.3: the names the page itself drew (a server-rendered comment, a footer's signed-in row) wear their effect too.
+ if (typeof document !== 'undefined' && document.addEventListener) {
+ const sweep = () => { try { names(document); } catch { /* names stay plain */ } };
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', sweep, { once: true });
+ else sweep();
+ }
// ov-share.js and ov-share.css (4.114.0, plan track Q): the one Share sheet, fetched the first time someone presses a
// ov-share.js and ov-share.css (4.114.0, plan track Q): the one Share sheet, fetched the first time someone presses a
// [data-ov-share-sheet] button, so a site gets it by putting the button on its page and nothing loads before that.
// [data-ov-share-sheet] button, so a site gets it by putting the button on its page and nothing loads before that.
function shareClick(e) {
function shareClick(e) {
@@ -1701,7 +1761,8 @@
@@ -1701,7 +1761,8 @@
if (!u || u.is_anon || u.id == null || !u.username) return;
if (!u || u.is_anon || u.id == null || !u.username) return;
try {
try {
const seen = readList(SEEN_KEY).filter((a) => a && String(a.id) !== String(u.id));
const seen = readList(SEEN_KEY).filter((a) => a && String(a.id) !== String(u.id));
- seen.unshift({ id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null });
+ // §Z.3: the subject too, so the name wears its effect when it is drawn again later.
+ seen.unshift({ id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, subject: subjectOf(u) });
localStorage.setItem(SEEN_KEY, JSON.stringify(seen.slice(0, 10)));
localStorage.setItem(SEEN_KEY, JSON.stringify(seen.slice(0, 10)));
} catch { /* storage unavailable */ }
} catch { /* storage unavailable */ }
}
}
@@ -2360,7 +2421,7 @@
@@ -2360,7 +2421,7 @@
<div class="openvibe-navbar-dropdown-header">
<div class="openvibe-navbar-dropdown-header">
<a class="ud-me" href="${escapeAttr(meHref)}" tabindex="-1" aria-hidden="true">${avatarImg(u, 72, '', '')}</a>
<a class="ud-me" href="${escapeAttr(meHref)}" tabindex="-1" aria-hidden="true">${avatarImg(u, 72, '', '')}</a>
<div class="info">
<div class="info">
- <a class="name ud-me" href="${escapeAttr(meHref)}" title="${isAnon ? 'Your account' : 'Your profile'}">${escapeHtml(u.display_name || u.username)}</a>
+ <a class="name ud-me" href="${escapeAttr(meHref)}" title="${isAnon ? 'Your account' : 'Your profile'}"${subjectAttr(u)}>${escapeHtml(u.display_name || u.username)}</a>
<div class="email">${isAnon ? `Anonymous #${u.anon_number || '?'}` : '@' + escapeHtml(u.username || '')}</div>
<div class="email">${isAnon ? `Anonymous #${u.anon_number || '?'}` : '@' + escapeHtml(u.username || '')}</div>
${barWallet ? '' : menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div><div class="ud-wallet ud-vibes" id="openvibe-vibes" hidden></div>'}
${barWallet ? '' : menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div><div class="ud-wallet ud-vibes" id="openvibe-vibes" hidden></div>'}
</div>
</div>
@@ -2371,7 +2432,7 @@
@@ -2371,7 +2432,7 @@
</div>
</div>
</div>
</div>
${_config.accounts === false || (!others.length && !switcher) ? '' : `<div class="openvibe-navbar-dropdown-accounts" role="group" aria-label="Switch account">
${_config.accounts === false || (!others.length && !switcher) ? '' : `<div class="openvibe-navbar-dropdown-accounts" role="group" aria-label="Switch account">
- ${others.map(a => { const t = switcher ? 'div' : 'a'; return `<${t} class="account-item" ${switcher ? `data-account-id="${escapeAttr(String(a.id))}" role="button" tabindex="0"` : `href="${escapeAttr(switchHref(a))}"`} title="Switch to ${escapeAttr(a.display_name || a.username || '')}">${avatarImg(a, 48, '', '')}<span>${escapeHtml(a.display_name || a.username || '')}${a.is_anon ? ' (anon)' : ''}</span></${t}>`; }).join('')}
+ ${others.map(a => { const t = switcher ? 'div' : 'a'; return `<${t} class="account-item" ${switcher ? `data-account-id="${escapeAttr(String(a.id))}" role="button" tabindex="0"` : `href="${escapeAttr(switchHref(a))}"`} title="Switch to ${escapeAttr(a.display_name || a.username || '')}">${avatarImg(a, 48, '', '')}<span><span${subjectAttr(a)}>${escapeHtml(a.display_name || a.username || '')}</span>${a.is_anon ? ' (anon)' : ''}</span></${t}>`; }).join('')}
${switcher && !isAnon ? `<div class="account-item ud-anon" data-account-id="anon" role="button" tabindex="0" title="Browse without your name"><span class="icon">${navIcon('fa-user-secret')}</span><span>Anonymous</span></div>` : ''}
${switcher && !isAnon ? `<div class="account-item ud-anon" data-account-id="anon" role="button" tabindex="0" title="Browse without your name"><span class="icon">${navIcon('fa-user-secret')}</span><span>Anonymous</span></div>` : ''}
</div>`}
</div>`}
<div class="openvibe-navbar-dropdown-menu">
<div class="openvibe-navbar-dropdown-menu">
@@ -2386,6 +2447,8 @@
@@ -2386,6 +2447,8 @@
</div>
</div>
`;
`;
dropdown.addEventListener('click', (e) => { const t = e.target.closest && e.target.closest('[data-use]'); if (t) countUse(t.getAttribute('data-use')); });
dropdown.addEventListener('click', (e) => { const t = e.target.closest && e.target.closest('[data-use]'); if (t) countUse(t.getAttribute('data-use')); });
+ // §Z.3: the names in the menu wear what their people wear (items.js; items.css).
+ names(dropdown);
// 4.188.0: not a popup under the picture. It rests in the bar, out of sight (so a site that looks for its
// 4.188.0: not a popup under the picture. It rests in the bar, out of sight (so a site that looks for its
// sign-out button or its own item still finds it), and the menu's You tab hosts it while the menu is open
// sign-out button or its own item still finds it), and the menu's You tab hosts it while the menu is open
// (openSearch's account(); onClose brings it back).
// (openSearch's account(); onClose brings it back).
@@ -2801,6 +2864,8 @@
@@ -2801,6 +2864,8 @@
const OpenVibeNavbar = {
const OpenVibeNavbar = {
/** 4.243.0 (§Z.2): who is looking, for the menu, a site's own nav, mods and apps; also OV.viewer(). */
/** 4.243.0 (§Z.2): who is looking, for the menu, a site's own nav, mods and apps; also OV.viewer(). */
viewer() { return viewer(); },
viewer() { return viewer(); },
+ /** 4.244.0 (§Z.3): every person's name under the scope wears its effect, hat and badge; also OV.names(scope). */
+ names(scope) { return names(scope); },
/** 4.228.0: the person's Network token the site gave init() (or null), for Shared's own calls to the Network
/** 4.228.0: the person's Network token the site gave init() (or null), for Shared's own calls to the Network
* (the composer's settings sync and Caption it). The site already put it in the page; this only reads it. */
* (the composer's settings sync and Caption it). The site already put it in the page; this only reads it. */
token() { return _config.token || null; },
token() { return _config.token || null; },
@@ -2996,5 +3061,10 @@
@@ -2996,5 +3061,10 @@
root.OV = root.OV || {};
root.OV = root.OV || {};
if (typeof root.OV.viewer !== 'function') root.OV.viewer = () => viewer();
if (typeof root.OV.viewer !== 'function') root.OV.viewer = () => viewer();
}
}
+ // §Z.3: OV.names(scope) — every person's name under the scope wears its effect, hat and badge. It is here in the
+ // browser and in Node (a site that renders names server-side asks for it the same way), so a renderer's call never
+ // depends on which of the two loaded the navbar.
+ root.OV = root.OV || {};
+ if (typeof root.OV.names !== 'function') root.OV.names = (scope) => names(scope);
})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
@@ -585,6 +585,21 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
@@ -585,6 +585,21 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
};
};
const svg = (d, cls) => `<svg${cls ? ` class="${cls}"` : ''} viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;
const svg = (d, cls) => `<svg${cls ? ` class="${cls}"` : ''} viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;
const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };
const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };
+ // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the data carries is what is used.
+ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+ const subjectOf = (data) => {
+ if (!data || typeof data !== 'object') return null;
+ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {
+ const v = data[key];
+ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;
+ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;
+ }
+ return null;
+ };
+ // The attribute a name wears, when the data carries a subject. Never invented.
+ const setSubject = (node, data) => { const s = subjectOf(data); if (s && node && node.setAttribute) node.setAttribute('data-ov-subject', s); return node; };
+ // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.
+ const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };
const store = {
const store = {
get(k) { try { return JSON.parse(root.localStorage.getItem(k) || 'null'); } catch { return null; } },
get(k) { try { return JSON.parse(root.localStorage.getItem(k) || 'null'); } catch { return null; } },
set(k, v) { try { if (v == null) root.localStorage.removeItem(k); else root.localStorage.setItem(k, JSON.stringify(v)); } catch { /* private mode */ } },
set(k, v) { try { if (v == null) root.localStorage.removeItem(k); else root.localStorage.setItem(k, JSON.stringify(v)); } catch { /* private mode */ } },
@@ -1529,8 +1544,10 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
@@ -1529,8 +1544,10 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
const av = src ? el('img', 'ovc-av') : el('span', 'ovc-av');
const av = src ? el('img', 'ovc-av') : el('span', 'ovc-av');
if (src) { av.src = src; av.alt = ''; av.loading = 'lazy'; } else av.textContent = initial(o.user);
if (src) { av.src = src; av.alt = ''; av.loading = 'lazy'; } else av.textContent = initial(o.user);
av.setAttribute('aria-hidden', 'true');
av.setAttribute('aria-hidden', 'true');
- const nm = el('span', 'ovc-head-n'); nm.textContent = String(o.user.display_name || o.user.username || '');
+ // §Z.3: the name wears its effect, hat and badge (the account's subject, when it carries one).
+ const nm = setSubject(el('span', 'ovc-head-n'), o.user); nm.textContent = String(o.user.display_name || o.user.username || '');
head.append(av, nm);
head.append(av, nm);
+ wearNames(head);
}
}
const ta = el('textarea');
const ta = el('textarea');
ta.name = o.name || 'text'; ta.rows = p.rows; ta.maxLength = o.maxLength || p.max;
ta.name = o.name || 'text'; ta.rows = p.rows; ta.maxLength = o.maxLength || p.max;
@@ -110,6 +110,21 @@
@@ -110,6 +110,21 @@
const same = (a, b) => !!a && !!b && sid(a) === sid(b);
const same = (a, b) => !!a && !!b && sid(a) === sid(b);
const handle = (p) => (p && p.username ? `@${p.username}` : '');
const handle = (p) => (p && p.username ? `@${p.username}` : '');
const nameOf = (p) => (p && (p.display_name || p.username)) || 'Someone';
const nameOf = (p) => (p && (p.display_name || p.username)) || 'Someone';
+ // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the data carries is what is used.
+ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+ const subjectOf = (data) => {
+ if (!data || typeof data !== 'object') return null;
+ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {
+ const v = data[key];
+ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;
+ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;
+ }
+ return null;
+ };
+ // The attribute a name wears, when the data carries a subject. Never invented.
+ const setSubject = (node, data) => { const s = subjectOf(data); if (s && node && node.setAttribute) node.setAttribute('data-ov-subject', s); return node; };
+ // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.
+ const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };
function when(iso, withYear) {
function when(iso, withYear) {
const d = new Date(iso);
const d = new Date(iso);
if (!iso || isNaN(d)) return '';
if (!iso || isNaN(d)) return '';
@@ -559,6 +574,8 @@
@@ -559,6 +574,8 @@
box.appendChild(foot(st));
box.appendChild(foot(st));
box.appendChild(el('p', `ov-share-toast${st.toastWords ? ' is-on' : ''}${st.toastQuiet ? ' is-quiet' : ''}`, { role: 'status', 'aria-live': 'polite' }, st.toastWords || ''));
box.appendChild(el('p', `ov-share-toast${st.toastWords ? ' is-on' : ''}${st.toastQuiet ? ' is-quiet' : ''}`, { role: 'status', 'aria-live': 'polite' }, st.toastWords || ''));
st.dlg.replaceChildren(box);
st.dlg.replaceChildren(box);
+ // §Z.3: every name in the sheet wears its effect, hat and badge.
+ wearNames(st.dlg);
// A panel just opened (QR code, Embed, Mastodon) comes into view in the sheet's scroll.
// A panel just opened (QR code, Embed, Mastodon) comes into view in the sheet's scroll.
if (st.panel && st.panel !== st.shownPanel) {
if (st.panel && st.panel !== st.shownPanel) {
const pnl = st.dlg.querySelector('.ov-share-panel');
const pnl = st.dlg.querySelector('.ov-share-panel');
@@ -737,7 +754,8 @@
@@ -737,7 +754,8 @@
const ul = el('ul', 'ov-share-fps', { 'aria-label': 'Fingerprints' });
const ul = el('ul', 'ov-share-fps', { 'aria-label': 'Fingerprints' });
for (const m of known) {
for (const m of known) {
const li = el('li', 'ov-share-fprow');
const li = el('li', 'ov-share-fprow');
- li.appendChild(el('span', 'ov-share-fpwho', null, handle(m) || nameOf(m)));
+ // §Z.3: the person's name in the sheet wears its effect, hat and badge (the member's subject).
+ li.appendChild(setSubject(el('span', 'ov-share-fpwho', null, handle(m) || nameOf(m)), m));
li.appendChild(el('span', 'ov-share-fp', null, m.key_fingerprint));
li.appendChild(el('span', 'ov-share-fp', null, m.key_fingerprint));
if (m.fingerprint_changed) li.appendChild(el('span', 'ov-share-tag is-warn', null, 'key changed'));
if (m.fingerprint_changed) li.appendChild(el('span', 'ov-share-tag is-warn', null, 'key changed'));
ul.appendChild(li);
ul.appendChild(li);
@@ -954,7 +972,9 @@
@@ -954,7 +972,9 @@
li.appendChild(avatar(p));
li.appendChild(avatar(p));
const words = el('div', 'ov-share-words');
const words = el('div', 'ov-share-words');
const top = el('div', 'ov-share-name');
const top = el('div', 'ov-share-name');
- top.appendChild(el('span', 'ov-share-display', null, nameOf(p)));
+ // §Z.3: the name wears its effect, hat and badge (the member's subject, when it carries one).
+ const shown = setSubject(el('span', 'ov-share-display', null, nameOf(p)), p);
+ top.appendChild(shown);
if (x.you || (st.server && st.server.you && p.subject && same(p.subject, st.server.you.subject))) top.appendChild(el('span', 'ov-share-tag', null, 'You'));
if (x.you || (st.server && st.server.you && p.subject && same(p.subject, st.server.you.subject))) top.appendChild(el('span', 'ov-share-tag', null, 'You'));
if (x.member && x.member.isNew) top.appendChild(el('span', 'ov-share-tag is-new', null, 'New'));
if (x.member && x.member.isNew) top.appendChild(el('span', 'ov-share-tag is-new', null, 'New'));
words.appendChild(top);
words.appendChild(top);
@@ -1029,7 +1049,11 @@
@@ -1029,7 +1049,11 @@
const li = el('li', 'ov-share-row');
const li = el('li', 'ov-share-row');
li.appendChild(avatar(r));
li.appendChild(avatar(r));
const words = el('div', 'ov-share-words');
const words = el('div', 'ov-share-words');
- words.appendChild(el('div', 'ov-share-name', null, nameOf(r)));
+ // §Z.3: the name wears its effect, hat and badge; it sits in its own span, so the hat stands beside it.
+ const asked = el('div', 'ov-share-name');
+ const askedName = setSubject(el('span', null, null, nameOf(r)), r);
+ asked.appendChild(askedName);
+ words.appendChild(asked);
const sub = el('div', 'ov-share-sub');
const sub = el('div', 'ov-share-sub');
if (r.username) sub.appendChild(el('span', null, null, handle(r)));
if (r.username) sub.appendChild(el('span', null, null, handle(r)));
if (r.requested_at) sub.appendChild(el('span', null, null, `asked ${when(r.requested_at)}`));
if (r.requested_at) sub.appendChild(el('span', null, null, `asked ${when(r.requested_at)}`));
@@ -1057,7 +1081,8 @@
@@ -1057,7 +1081,8 @@
b.appendChild(iconEl('give'));
b.appendChild(iconEl('give'));
const p = el('p');
const p = el('p');
p.appendChild(doc.createTextNode('Waiting for '));
p.appendChild(doc.createTextNode('Waiting for '));
- p.appendChild(el('strong', null, null, handle(t.to) || nameOf(t.to)));
+ // §Z.3: the person's name wears its effect, hat and badge (the subject the offer carries, when it has one).
+ p.appendChild(setSubject(el('strong', null, null, handle(t.to) || nameOf(t.to)), t.to));
p.appendChild(doc.createTextNode(` to accept ownership${t.expires_at ? `. The offer ends ${when(t.expires_at)}` : ''}.`));
p.appendChild(doc.createTextNode(` to accept ownership${t.expires_at ? `. The offer ends ${when(t.expires_at)}` : ''}.`));
b.appendChild(p);
b.appendChild(p);
const c = el('button', 'ov-share-btn is-small is-quiet', { type: 'button', 'data-k': 'cancel-give' }, 'Cancel offer');
const c = el('button', 'ov-share-btn is-small is-quiet', { type: 'button', 'data-k': 'cancel-give' }, 'Cancel offer');
@@ -1257,7 +1282,8 @@
@@ -1257,7 +1282,8 @@
if (state === 'accepted') p.textContent = 'It is yours now. You decide who else can open it.';
if (state === 'accepted') p.textContent = 'It is yours now. You decide who else can open it.';
else if (state === 'declined') p.textContent = 'You declined. Nothing changed.';
else if (state === 'declined') p.textContent = 'You declined. Nothing changed.';
else {
else {
- p.appendChild(el('strong', null, null, handle(o.from) || nameOf(o.from)));
+ // §Z.3: the person's name wears its effect, hat and badge (the subject the offer carries, when it has one).
+ p.appendChild(setSubject(el('strong', null, null, handle(o.from) || nameOf(o.from)), o.from));
p.appendChild(doc.createTextNode(` wants to give you ${o.title ? `“${o.title}”` : 'this'}. `));
p.appendChild(doc.createTextNode(` wants to give you ${o.title ? `“${o.title}”` : 'this'}. `));
p.appendChild(doc.createTextNode(o.keep_access ? 'They would keep view access. ' : 'They would lose access. '));
p.appendChild(doc.createTextNode(o.keep_access ? 'They would keep view access. ' : 'They would lose access. '));
if (o.expires_at) p.appendChild(doc.createTextNode(`The offer ends ${when(o.expires_at)}.`));
if (o.expires_at) p.appendChild(doc.createTextNode(`The offer ends ${when(o.expires_at)}.`));
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
- const THREAD_REV = { 'ov-composer.js': 'ce2ed7a5d349', 'ov-combo.js': '8001a3b19c2f' };
+ const THREAD_REV = { 'ov-composer.js': '5bd54df16c8f', 'ov-combo.js': '8001a3b19c2f' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
function loadCombo() {
@@ -255,6 +255,10 @@
@@ -255,6 +255,10 @@
};
};
root.OVThread = api;
root.OVThread = api;
+ // §Z.3: every person's name under the scope wears its effect, hat and badge (items.js; items.css). Never throws.
+ function wearNames(scope) {
+ try { if (root.OV && typeof root.OV.names === 'function') root.OV.names(scope || document); } catch (e) { /* names stay plain */ }
+ }
function start() {
function start() {
loadCombo();
loadCombo();
qa(document, '[data-ovt], [data-ovc-thread]').forEach(function (box) {
qa(document, '[data-ovt], [data-ovc-thread]').forEach(function (box) {
@@ -479,6 +483,8 @@
@@ -479,6 +483,8 @@
if (sbBtn) navLead(box, sbBtn, 0);
if (sbBtn) navLead(box, sbBtn, 0);
enhance(box);
enhance(box);
+ // §Z.3 (11-13): the names the server drew (the comment authors) wear their effect, hat and badge.
+ wearNames(box);
if (!location.hash && q(box, '[data-ovt-thread] > li')) toBottom(false);
if (!location.hash && q(box, '[data-ovt-thread] > li')) toBottom(false);
});
});
}
}
@@ -76,6 +76,21 @@ html[data-ov-motion=off] .ovu-live::before, html[data-ov-motion=off] .ovu-wait i
@@ -76,6 +76,21 @@ html[data-ov-motion=off] .ovu-live::before, html[data-ov-motion=off] .ovu-wait i
}
}
const mounts = new WeakMap();
const mounts = new WeakMap();
const bindings = new WeakMap();
const bindings = new WeakMap();
+ // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the card carries is what is used.
+ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+ const subjectOf = (data) => {
+ if (!data || typeof data !== 'object') return null;
+ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {
+ const v = data[key];
+ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;
+ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;
+ }
+ return null;
+ };
+ // The attribute a name wears, when the data carries a subject. Never invented.
+ const setSubject = (node, data) => { const s = subjectOf(data); if (s && node && node.setAttribute) node.setAttribute('data-ov-subject', s); return node; };
+ // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.
+ const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };
const usernameOf = value => typeof value === 'string' && /^[A-Za-z0-9_]{3,24}$/.test(value.replace(/^@/, '')) ? value.replace(/^@/, '') : null;
const usernameOf = value => typeof value === 'string' && /^[A-Za-z0-9_]{3,24}$/.test(value.replace(/^@/, '')) ? value.replace(/^@/, '') : null;
function safeUrl(value) {
function safeUrl(value) {
try {
try {
@@ -111,10 +126,17 @@ html[data-ov-motion=off] .ovu-live::before, html[data-ov-motion=off] .ovu-wait i
@@ -111,10 +126,17 @@ html[data-ov-motion=off] .ovu-live::before, html[data-ov-motion=off] .ovu-wait i
if (avatar) { const img = doc.createElement('img'); img.src = avatar; img.alt = ''; img.decoding = 'async'; img.addEventListener('error', () => { img.remove(); face.textContent = person.username[0].toUpperCase(); }); face.appendChild(img); }
if (avatar) { const img = doc.createElement('img'); img.src = avatar; img.alt = ''; img.decoding = 'async'; img.addEventListener('error', () => { img.remove(); face.textContent = person.username[0].toUpperCase(); }); face.appendChild(img); }
else face.textContent = person.username[0].toUpperCase();
else face.textContent = person.username[0].toUpperCase();
const names = element(doc, 'div', undefined, 'ovu-names');
const names = element(doc, 'div', undefined, 'ovu-names');
- names.appendChild(element(doc, 'h2', typeof person.display_name === 'string' && person.display_name.trim() ? person.display_name : person.username, 'ovu-name'));
+ // §Z.3: the name wears its effect, hat and badge. The name text sits in its own span, so the hat stands beside
+ // the name (not on a line of its own) and the heading keeps its own block.
+ const title = element(doc, 'h2', undefined, 'ovu-name');
+ const who = setSubject(element(doc, 'span', typeof person.display_name === 'string' && person.display_name.trim() ? person.display_name : person.username), person);
+ title.appendChild(who);
+ names.appendChild(title);
names.appendChild(element(doc, 'p', `@${person.username}`, 'ovu-handle'));
names.appendChild(element(doc, 'p', `@${person.username}`, 'ovu-handle'));
head.append(face, names);
head.append(face, names);
section.appendChild(head);
section.appendChild(head);
+ // §Z.3: the name in the card wears its effect, hat and badge.
+ wearNames(section);
if (live) {
if (live) {
const a = link(doc, 'Live now', person.live.url);
const a = link(doc, 'Live now', person.live.url);
if (a) { a.className = 'ovu-live'; if (typeof person.live.where === 'string' && person.live.where) a.appendChild(element(doc, 'small', `on ${person.live.where}`)); section.appendChild(a); }
if (a) { a.className = 'ovu-live'; if (typeof person.live.where === 'string' && person.live.where) a.appendChild(element(doc, 'small', `on ${person.live.where}`)); section.appendChild(a); }
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.243.0",
+ "version": "4.244.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.243.0",
+ "version": "4.244.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.243.0",
+ "version": "4.244.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": {
@@ -27,6 +27,22 @@
@@ -27,6 +27,22 @@
const api = () => String((root.OpenVibeProfilePanelConfig && root.OpenVibeProfilePanelConfig.api) || 'https://openvibe.network').replace(/\/+$/, '');
const api = () => String((root.OpenVibeProfilePanelConfig && root.OpenVibeProfilePanelConfig.api) || 'https://openvibe.network').replace(/\/+$/, '');
const seen = new Map(); // username \u2192 Promise(profile | null), once a page
const seen = new Map(); // username \u2192 Promise(profile | null), once a page
+ // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the profile carries is what is used.
+ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+ const subjectOf = (data) => {
+ if (!data || typeof data !== 'object') return null;
+ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {
+ const v = data[key];
+ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;
+ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;
+ }
+ return null;
+ };
+ // The attribute a name wears, when the data carries a subject. Never invented.
+ const setSubject = (node, data) => { const s = subjectOf(data); if (s && node && node.setAttribute) node.setAttribute('data-ov-subject', s); return node; };
+ // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.
+ const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };
+
function load(username) {
function load(username) {
if (!seen.has(username)) {
if (!seen.has(username)) {
// At most eight seconds, and a failure is not kept: a request held while Network restarted used to stay in
// At most eight seconds, and a failure is not kept: a request held while Network restarted used to stay in
@@ -100,10 +116,14 @@
@@ -100,10 +116,14 @@
pic.alt = ''; pic.width = 48; pic.height = 48;
pic.alt = ''; pic.width = 48; pic.height = 48;
head.appendChild(pic);
head.appendChild(pic);
const who = el('span', 'ovpp-who');
const who = el('span', 'ovpp-who');
- who.appendChild(el('b', null, p.display_name || p.username));
+ // §Z.3: the name wears its effect, hat and badge (the profile's subject, when it carries one).
+ const nameEl = setSubject(el('b', null, p.display_name || p.username), p);
+ who.appendChild(nameEl);
who.appendChild(el('small', null, `@${p.username}`));
who.appendChild(el('small', null, `@${p.username}`));
head.appendChild(who);
head.appendChild(who);
card.appendChild(head);
card.appendChild(head);
+ // §Z.3: the name in the panel wears its effect, hat and badge.
+ wearNames(card);
if (p.private) {
if (p.private) {
card.appendChild(el('p', 'ovpp-note', 'This profile is private.'));
card.appendChild(el('p', 'ovpp-note', 'This profile is private.'));
target.replaceChildren(card);
target.replaceChildren(card);
@@ -27,7 +27,10 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
@@ -27,7 +27,10 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
// 2026-11-10: soft pages (intent prefetch, cross-document view transitions); not loaded by navbar.js yet.
// 2026-11-10: soft pages (intent prefetch, cross-document view transitions); not loaded by navbar.js yet.
'soft.js',
'soft.js',
// 4.224.0: Vibe's runs in the menu, and the one composer they write with.
// 4.224.0: Vibe's runs in the menu, and the one composer they write with.
- 'ov-run.js', 'ov-composer.js'];
+ 'ov-run.js', 'ov-composer.js',
+ // 4.244.0 (§Z.3): what people wear on their names (items.js; items.css), loaded by the navbar the first time a page
+ // draws a name that carries a subject.
+ 'items.js', 'items.css'];
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-code.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js', 'dock-chat-edits.js'] },
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-code.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js', 'dock-chat-edits.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -0,0 +1,186 @@
+'use strict';
+// §Z.3 (11-13), the owner: "make sure user name decoration … applies everywhere". A person's name wears what their
+// person wears (OpenVibe.Inventory through items.js; items.css) — their effect, their hat, their badge — wherever
+// Shared draws the name: a card, a message, a comment, the menu, the account list, the share sheet.
+//
+// Two halves:
+// 1. a sweep over every renderer that draws a person's name: each place that writes a display_name/username into
+// markup must set data-ov-subject on that same element, so the name can be found and dressed. The few places
+// that are not a name (an initial letter, an @handle, a comparison, a key) are in a skip list, one reason each.
+// 2. the behaviour of the one call every renderer makes: OV.names(scope) loads items.js once a page and calls
+// OpenVibeItems.decorate(scope); with no subject under the scope it does nothing at all, and it never throws.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const ROOT = path.join(__dirname, '..');
+
+// ── 1. the sweep ────────────────────────────────────────────────────────────────────────────────────────────────────
+// Every renderer that draws a person's name.
+const FILES = [
+ 'navbar.js', 'ov-user.js', 'user-card.js', 'profile-panel.js', 'messages.js', 'thread.js', 'search.js',
+ 'dock.js', 'dock-share.js', 'ov-share.js', 'account-switcher.js', 'footer.js', 'ov-composer.js', 'ov-thread.js',
+];
+// A line that writes a person's name (or a handle) into markup.
+const NAME = /display_name|\busername\b|\bname\(c\)|\bnameOf\(|\w*NameMarkup\(/;
+const WRITES = /(textContent|innerHTML|outerHTML|appendChild|insertAdjacentHTML|\btext\(|\bel\(|\belement\(|\bh\(|\bhtml\(|\bsetSubject\(|\bovmSetSubject\(|\bsubjectAttr\(|\w*NameMarkup\()/;
+// The same element carrying its person's subject.
+const SUBJECT = /(data-ov-subject|\bsetSubject\(|\bovmSetSubject\(|\bsubjectAttr\(|\w*NameMarkup\()/;
+// The places that write something of a person that is not the name: each with its reason.
+const SKIP = [
+ { file: 'ov-user.js', at: /person\.username\[0\]/, reason: 'the picture\'s initial letter — a letter, not the name' },
+ { file: 'ov-user.js', at: /@\$\{person\.username\}/, reason: 'the @handle under the name — a handle, not the name' },
+ { file: 'profile-panel.js', at: /@\$\{p\.username\}/, reason: 'the @handle under the name — a handle, not the name' },
+ { file: 'messages.js', at: /message\.display_name \|\| message\.username \|\| '\?'/, reason: 'the picture\'s initial letter — a letter, not the name' },
+ { file: 'dock.js', at: /prev\.username === m\.username/, reason: 'a comparison that picks a class, not a name in markup' },
+ { file: 'dock-share.js', at: /found\[0\]\.username/, reason: 'a sentence that names a handle to say nobody is called that' },
+ { file: 'ov-share.js', at: /s\.owner\.username/, reason: 'the owner\'s handle in a line of the sheet' },
+ { file: 'ov-share.js', at: /sugg-\$\{p\.username\}/, reason: 'a chip\'s key — the handle as a key' },
+ { file: 'ov-share.js', at: /give-\$\{p\.username\}/, reason: 'a chip\'s key — the handle as a key' },
+ { file: 'ov-share.js', at: /handle\(p\)|handle\(r\)/, reason: 'the @handle under the name — a handle, not the name' },
+];
+
+// A label is not a name people read: an aria-label, a title, an alt or a placeholder says the name to a machine.
+const LABEL = (line) => line
+ .replace(/'aria-label':\s*`[^`]*`/g, '')
+ .replace(/setAttribute\(\s*'aria-label',\s*`[^`]*`\s*\)/g, '')
+ .replace(/\baria-label="[^"]*"/g, '')
+ .replace(/\btitle="[^"]*"/g, '')
+ .replace(/\balt="[^"]*"/g, '')
+ .replace(/\bplaceholder="[^"]*"/g, '');
+
+for (const file of FILES) {
+ const src = fs.readFileSync(path.join(ROOT, file), 'utf8');
+ const lines = src.split('\n');
+ let names = 0;
+ lines.forEach((line, i) => {
+ const t = line.trim();
+ if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return; // a comment says nothing into markup
+ const said = LABEL(line); // labels are not names people read
+ if (!NAME.test(said) || !WRITES.test(said)) return;
+ const where = `${file}:${i + 1}`;
+ const skip = SKIP.find((s) => s.file === file && s.at.test(line));
+ if (skip) {
+ assert.ok(skip.reason && skip.reason.length > 10, `${where}: a skip without a reason`);
+ return;
+ }
+ assert.ok(SUBJECT.test(line), `${where}: a person's name is written into markup without data-ov-subject — ${t.slice(0, 120)}`);
+ names++;
+ });
+ // A renderer that draws people's names must actually dress them (never lose the tagging by accident).
+ if (['search.js', 'dock-share.js', 'ov-thread.js'].includes(file)) continue; // these draw no person's name
+ assert.ok(names > 0, `${file}: no name carries a subject any more`);
+}
+
+// The sweep knows what it reads: a file that disappears is a failure, not a silent pass.
+assert.ok(fs.existsSync(path.join(ROOT, 'items.js')), 'items.js is what dresses a name');
+assert.ok(fs.existsSync(path.join(ROOT, 'items.css')), 'items.css is what draws the effect, the hat and the badge');
+
+// ── 2. the behaviour: OV.names(scope) ─────────────────────────────────────────────────────────────────────────
+// The stub style of test/viewer-model.test.js: a browser made of plain objects, no network.
+function stubElement(tag) {
+ return {
+ tagName: tag || '', style: {}, dataset: {}, attrs: {}, children: [], className: '', id: '', innerHTML: '', textContent: '',
+ classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
+ setAttribute(k, v) { this.attrs[k] = String(v); }, getAttribute(k) { return this.attrs[k] ?? null; },
+ appendChild(c) { this.children.push(c); return c; }, prepend(c) { this.children.unshift(c); }, remove() {},
+ addEventListener() {}, removeEventListener() {}, dispatchEvent() {},
+ querySelector() { return null; }, querySelectorAll() { return []; },
+ };
+}
+function load() {
+ const scripts = [];
+ const links = [];
+ global.location = { hostname: 'openvibe.games', href: 'https://openvibe.games/', pathname: '/', protocol: 'https:' };
+ global.window = { location: global.location };
+ try { Object.defineProperty(global, 'navigator', { value: { userAgent: 'test' }, configurable: true }); } catch { /* */ }
+ const html = stubElement('html');
+ html.style = { setProperty() {}, removeProperty() {} };
+ global.document = {
+ cookie: '', title: 'T', readyState: 'complete', visibilityState: 'visible', documentElement: html, body: stubElement('body'),
+ head: { appendChild(n) { if (String(n.tagName).toLowerCase() === 'script') scripts.push(n); else links.push(n); return n; } },
+ getElementById: () => null,
+ querySelector: (sel) => (sel === 'link[data-ov-items-css]' ? (links[0] || null) : null),
+ querySelectorAll: () => [],
+ createElement: (tag) => stubElement(tag),
+ addEventListener() {}, removeEventListener() {}, dispatchEvent() {},
+ };
+ global.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i && i.detail; } };
+ global.localStorage = { getItem: () => null, setItem() {}, removeItem() {} };
+ global.sessionStorage = { getItem: () => null, setItem() {}, removeItem() {} };
+ global.matchMedia = (q) => ({ matches: false });
+ global.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) });
+ delete global.OV;
+ delete global.OpenVibeItems;
+ delete require.cache[require.resolve(path.join(ROOT, 'navbar.js'))];
+ const navbar = require(path.join(ROOT, 'navbar.js'));
+ return { navbar, scripts, links };
+}
+const tick = () => new Promise((resolve) => setImmediate(resolve));
+// A scope: a part of a page. `withSubject` says whether a name with a subject is under it.
+const scope = (withSubject) => ({
+ querySelector: (sel) => (withSubject && sel === '[data-ov-subject]' ? {} : null),
+ querySelectorAll: () => [],
+});
+
+(async () => {
+ // A scope with no subject: nothing is dressed, and items.js is not even fetched.
+ {
+ const { navbar, scripts } = load();
+ let dressed = 0;
+ global.OpenVibeItems = { decorate: () => { dressed++; return 0; } };
+ assert.strictEqual(navbar.names(scope(false)), 0, 'a scope with no subject dresses nothing');
+ assert.strictEqual(dressed, 0, 'decorate is not called for a scope with no subject');
+ assert.strictEqual(scripts.filter((s) => /items\.js/.test(s.src)).length, 0, 'items.js is not loaded for a scope with no subject');
+ }
+
+ // A scope with a subject: items.js is loaded once, and decorate is called with the scope.
+ {
+ const { navbar, scripts, links } = load();
+ const seen = [];
+ global.OpenVibeItems = undefined; // items.js has not arrived yet
+ const one = scope(true), two = scope(true);
+ assert.strictEqual(navbar.names(one), 0, 'the names wait for items.js');
+ assert.strictEqual(navbar.names(two), 0, 'the names wait for items.js');
+ const loaded = scripts.filter((s) => /items\.js/.test(s.src));
+ assert.strictEqual(loaded.length, 1, 'items.js is loaded once a page, not once a name');
+ assert.ok(links.some((l) => /items\.css/.test(l.href)), 'items.css is loaded beside it');
+ global.OpenVibeItems = { decorate: (s) => { seen.push(s); return 1; } };
+ loaded[0].onload();
+ await tick();
+ assert.deepStrictEqual(seen, [one, two], 'decorate is called with the scope that was asked for');
+ }
+
+ // items.js is already there: the names are dressed at once, with no second copy of the script.
+ {
+ const { navbar, scripts } = load();
+ const seen = [];
+ global.OpenVibeItems = { decorate: (s) => { seen.push(s); return 1; } };
+ const one = scope(true);
+ assert.strictEqual(navbar.names(one), 1, 'the names are dressed at once');
+ assert.deepStrictEqual(seen, [one], 'decorate is called with the scope');
+ assert.strictEqual(scripts.filter((s) => /items\.js/.test(s.src)).length, 0, 'items.js is not loaded again');
+ }
+
+ // The one call every renderer makes: OV.names(scope) — the same thing, under its short name.
+ {
+ const { navbar } = load();
+ assert.strictEqual(typeof navbar.names, 'function', 'OpenVibeNavbar.names(scope) is the one call');
+ assert.strictEqual(typeof global.OV.names, 'function', 'OV.names(scope) is the one call');
+ const seen = [];
+ global.OpenVibeItems = { decorate: (s) => { seen.push(s); return 1; } };
+ const one = scope(true);
+ global.OV.names(one);
+ assert.deepStrictEqual(seen, [one], 'OV.names decorates the scope');
+ }
+
+ // It never throws: a name stays plain when anything fails.
+ {
+ const { navbar } = load();
+ global.OpenVibeItems = { decorate: () => { throw new Error('the inventory is down'); } };
+ assert.doesNotThrow(() => navbar.names(scope(true)), 'a failing inventory leaves the name plain');
+ assert.doesNotThrow(() => navbar.names(null), 'no scope is no problem');
+ assert.doesNotThrow(() => navbar.names({}), 'a scope without a query is no problem');
+ }
+
+ console.log('names-decorated: ok');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
const ICONS = require('../nav-icons');
-const NAVBAR_BROTLI_BUDGET = 53.6 * 1024; // 53.3 KB with the viewer model (4.243.0, 2026-11-13, plan §Z.2: OV.viewer(), data-ov-viewer and the openvibe-viewer event); before, 52.3 KB with the menu button drawn from the menu (4.240.0, 2026-11-13: the owner's "regenerate this icon … based on what the menu actually looks like … glow"); before: // 51.6 KB with menu addresses (data-ov-menu, #ov-menu=, menuAddress; 4.229.0, 2026-11-12: the owner's "other tools and buttons open specific areas of the menu"; budgets grow for a feature); before that: 50.9 KB with Back to the menu, the links' warm-up and token() (4.229.0, 2026-11-12: the owner's back button and fast links; budgets grow for a feature); before that: 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
+const NAVBAR_BROTLI_BUDGET = 54.4 * 1024; // 54.2 KB with the names wearing what their people wear (4.244.0, 2026-11-13, plan §Z.3: OV.names(scope) and the items loader); before: 53.3 KB with the viewer model (4.243.0, 2026-11-13, plan §Z.2: OV.viewer(), data-ov-viewer and the openvibe-viewer event); before, 52.3 KB with the menu button drawn from the menu (4.240.0, 2026-11-13: the owner's "regenerate this icon … based on what the menu actually looks like … glow"); before: // 51.6 KB with menu addresses (data-ov-menu, #ov-menu=, menuAddress; 4.229.0, 2026-11-12: the owner's "other tools and buttons open specific areas of the menu"; budgets grow for a feature); before that: 50.9 KB with Back to the menu, the links' warm-up and token() (4.229.0, 2026-11-12: the owner's back button and fast links; budgets grow for a feature); before that: 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
// ── size budget and generated block ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{
@@ -309,6 +309,23 @@ const safeHttps = (value) => { try { const url = new URL(String(value)); return
@@ -309,6 +309,23 @@ const safeHttps = (value) => { try { const url = new URL(String(value)); return
const author = (c) => c.author || c;
const author = (c) => c.author || c;
const username = (c) => author(c).username || c.username || '';
const username = (c) => author(c).username || c.username || '';
const name = (c) => author(c).display_name || c.display_name || username(c) || c.anon_name || 'Member';
const name = (c) => author(c).display_name || c.display_name || username(c) || c.anon_name || 'Member';
+// §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the answer carries is what is used.
+const OVC_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ovcSubjectOf = (c) => {
+ if (!c || typeof c !== 'object') return null;
+ const a = author(c) || {};
+ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {
+ const v = a[key] != null ? a[key] : c[key];
+ if (typeof v === 'string' && OVC_SUBJECT_RE.test(v)) return v;
+ if (v && typeof v === 'object' && typeof v.id === 'string' && OVC_SUBJECT_RE.test(v.id)) return v.id;
+ }
+ return null;
+};
+// §Z.3: the name wears its effect, hat and badge. The name text sits in its own span, so the hat stands beside it.
+const ovcNameMarkup = (who, words) => {
+ const subject = ovcSubjectOf(who);
+ return `<span${subject ? ` data-ov-subject="${subject}"` : ''}>${esc(words)}</span>`;
+};
const date = (iso) => {
const date = (iso) => {
const ms = Date.parse(String(iso || ''));
const ms = Date.parse(String(iso || ''));
if (!Number.isFinite(ms)) return { short: '', full: '' };
if (!Number.isFinite(ms)) return { short: '', full: '' };
@@ -368,7 +385,7 @@ function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowed
@@ -368,7 +385,7 @@ function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowed
const when = date(c.created_at);
const when = date(c.created_at);
const targetId = ID.test(String(c.reply_to_id || '')) ? String(c.reply_to_id) : null;
const targetId = ID.test(String(c.reply_to_id || '')) ? String(c.reply_to_id) : null;
const topId = root || id;
const topId = root || id;
- const target = targetId && c.reply_to ? `<a class="ovc-target" href="${query(base, sort, { replies: topId }, `comment-${targetId}`)}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="14" height="14" style="margin-right:4px;vertical-align:-2px"><path d="M9.2 5.8L4 11l5.1 5.1M4 11h10.3a6.2 6.2 0 0 1 6.2 6.2v1"/></svg>@${esc(c.reply_to.display_name || 'Member')}</a>` : '';
+ const target = targetId && c.reply_to ? `<a class="ovc-target" href="${query(base, sort, { replies: topId }, `comment-${targetId}`)}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="14" height="14" style="margin-right:4px;vertical-align:-2px"><path d="M9.2 5.8L4 11l5.1 5.1M4 11h10.3a6.2 6.2 0 0 1 6.2 6.2v1"/></svg>@${ovcNameMarkup(c.reply_to, c.reply_to.display_name || 'Member')}</a>` : '';
const controls = !gone ? `<div class="ovc-actions">${signedIn ? `<a class="ovk-btn ovk-btn--quiet" href="${query(base, sort, { reply: id }, `comment-${id}`)}" data-ovc-reply="${id}">Reply</a>` : ''}${c.can_delete && signedIn ? `<form method="post" action="${esc(remove)}"><input type="hidden" name="comment_id" value="${id}"><button class="ovk-btn ovk-btn--quiet" type="submit">Delete</button></form>` : ''}</div>` : '';
const controls = !gone ? `<div class="ovc-actions">${signedIn ? `<a class="ovk-btn ovk-btn--quiet" href="${query(base, sort, { reply: id }, `comment-${id}`)}" data-ovc-reply="${id}">Reply</a>` : ''}${c.can_delete && signedIn ? `<form method="post" action="${esc(remove)}"><input type="hidden" name="comment_id" value="${id}"><button class="ovk-btn ovk-btn--quiet" type="submit">Delete</button></form>` : ''}</div>` : '';
let replies = '';
let replies = '';
if (!root) {
if (!root) {
@@ -376,7 +393,7 @@ function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowed
@@ -376,7 +393,7 @@ function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowed
const more = Math.max(0, Number(c.reply_count || 0) - rows.length);
const more = Math.max(0, Number(c.reply_count || 0) - rows.length);
replies = `<div class="ovc-replies" data-ovc-replies="${id}">${rows.map((r) => comment(r, id)).join('')}${more ? `<a class="ovc-more ovk-btn ovk-btn--quiet" data-ovc-more="${id}" href="${query(base, sort, { replies: id })}">${more} more ${more === 1 ? 'reply' : 'replies'}</a>` : ''}</div>`;
replies = `<div class="ovc-replies" data-ovc-replies="${id}">${rows.map((r) => comment(r, id)).join('')}${more ? `<a class="ovc-more ovk-btn ovk-btn--quiet" data-ovc-more="${id}" href="${query(base, sort, { replies: id })}">${more} more ${more === 1 ? 'reply' : 'replies'}</a>` : ''}</div>`;
}
}
- return `<article class="ovc-comment" id="comment-${id}"><header>${avatar ? `<img src="${esc(avatar)}" alt="" width="32" height="32" loading="lazy">` : ''}${profile ? `<a href="${profile}">${esc(name(c))}</a>` : `<span>${esc(name(c))}</span>`}${when.short ? `<time datetime="${esc(c.created_at)}" title="${esc(when.full)}">${esc(when.short)}</time>` : ''}</header>${target}${gone ? '<p>Comment removed</p>' : `<p>${esc(c.message || '')}</p>${attachments(c)}`}${controls}${validReply === id ? `<div data-ovc-composer-slot>${composer_html(id)}</div>` : ''}${replies}</article>`;
+ return `<article class="ovc-comment" id="comment-${id}"><header>${avatar ? `<img src="${esc(avatar)}" alt="" width="32" height="32" loading="lazy">` : ''}${profile ? `<a href="${profile}">${ovcNameMarkup(c, name(c))}</a>` : ovcNameMarkup(c, name(c))}${when.short ? `<time datetime="${esc(c.created_at)}" title="${esc(when.full)}">${esc(when.short)}</time>` : ''}</header>${target}${gone ? '<p>Comment removed</p>' : `<p>${esc(c.message || '')}</p>${attachments(c)}`}${controls}${validReply === id ? `<div data-ovc-composer-slot>${composer_html(id)}</div>` : ''}${replies}</article>`;
};
};
const hasReplySlot = validReply && comments.some((c) => String(c.id) === validReply || (validExpanded === String(c.id) && Array.isArray(expandedReplies) ? expandedReplies : c.replies || []).some((r) => String(r.id) === validReply));
const hasReplySlot = validReply && comments.some((c) => String(c.id) === validReply || (validExpanded === String(c.id) && Array.isArray(expandedReplies) ? expandedReplies : c.replies || []).some((r) => String(r.id) === validReply));
// Top and Newest only when there is something to put in order.
// Top and Newest only when there is something to put in order.
@@ -14,6 +14,20 @@
@@ -14,6 +14,20 @@
let _cache = new Map();
let _cache = new Map();
const _menuItems = new Map();
const _menuItems = new Map();
const escapeHtml = value => String(value == null ? '' : value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
const escapeHtml = value => String(value == null ? '' : value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
+ // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the card carries is what is used.
+ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+ const subjectOf = (data) => {
+ if (!data || typeof data !== 'object') return null;
+ for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {
+ const v = data[key];
+ if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;
+ if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;
+ }
+ return null;
+ };
+ const subjectAttr = (data) => { const s = subjectOf(data); return s ? ` data-ov-subject="${s}"` : ''; };
+ // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.
+ const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };
function injectStyles() {
function injectStyles() {
if (document.getElementById('openvibe-usercard-styles')) return;
if (document.getElementById('openvibe-usercard-styles')) return;
@@ -155,7 +169,7 @@
@@ -155,7 +169,7 @@
<img class="openvibe-usercard-avatar" src="${avatarUrl}" alt="${user.username}" width="56" height="56" decoding="async" onerror="this.style.display='none'">
<img class="openvibe-usercard-avatar" src="${avatarUrl}" alt="${user.username}" width="56" height="56" decoding="async" onerror="this.style.display='none'">
<div class="openvibe-usercard-body">
<div class="openvibe-usercard-body">
<div class="openvibe-usercard-name">
<div class="openvibe-usercard-name">
- <span class="${nameClass}" style="${nameColor}">${user.display_name || user.username}</span>
+ <span class="${nameClass}" style="${nameColor}"${subjectAttr(user)}>${escapeHtml(user.display_name || user.username)}</span>
${roleBadge}
${roleBadge}
</div>
</div>
<div class="openvibe-usercard-username">@${user.username}${data.anon_number ? ` · Anon #${data.anon_number}` : ''}</div>
<div class="openvibe-usercard-username">@${user.username}${data.anon_number ? ` · Anon #${data.anon_number}` : ''}</div>
@@ -180,6 +194,8 @@
@@ -180,6 +194,8 @@
document.body.appendChild(overlay);
document.body.appendChild(overlay);
document.body.appendChild(card);
document.body.appendChild(card);
_cardEl = { card, overlay };
_cardEl = { card, overlay };
+ // §Z.3: the name in the card wears its effect, hat and badge.
+ wearNames(card);
// Action handlers
// Action handlers
card.querySelectorAll('[data-act]').forEach(btn => {
card.querySelectorAll('[data-act]').forEach(btn => {