openvibers/openvibe.shared Commits

Shared 4.244.0: every name wears what its person wears (§Z.3) (#530)

OP OpenVibers committed 12 h ago · 292398ad04 · 21 files, +535 −35

* Names wear their effect, hat and badge everywhere (§Z.3)

* Navbar budget 54.4 KB brotli: the names wear their effects (4.244.0)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b75311b..4652e30 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 …
ddiff --git a/README.md b/README.md
iindex 4103cc3..0b332fa 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex a1672b8..1f62a94 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/account-switcher.js b/account-switcher.js
iindex 036dba1..c57f8bb 100644
---- a/account-switcher.js
++++ b/account-switcher.js
@@@ -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 events
panel.querySelectorAll('.openvibe-switcher-item[data-id]').forEach(el => {
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex 3b7163f..3e1c8e4 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -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 box
form.setAttribute('data-ovc-no-files', '');
ddiff --git a/dock.js b/dock.js
iindex 48e074c..a427918 100644
---- a/dock.js
++++ b/dock.js
@@@ -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(() => { /* */ });
ddiff --git a/footer.js b/footer.js
iindex 4161feb..828214a 100644
---- a/footer.js
++++ b/footer.js
@@@ -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;opacity
const 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)));
ddiff --git a/messages.js b/messages.js
iindex 297077a..7e0dfbc 100644
---- a/messages.js
++++ b/messages.js
@@@ -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 column
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';
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 };
ddiff --git a/navbar.js b/navbar.js
iindex ab21679..5b5868b 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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);
ddiff --git a/ov-composer.js b/ov-composer.js
iindex 1694e1f..95e5a33 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -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] .ovc
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);
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;
ddiff --git a/ov-share.js b/ov-share.js
iindex 4f0930f..4f23838 100644
---- a/ov-share.js
++++ b/ov-share.js
@@@ -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)}.`));
ddiff --git a/ov-thread.js b/ov-thread.js
iindex 31432c4..a2863ff 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -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);
});
}
ddiff --git a/ov-user.js b/ov-user.js
iindex 4c464f5..abc61b4 100644
---- a/ov-user.js
++++ b/ov-user.js
@@@ -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 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); }
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); }
ddiff --git a/package-lock.json b/package-lock.json
iindex 410ce42..5a27843 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 5ac1e29..7fb1836 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/profile-panel.js b/profile-panel.js
iindex e602f45..5a0104a 100644
---- a/profile-panel.js
++++ b/profile-panel.js
@@@ -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);
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex 0e31428..27cdbbf 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -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'] },
ddiff --git a/test/names-decorated.test.js b/test/names-decorated.test.js
nnew file mode 100644
iindex 0000000..53ce8d0
---- /dev/null
++++ b/test/names-decorated.test.js
@@@ -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); });
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex 8958209..3fb8fbb 100644
---- a/test/nav-icons.test.js
++++ b/test/nav-icons.test.js
@@@ -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 ──────────────────────────
{
ddiff --git a/thread.js b/thread.js
iindex f99bb2a..e409b2e 100644
---- a/thread.js
++++ b/thread.js
@@@ -309,6 +309,23 @@ const safeHttps = (value) => { try { const url = new URL(String(value)); return
const 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, attachmentsAllowed
const 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, attachmentsAllowed
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>`;
}
-- 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.
ddiff --git a/user-card.js b/user-card.js
iindex f0fd3a6..62cd46c 100644
---- a/user-card.js
++++ b/user-card.js
@@@ -14,6 +14,20 @@
let _cache = new Map();
const _menuItems = new Map();
const escapeHtml = value => String(value == null ? '' : value).replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 handlers
card.querySelectorAll('[data-act]').forEach(btn => {