openvibers/openvibe.shared Commits

4.193.0: the bar's right end, one tooltip, one frame for mail (#422)

OP OpenVibers committed 3 days ago · 8a5540f257 · 19 files, +525 −51

* 4.193.0: the bar's right end, one tooltip (ov-tip.js), one frame for mail (email.js)

The menu button at the right on a computer too, with no second one beside the name. Sign in is an icon the size of the picture it stands in for, named by its tooltip. ov-tip.js (OVTip): the network's one small tooltip. email.js: one frame for every mail, in the person's theme, with a tone that is a lean and a glow, never a hard border. mini.js says when a file cannot play. The wallet says what a line was for (the content of #416).

* mini.js plays what the page plays: the playlist first, the file as its fallback

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex f860278..8d22279 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,6 +1,43 @@
# Changelog
--## 4.192.0 — 2026-11-11
++## 4.193.0 — 2026-11-10
++
++**The bar's right end, one tooltip, and one frame for mail.** From the owner's notes of 2026-11-10.
++
++- **The menu button is at the right on a computer too** ("The menu button should be on the right, even on desktop …
++ there's like a duplicated icon"). The orb at the bar's right is drawn on every width, and the one beside the name is
++ not drawn where it is: the left end is the logo (the network's sites) and the name (this site's pages), the right
++ end the menu and then the person. A second press closes the menu.
++- **Sign in is an icon** ("This can be majorly improved with a custom icon. Doesn't need the label really, and could
++ have a tooltip"). A filled circle with a figure in it, the size of the picture it stands in for, in the readable
++ accent pair; the figure arrives on load, lifts under the pointer, and the ring runs while the sign-in page opens. Its
++ name is its own (`aria-label="Sign in"`) and its tooltip. A finger's area is 44 px and stays off the menu button's.
++- **New `ov-tip.js` (`OVTip`): the network's one small tooltip.** Anything with `data-ov-tip` (and an optional quiet
++ second line, `data-ov-tip-sub`) gets a small card that grows out of what it names, under it or above, kept on the
++ screen. A mouse sees it after a short rest (the next one at once), the keyboard's focus at once; a press, a scroll,
++ Escape or leaving hides it; a finger never sees it, so nothing is said only there. The bar loads it where a pointer
++ can hover; the menu button carries one too (its native `title` made a second, plain one).
++- **New `email.js`: one frame for every mail** ("Improve the entire email system so it uses openvibe.design … uses the
++ theme applied by the user … explain more details … what they can do, what they can click"). `frame({ theme, site,
++ tone, kicker, title, lead, body, facts, code, actions, next, why, manage, preheader })` returns `{ html, text }`: the
++ person's theme written on the elements (all fifty read at 4.5:1 or better), a title that says what happened, the
++ facts as a list, one button and quieter links, what happens next, and why it came. Text only and safe links only. A
++ tone is never a hard coloured border ("instead of just like this red … border … shadow, glow"): the card leans toward
++ the tone at its top and glows in it, the kicker is a pill with a round mark, the facts stand in a box with a tinted
++ hairline. `palette(theme)` and `mix(a, b, t)` are exported for a site's own blocks.
++- **`mini.js` plays what the page plays.** Found on openvibe.video the hour it shipped: the page's script gives the
++ video its streaming playlist, and one recording's plain file stopped with a decode error three seconds in. The small
++ player now takes the playlist (`data-ov-mini-hls`) where the browser plays one by itself, in the page's cross-origin
++ mode, and the plain file (`data-ov-mini-src`) otherwise; a playlist that fails gives way to the file at the same
++ second. A video that cannot be played either says so ("Open its page to keep watching"), keeps the way back, and
++ does not follow to the next page; it used to close without a word.
++- **The wallet says what a line was for** (OpenVibe.Shared #416, from the money-reasons work): the reason is a made
++ sentence, linked to the thing it paid for.
++- The 4.192.0 entry's date is corrected (it said the 11th).
++- Tests: `ov-tip-chrome`, `email` (every theme, every tone), `mini-chrome` (the cannot-play state), `navbar-fit-chrome`
++ (the menu button at the right at every width, no second one by the name), the tap-area tests with the round Sign in.
++
++## 4.192.0 — 2026-11-10
**The video that comes with you.** The owner, 2026-11-10, with the browser's floating picture-in-picture window over a
watch page: "should … actually [be] like YouTube where it moves that video. Now you go to the search again or the index
ddiff --git a/README.md b/README.md
iindex 2cc152e..8b79b3c 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.192.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.193.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 4114d6b..b103a3e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.192.0",
++ "version": "4.193.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/email.js b/email.js
nnew file mode 100644
iindex 0000000..cc65b6e
---- /dev/null
++++ b/email.js
@@@ -0,0 +1,118 @@
++'use strict';
++/*
++ * openvibe-shared/email — one frame for every mail the network sends (the owner, 2026-11-10, with a "Browser check
++ * failed" mail open: "Improve the entire email system so it uses openvibe.design … uses the theme applied by the user
++ * … explain more details … show the words, what they can do, what they can click").
++ *
++ * const mail = require('openvibe-shared/email');
++ * const { html, text } = mail.frame({
++ * theme: 'vibe', // a built-in theme's id (the person's own), or its { mode, variables }
++ * site: { name: 'OpenVibe', url: 'https://openvibe.network', logo: 'https://…/logo-192.png' },
++ * tone: 'alert', // 'plain' | 'alert' | 'warning' | 'good'
++ * kicker: 'Still failing', // a few words over the title (optional)
++ * title: 'openvibe.domains: 1 problem found by the browser check',
++ * lead: 'After release 74bdff4, the automatic check found a problem on the page /.',
++ * body: ['Two fold headings have no name a screen reader can say.'],
++ * facts: [{ label: 'Page', value: '/', href: 'https://openvibe.domains/' }, { label: 'Release', value: '74bdff4' }],
++ * code: '482 913', // a code to type somewhere (sign-in, two-step), drawn large (optional)
++ * actions: [{ label: 'Open the page', href: 'https://…' }, { label: 'See the release', href: 'https://…' }],
++ * next: ['The check runs again after every release and once a day.'],
++ * why: 'You get this because you run this site.',
++ * manage: { label: 'Change what is mailed to you', href: 'https://openvibe.network/notifications' },
++ * preheader: 'Two fold headings have no name.', // the line a mail list shows beside the subject
++ * });
++ *
++ * What a mail client allows decides the build: tables for layout, every style written on its element, no script, no
++ * web font, no SVG (the mark is a hosted PNG, with the site's name beside it as text), nothing that moves. The colours
++ * are the person's theme (dark or light as the theme is; the client is told so and does not invert it). A mail's tone is
++ * never a hard coloured border: the card leans toward the tone at its top and glows in it, the kicker is a pill with a
++ * round mark, the facts stand in a box with a tinted hairline. Where a client draws no shadow or gradient the tints and
++ * hairlines remain. Every string is
++ * text: it is escaped, and a link is kept only if it is https or mailto. The first action is the one button; the others
++ * are plain links under it. `text` says the same in plain words for clients that show no HTML.
++ */
++const themes = require('./builtin-themes');
++
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++const link = (u) => { const s = String(u == null ? '' : u).trim(); return /^https:\/\/[^\s<>"']+$/i.test(s) || /^mailto:[^\s<>"']+$/i.test(s) ? s : ''; };
++const rgb = (h) => { const x = String(h).replace('#', ''); const f = x.length === 3 ? x.split('').map((ch) => ch + ch).join('') : x.slice(0, 6); return [0, 2, 4].map((i) => parseInt(f.slice(i, i + 2), 16)); };
++/** A colour part of the way from one to another, as a plain hex: a mail client has no colour mixing and little transparency. */
++const mix = (a, b, t) => { const q = rgb(b); return '#' + rgb(a).map((v, i) => Math.round(v + (q[i] - v) * t).toString(16).padStart(2, '0')).join(''); };
++const ratio = (a, b) => themes.contrast(themes.hexToRgb(a), themes.hexToRgb(b));
++const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif";
++const MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace";
++
++/** The colours a mail is written in, from a theme's id or its { mode, variables }. An unknown id is the default theme. */
++function palette(theme) {
++ let t = theme && typeof theme === 'object' ? theme : themes.BUILTIN_THEMES.find((x) => x.id === theme || x.slug === theme);
++ if (!t || !t.variables) t = themes.BUILTIN_THEMES.find((x) => x.id === themes.DEFAULT_THEME) || themes.BUILTIN_THEMES[0];
++ const mode = t.mode === 'light' ? 'light' : 'dark';
++ const v = { ...t.variables, ...themes.deriveTokens(t.variables, mode) };
++ const pick = (k, d) => (/^#[0-9a-f]{3,8}$/i.test(String(v[k] || '')) ? v[k] : d);
++ const dark = mode === 'dark';
++ return {
++ mode,
++ page: pick('--bg-primary', dark ? '#0a0f1c' : '#f4f6fb'), card: pick('--bg-card', dark ? '#131c2e' : '#ffffff'), well: pick('--bg-secondary', dark ? '#101828' : '#f1f4f9'),
++ line: pick('--border', dark ? '#1f2d47' : '#d9e0ec'), text: pick('--text-primary', dark ? '#e6edf7' : '#0f172a'), dim: pick('--text-secondary', dark ? '#96a7c2' : '#475569'), faint: pick('--text-muted', dark ? '#8192ac' : '#64748b'),
++ accent: pick('--accent-light', pick('--accent', '#3b82f6')), button: pick('--accent-strong', '#3472d8'), onButton: pick('--on-accent-strong', '#ffffff'),
++ alert: pick('--danger', '#ef4444'), warning: pick('--warning', '#f59e0b'), good: pick('--success', '#22c55e'),
++ };
++}
++
++/** One mail: { html, text }. See the head of this file for what it takes. */
++function frame(o = {}) {
++ const c = palette(o.theme);
++ const site = { name: 'OpenVibe', url: 'https://openvibe.network', ...(o.site || {}) };
++ const tone = ['alert', 'warning', 'good'].includes(o.tone) ? o.tone : 'plain';
++ const mark = tone === 'plain' ? c.accent : c[tone];
++ const tint = (t) => mix(c.card, mark, t); // the card, leaning toward the tone
++ let ink = mark; // the tone as words on its own tint: readable in every theme
++ for (let i = 0; i < 8 && ratio(ink, tint(0.14)) < 4.5; i += 1) ink = mix(ink, c.text, 0.25);
++ const onMark = ratio(mark, '#ffffff') >= ratio(mark, c.page) ? '#ffffff' : c.page;
++ const glyph = { alert: '!', warning: '!', good: '&#10003;', plain: 'i' }[tone];
++ const body = (Array.isArray(o.body) ? o.body : o.body ? [o.body] : []).filter(Boolean);
++ const facts = (o.facts || []).filter((f) => f && f.label && f.value != null && f.value !== '');
++ const actions = (o.actions || []).map((a) => ({ label: a && a.label, href: link(a && a.href) })).filter((a) => a.label && a.href);
++ const next = (Array.isArray(o.next) ? o.next : o.next ? [o.next] : []).filter(Boolean);
++ const manage = o.manage && link(o.manage.href) ? { label: o.manage.label || 'Change what is mailed to you', href: link(o.manage.href) } : null;
++ const home = link(site.url) || 'https://openvibe.network';
++ const logo = link(site.logo);
++ const p = (s, style) => `<p style="margin:0 0 14px;font:400 15px/1.6 ${FONT};color:${c.dim};${style || ''}">${esc(s)}</p>`;
++ const a = (label, href, style) => `<a href="${esc(href)}" style="color:${c.accent};text-decoration:underline;${style || ''}">${esc(label)}</a>`;
++
++ const head = `<tr><td style="padding:22px 28px 0"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>${logo ? `<td style="padding-right:10px"><a href="${esc(home)}"><img src="${esc(logo)}" width="32" height="32" alt="" style="display:block;border:0;border-radius:8px"></a></td>` : ''}<td><a href="${esc(home)}" style="font:700 16px/1 ${FONT};color:${c.text};text-decoration:none;letter-spacing:.1px">${esc(site.name)}</a></td></tr></table></td></tr>`;
++ const title = `<tr><td style="padding:22px 28px 0">${o.kicker ? `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 12px"><tr><td style="padding:4px 12px 4px 5px;border-radius:999px;background:${tint(0.14)};border:1px solid ${tint(0.34)};font:700 12px/18px ${FONT};color:${ink};white-space:nowrap"><span style="display:inline-block;width:18px;height:18px;margin-right:7px;border-radius:50%;background:${mark};color:${onMark};font:700 12px/18px ${FONT};text-align:center;vertical-align:top">${glyph}</span>${esc(o.kicker)}</td></tr></table>` : ''}<h1 style="margin:0 0 12px;font:700 21px/1.3 ${FONT};color:${c.text}">${esc(o.title || '')}</h1>${o.lead ? p(o.lead, `color:${c.text};`) : ''}${body.map((s) => p(s)).join('')}</td></tr>`;
++ const code = o.code ? `<tr><td style="padding:2px 28px 16px"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr><td align="center" style="padding:18px 12px;border-radius:12px;background:${c.well};border:1px solid ${c.line};font:700 30px/1.2 ${MONO};letter-spacing:6px;color:${c.text}">${esc(o.code)}</td></tr></table></td></tr>` : '';
++ const factRows = facts.map((f, i) => `<tr><td valign="top" style="padding:9px 14px 9px 16px;width:34%;font:600 13px/1.5 ${FONT};color:${c.faint};${i ? `border-top:1px solid ${c.line};` : ''}">${esc(f.label)}</td><td valign="top" style="padding:9px 16px 9px 0;font:400 14px/1.5 ${FONT};color:${c.text};word-break:break-word;${i ? `border-top:1px solid ${c.line};` : ''}">${link(f.href) ? a(f.value, link(f.href)) : esc(f.value)}</td></tr>`).join('');
++ const factsBlock = facts.length ? `<tr><td style="padding:2px 28px 18px"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="border-radius:14px;background:${c.well};border:1px solid ${tint(0.3)};box-shadow:0 12px 30px -18px ${mark}">${factRows}</table></td></tr>` : '';
++ const first = actions[0];
++ const actionsBlock = first ? `<tr><td style="padding:2px 28px 6px"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="border-radius:12px;background:${c.button};box-shadow:0 10px 24px -12px ${c.button}"><a href="${esc(first.href)}" style="display:inline-block;padding:12px 22px;font:700 15px/1.2 ${FONT};color:${c.onButton};text-decoration:none;border-radius:12px">${esc(first.label)} <span aria-hidden="true">&rarr;</span></a></td></tr></table></td></tr>${actions.length > 1 ? `<tr><td style="padding:8px 28px 6px;font:400 14px/1.9 ${FONT};color:${c.dim}">${actions.slice(1).map((x) => a(x.label, x.href)).join(' &nbsp;·&nbsp; ')}</td></tr>` : ''}` : '';
++ const nextBlock = next.length ? `<tr><td style="padding:12px 28px 4px"><p style="margin:0 0 6px;font:700 13px/1.4 ${FONT};color:${c.text}">What happens next</p><table role="presentation" cellpadding="0" cellspacing="0" border="0">${next.map((s) => `<tr><td valign="top" style="padding:9px 10px 0 1px"><span style="display:block;width:6px;height:6px;border-radius:50%;background:${mark}"></span></td><td style="padding:0 0 5px;font:400 14px/1.6 ${FONT};color:${c.dim}">${esc(s)}</td></tr>`).join('')}</table></td></tr>` : '';
++ const foot = `<tr><td style="padding:18px 28px 22px"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr><td style="border-top:1px solid ${c.line};padding-top:14px;font:400 12px/1.6 ${FONT};color:${c.faint}">${o.why ? `${esc(o.why)} ` : ''}${manage ? a(manage.label, manage.href, `color:${c.faint};`) : ''}${o.why || manage ? '<br>' : ''}${a(site.name, home, `color:${c.faint};text-decoration:none;`)}</td></tr></table></td></tr>`;
++
++ const html = `<!doctype html>
++<html lang="${esc(o.lang || 'en')}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="${c.mode}"><meta name="supported-color-schemes" content="${c.mode}"><title>${esc(o.title || site.name)}</title>
++<style>@media (max-width:480px){.ovm-pad>tbody>tr>td{padding-left:18px!important;padding-right:18px!important}.ovm-out{padding:12px 8px!important}}</style></head>
++<body style="margin:0;padding:0;background:${c.page};-webkit-text-size-adjust:100%">
++${o.preheader ? `<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:${c.page};font-size:1px;line-height:1px">${esc(o.preheader)}</div>` : ''}<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${c.page}"><tr><td class="ovm-out" align="center" style="padding:24px 12px">
++<table role="presentation" class="ovm-pad" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:580px;background:${c.card};background-image:linear-gradient(180deg, ${tint(0.2)} 0, ${c.card} 130px);border:1px solid ${mix(c.line, mark, 0.3)};border-radius:16px;box-shadow:0 22px 60px -30px ${mark}">
++${head}${title}${code}${factsBlock}${actionsBlock}${nextBlock}${foot}
++</table></td></tr></table></body></html>`;
++
++ const lines = [];
++ if (o.kicker) lines.push(String(o.kicker).toUpperCase());
++ lines.push(String(o.title || ''), '');
++ if (o.lead) lines.push(String(o.lead));
++ body.forEach((s) => lines.push(String(s)));
++ if (o.code) lines.push('', `Your code: ${o.code}`);
++ if (facts.length) { lines.push(''); facts.forEach((f) => lines.push(`${f.label}: ${f.value}${link(f.href) ? ` (${link(f.href)})` : ''}`)); }
++ if (actions.length) { lines.push(''); actions.forEach((x) => lines.push(`${x.label}: ${x.href}`)); }
++ if (next.length) { lines.push('', 'What happens next'); next.forEach((s) => lines.push(`- ${s}`)); }
++ lines.push('', '--');
++ if (o.why) lines.push(String(o.why));
++ if (manage) lines.push(`${manage.label}: ${manage.href}`);
++ lines.push(`${site.name} · ${home}`);
++ return { html, text: lines.join('\n').replace(/\n{3,}/g, '\n\n') };
++}
++
++module.exports = { frame, palette, mix };
ddiff --git a/files.js b/files.js
iindex 5b1e204..8dbf8fd 100644
---- a/files.js
++++ b/files.js
@@@ -25,7 +25,7 @@ const BROWSER = Object.freeze([
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
'dock-runs.js', // the Dock's content-aware Agents rows and overview, loaded with that tab
'ai-marks-client.js', // maker marks on a run receipt, loaded only in Agents
-- 'presence.js', 'purse.js', 'machines.js', 'ov-drop.js', 'mini.js', 'overlay.js', 'nav-slots.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
++ 'presence.js', 'purse.js', 'machines.js', 'ov-drop.js', 'mini.js', 'ov-tip.js', 'overlay.js', 'nav-slots.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent
ddiff --git a/mini.js b/mini.js
iindex 1281cd5..9a4d58c 100644
---- a/mini.js
++++ b/mini.js
@@@ -6,7 +6,7 @@
* A watch page says its video may come along:
*
* <video data-ov-player data-ov-mini data-ov-mini-title="…" data-ov-mini-by="…" [data-ov-mini-page="/watch?v=…"]
-- * [data-ov-mini-to="/"] [data-ov-mini-src="https://…/file.mp4"] src="…">
++ * [data-ov-mini-to="/"] [data-ov-mini-src="https://…/file.mp4"] [data-ov-mini-hls="https://…/index.m3u8"] src="…">
*
* The shared player's picture-in-picture button then calls OpenVibeMini.carry(video): what is playing is written down
* (the page, the file, the title, who made it, the poster, the second, the speed, the volume, whether it was playing)
@@@ -15,6 +15,9 @@
* on by loading this file on its pages, deferred: it starts itself and does nothing without a record.) It has play and pause,
* a way back to the video's own page at that second, and a close. The page the video came from takes the second back
* and the small player is not drawn there. Starting another video on a page closes it: one sound at a time.
++ * It plays what the page plays: the streaming playlist (data-ov-mini-hls) where the browser plays one by itself, in the
++ * same cross-origin mode as the page, and the plain file otherwise; a playlist that fails gives way to the file at the
++ * same second. A file that cannot be played either is said, with the way back.
*
* The record is this tab's own (sessionStorage): it never leaves the site, so a private file's signed address is safe
* in it. Across sites it will travel another way (plan, ask 265).
@@@ -72,6 +75,8 @@
.ovmini-btn:active { transform: scale(.92); }
.ovmini-btn:focus-visible, .ovmini-back:focus-visible, .ovmini-sound:focus-visible, .ovmini-text:focus-visible { outline: 2px solid var(--mc); outline-offset: 2px; }
.ovmini-btn:last-child { margin-right: 4px; }
++.ovmini-btn[hidden] { display: none; }
++.ovmini.is-broken .ovmini-bar { visibility: hidden; }
.ovmini.is-leaving { animation: ovmini-out .18s ease both; }
@keyframes ovmini-in { from { opacity: 0; transform: translateY(14px) scale(.98); } }
@keyframes ovmini-out { to { opacity: 0; transform: translateY(10px); } }
@@@ -135,7 +140,7 @@ html[data-ov-motion=off] .ovmini-bar i { transition: none; }
const d = isFinite(video.duration) && video.duration > 0 ? video.duration : (r.dur || 0);
const pct = d ? Math.min(100, (video.currentTime / d) * 100) : 0;
bars.forEach((b) => { b.style.width = pct.toFixed(2) + '%'; });
-- by.textContent = [r.by, d ? `${clock(video.currentTime)} / ${clock(d)}` : clock(video.currentTime)].filter(Boolean).join(' · ');
++ if (!el.classList.contains('is-broken')) by.textContent = [r.by, d ? `${clock(video.currentTime)} / ${clock(d)}` : clock(video.currentTime)].filter(Boolean).join(' · ');
soundBtn.hidden = !(video.muted && on && r.wantSound);
};
el.querySelectorAll('[data-m="back"]').forEach((a) => { a.href = back(); });
@@@ -149,8 +154,14 @@ html[data-ov-motion=off] .ovmini-bar i { transition: none; }
});
video.addEventListener('timeupdate', () => { paint(); if (Date.now() - saved > 1000) { saved = Date.now(); note(r); } });
['play', 'pause', 'ended', 'volumechange', 'ratechange', 'durationchange'].forEach((t) => video.addEventListener(t, () => { paint(); note(r); }));
-- video.addEventListener('error', () => { close(); });
-- video.addEventListener('loadedmetadata', () => {
++ // A file that cannot be played here: say so, keep the way back, and do not follow the person to the next page.
++ video.addEventListener('error', () => {
++ if (!video) return;
++ // The playlist failed: the file, from the same second, once.
++ if (onPlaylist && r.src) { onPlaylist = false; if (live && video.currentTime > 0) r.t = video.currentTime; live = false; arm(); source(); return; }
++ live = false; forget(); playBtn.hidden = true; soundBtn.hidden = true; by.textContent = 'Open its page to keep watching'; el.classList.add('is-broken');
++ });
++ const arm = () => video.addEventListener('loadedmetadata', () => {
try { if (r.t > 0 && (!isFinite(video.duration) || r.t < video.duration - 0.25)) video.currentTime = r.t; } catch { /* not seekable yet: it starts where it can */ }
video.playbackRate = r.rate || 1;
const was = r.playing;
@@@ -159,9 +170,16 @@ html[data-ov-motion=off] .ovmini-bar i { transition: none; }
// With its sound where the browser lets a new page make sound; else silent, with the way to turn it on.
video.play().catch(() => { r.wantSound = !r.muted; video.muted = true; return video.play(); }).catch(() => {}).then(paint);
}, { once: true });
++ // What the page plays: its playlist where the browser plays one by itself (a signed playlist is served without
++ // CORS, as on the page), else the file.
++ let onPlaylist = !!(r.hls && video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl'));
++ const source = () => {
++ if (onPlaylist) { if (!/[?&]sig=/.test(r.hls)) video.crossOrigin = 'anonymous'; video.src = r.hls; }
++ else { video.removeAttribute('crossorigin'); video.src = r.src; }
++ };
video.volume = typeof r.vol === 'number' ? r.vol : 1;
video.muted = !!r.muted;
-- video.src = r.src;
++ arm(); source();
document.body.appendChild(el);
root.addEventListener('pagehide', leave);
document.addEventListener('play', other, true);
@@@ -191,7 +209,7 @@ html[data-ov-motion=off] .ovmini-bar i { transition: none; }
if (!src || /^blob:/.test(src)) src = /^blob:/.test(v.currentSrc || '') ? '' : (v.currentSrc || '');
if (!src) return false;
save({
-- page: d.ovMiniPage || (root.location.pathname + root.location.search), src: new URL(src, root.location.href).href,
++ page: d.ovMiniPage || (root.location.pathname + root.location.search), src: new URL(src, root.location.href).href, hls: d.ovMiniHls ? new URL(d.ovMiniHls, root.location.href).href : '',
title: d.ovMiniTitle || document.title, by: d.ovMiniBy || '', poster: v.poster || '',
t: v.currentTime || 0, dur: isFinite(v.duration) ? v.duration : 0, rate: v.playbackRate || 1, vol: v.volume, muted: v.muted,
playing: !v.paused && !v.ended, at: Date.now(),
ddiff --git a/navbar.js b/navbar.js
iindex d8c88bb..71cd45b 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -91,8 +91,8 @@
.openvibe-navbar .openvibe-navbar-brand .flame::after { content: ''; position: absolute; inset: -8px -4px -8px -12px; }
/* 4.46.0 (the fleet tap audit): Sign In, the menu and the orb reach 44px to a finger, still drawn as before. */
.openvibe-navbar .openvibe-navbar-login, .openvibe-navbar .ovnav-burger, .openvibe-navbar .ovnav-orb { position: relative; }
-- /* 4.107.2 (the 11-03 re-audit): Sign In measured 40px tall; 9px above and below makes 45. */
-- .openvibe-navbar .openvibe-navbar-login::after { content: ''; position: absolute; inset: -9px 0; }
++ /* Sign in is a 36px circle: its taps grow to the right and a little to the left, off the menu button's. */
++ .openvibe-navbar .openvibe-navbar-login::after { content: ''; position: absolute; inset: -5px -8px -5px -3px; }
.openvibe-navbar .ovnav-burger::after { content: ''; position: absolute; inset: -3px -5px; }
/* The orb grows up and down, and sideways mostly to the right: the site's name sits 8px to its left and keeps
its own taps (4px there); the extra width goes to the open bar on its right (12px), so it measures 44px
@@@ -194,7 +194,7 @@
.ovnav-chips-pop button .ovnav-ic { color: var(--chip-c, inherit); width: 16px; height: 16px; }
.ovnav-chips-pop button[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chips-pop button[data-tone="green"] { --chip-c: #4ade80; }
.ovnav-chips-pop b { margin-left: auto; font-variant-numeric: tabular-nums; }
-- @media (max-width: 860px) { .openvibe-navbar .ovnav-burger, .openvibe-navbar .ovnav-featured-phone { display: inline-flex; } .openvibe-navbar:has(.ovnav-burger) .ovnav-orb:not(.ovnav-burger) { display: none; } .openvibe-navbar .openvibe-navbar-links, .openvibe-navbar .openvibe-navbar-spacer { display: none; } .openvibe-navbar .openvibe-navbar-right { margin-left: auto; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }
++ @media (max-width: 860px) { .openvibe-navbar .ovnav-burger, .openvibe-navbar .ovnav-featured-phone { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links, .openvibe-navbar .openvibe-navbar-spacer { display: none; } .openvibe-navbar .openvibe-navbar-right { margin-left: auto; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }
@media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }
/* A narrow phone never draws the site's name over the bar's buttons (owner, 2026-10-28: on openvibe.games, signed
in, the name ran under the launcher and the magnifier). The name gives way with an ellipsis, and up to 440px
@@@ -287,6 +287,7 @@
.openvibe-navbar-right { display: flex; align-items: center; gap: 6px; }
/* The person (or Sign In) is last, after the menu button, with a little air between them. */
.openvibe-navbar .ovnav-burger + :is(.openvibe-navbar-avatar, .openvibe-navbar-login) { margin-left: 3px; }
++ .openvibe-navbar .ovnav-burger + .openvibe-navbar-login { margin-left: 6px; }
/* The OpenVibe menu's button (4.187.0; the owner's pick of 2026-11-10: a search bar over three apps). Under
the pointer its parts lift in turn; while the menu loads the apps breathe; open, the bar and the apps cross
into the close mark. Transform, opacity and fill only, and nothing moves while it rests. */
@@@ -327,10 +328,10 @@
.openvibe-navbar :is(.ovnav-orb, .openvibe-bell, .openvibe-navbar-avatar) { transition: transform .18s cubic-bezier(.2, 1.4, .3, 1), background-color .15s, color .15s; }
.openvibe-navbar :is(.ovnav-orb, .openvibe-bell, .openvibe-navbar-avatar):active { transform: scale(.9); }
.openvibe-navbar .openvibe-navbar-brand .ovnav-orb { margin-left: 1px; }
-- /* 4.148.0 (the owner: "replace the hamburger with that button"): the phone's menu button is the orb, in the
-- burger's place at the bar's right. Hidden on a wide bar (its links are there), shown at 860 px and below;
-- the orb's glow is its hover, not the burger's square. */
-- .openvibe-navbar .ovnav-orb.ovnav-burger { display: none; }
++ /* The menu button is the orb, at the bar's right on every width (4.148.0 a phone; 4.193.0 a computer too: the
++ owner, "on the right, even on desktop"). The orb beside the name is not drawn where this one is. */
++ .openvibe-navbar .ovnav-orb.ovnav-burger { display: inline-grid; }
++ .openvibe-navbar:has(.ovnav-burger) .ovnav-orb:not(.ovnav-burger) { display: none; }
/* At the bar's right edge its tap area grows to the left (the brand orb's reached 12 px right, past the screen). */
.openvibe-navbar .ovnav-orb.ovnav-burger::after { inset: -6px -4px -6px -10px; }
.openvibe-navbar .ovnav-orb.ovnav-burger:hover, .openvibe-navbar .ovnav-orb.ovnav-burger[aria-expanded="true"] { background: none; }
@@@ -353,24 +354,19 @@
}
.openvibe-navbar-avatar:hover { border-color: var(--accent, #3b82f6); }
-- .openvibe-navbar-login {
-- padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600;
-- /* --accent-strong / --on-accent-strong (builtin-themes.js deriveTokens) read at 4.5:1 or better in
-- every theme (WCAG AA); the accent itself did not (white on #3b82f6 is 3.67:1). */
-- background: var(--accent-strong, #1d4ed8); color: var(--on-accent-strong, #fff); border: none; cursor: pointer;
-- transition: filter .15s; text-decoration: none; display: inline-flex; align-items: center;
-- }
-- .openvibe-navbar-login:hover { filter: brightness(1.12); }
-- .openvibe-navbar-login { gap: 7px; }
-- .openvibe-navbar-login:active { transform: scale(.95); }
-- .ovsi { margin-left: -3px; fill: none; stroke: currentColor; stroke-width: 2.1; stroke-linecap: round; stroke-linejoin: round; overflow: visible; }
-- .ovsi-o { stroke-dasharray: 30 10.2; stroke-dashoffset: -15.1; }
-- .ovsi-a { animation: ovsi-in 7s ease-in-out 2s infinite; }
-- :hover > .ovsi .ovsi-a { animation-duration: .9s; animation-delay: 0s; }
-- .is-going .ovsi-a { opacity: 0; animation: none; }
-- .is-going .ovsi-o { animation: ovsi-go 1s linear infinite; }
-- @keyframes ovsi-in { 0%, 80% { transform: none; } 90% { transform: translateX(calc(3px * var(--ov-move, 1))); } }
-- @keyframes ovsi-go { to { stroke-dashoffset: -55.3; } }
++ /* Sign in is the person's place before there is a person: a filled circle with a figure in it, named by its
++ tooltip (ov-tip.js). --accent-strong / --on-accent-strong read at 4.5:1 or better in every theme. */
++ .openvibe-navbar-login { display: inline-grid; place-items: center; background: var(--accent-strong, #1d4ed8); color: var(--on-accent-strong, #fff); border: none; cursor: pointer; text-decoration: none; transition: filter .15s, box-shadow .25s, transform .18s cubic-bezier(.2, 1.4, .3, 1); }
++ .openvibe-navbar-login:hover, .openvibe-navbar-login:focus-visible { filter: brightness(1.12); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 28%, transparent); }
++ .openvibe-navbar-login:active { transform: scale(.9); }
++ .ovsi { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; overflow: visible; }
++ .ovsi-p { transform-origin: 12px 13px; animation: ovsi-in .5s cubic-bezier(.2, 1.4, .3, 1) .3s both; transition: transform .2s; }
++ :hover > .ovsi .ovsi-p { transform: translateY(calc(-1px * var(--ov-move, 1))); }
++ .ovsi-o { opacity: 0; stroke-dasharray: 30 39; }
++ .is-going .ovsi-o { opacity: 1; animation: ovsi-go 1s linear infinite; }
++ .is-going .ovsi-p { opacity: .55; }
++ @keyframes ovsi-in { from { opacity: 0; transform: translateY(4px) scale(.8); } }
++ @keyframes ovsi-go { to { stroke-dashoffset: -69; } }
/* 4.148.0 (the owner, 11-08: "the things need to be more consistent in the nav bar in size"): one height for
every control at the bar's right, 36 px (34 px on a phone under 400 px): Sign In, the orb, messages, the
picture, the balances and a featured link. The orb draws its dots at 26 px (24) so it reads as large as
@@@ -380,12 +376,12 @@
.openvibe-navbar .ovnav-featured-phone { width: 36px; height: 36px; }
.openvibe-navbar .openvibe-navbar-avatar { width: 36px; height: 36px; box-sizing: border-box; }
.openvibe-navbar .ovnav-chip { height: 36px; }
-- .openvibe-navbar .openvibe-navbar-login { box-sizing: border-box; height: 36px; padding: 0 16px; font-size: 14px; border-radius: 10px; }
++ .openvibe-navbar .openvibe-navbar-login { box-sizing: border-box; width: 36px; height: 36px; padding: 0; border-radius: 50%; }
@media (max-width: 400px) {
.openvibe-navbar .ovnav-orb, .openvibe-navbar .ovnav-featured-phone, .openvibe-navbar .openvibe-navbar-avatar { width: 34px; height: 34px; }
.openvibe-navbar .ovnav-orb svg { width: 24px; height: 24px; }
.openvibe-navbar .ovnav-chip, .openvibe-navbar .openvibe-navbar-login { height: 34px; }
-- .openvibe-navbar .openvibe-navbar-login { padding: 0 13px; }
++ .openvibe-navbar .openvibe-navbar-login { width: 34px; }
}
/* The account's block (4.188.0): not a popup. It rests in the bar out of sight and is a block of the menu's
@@@ -567,7 +563,7 @@
const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '04aa1cedb6b4', 'dock.js': '5754cf36a7de', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'b64ea0c39d34', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'c38f5859fff6', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': 'daf7e9cbec5d', 'ov-wallet.css': 'e44856825761', 'purse.js': '749120bfd24b', 'ov-drop.js': '0132897495f0' };
++ const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '04aa1cedb6b4', 'dock.js': '5754cf36a7de', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'b64ea0c39d34', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'c38f5859fff6', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': '5af39638e579', 'ov-wallet.css': 'bd92a321ae64', 'purse.js': '749120bfd24b', 'ov-drop.js': '0132897495f0', 'ov-tip.js': '6ac08ce47825' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@@ -992,7 +988,7 @@
}
root.OpenVibeOrb = { svg: orbSVG };
function orbHTML() {
-- return `<button type="button" class="ovnav-search ovnav-orb" id="openvibe-search-btn" aria-label="OpenVibe menu: search, every site and tool (/)" title="Search and every OpenVibe site (/)" aria-haspopup="dialog" aria-expanded="false">${orbSVG()}</button>`;
++ return `<button type="button" class="ovnav-search ovnav-orb" id="openvibe-search-btn" aria-label="OpenVibe menu: search, every site and tool (/)" data-ov-tip="Menu" data-ov-tip-sub="Search, this site, every OpenVibe site" aria-haspopup="dialog" aria-expanded="false">${orbSVG()}</button>`;
}
@@@ -1193,7 +1189,7 @@
// site that turns the menu off (search: false) or asks for it (drawer: 'legacy').
burger.addEventListener('click', (e) => {
e.stopPropagation();
-- if (_config.search !== false && _config.drawer !== 'legacy') { openSearch('', 'here', burger); return; }
++ if (_config.search !== false && _config.drawer !== 'legacy') { if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close(); else openSearch('', 'here', burger); return; }
const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); if (open) loadOvIcons(drawer);
});
drawer.addEventListener('click', (e) => {
@@@ -2118,9 +2114,9 @@
${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''}
${featuredPhoneHTML(links)}
<div id="openvibe-bell-mount"></div>
-- ${links.length ? `<button type="button" class="ovnav-burger ovnav-orb" id="openvibe-burger" aria-label="Menu: this site, search and every OpenVibe site" aria-haspopup="dialog" aria-expanded="false">${orbSVG()}</button>` : ''}
++ ${links.length ? `<button type="button" class="ovnav-burger ovnav-orb" id="openvibe-burger" aria-label="Menu: this site, search and every OpenVibe site" data-ov-tip="Menu" data-ov-tip-sub="This site, search, every OpenVibe site" aria-haspopup="dialog" aria-expanded="false">${orbSVG()}</button>` : ''}
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
-- `<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}"><svg class="ovsi" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><circle class="ovsi-o" cx="14.5" cy="12" r="6.4"/><path class="ovsi-a" d="M2.5 12h9.6M9 8.4l3.6 3.6L9 15.6"/></svg>Sign In</a>`}
++ `<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}" aria-label="Sign in" data-ov-tip="Sign in"><svg class="ovsi" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><circle class="ovsi-o" cx="12" cy="12" r="11"/><g class="ovsi-p"><circle cx="12" cy="8.8" r="3.2"/><path d="M5.900 18.400c1.100-2.700 3.300-4.100 6.100-4.100s5 1.400 6.100 4.100"/></g></svg></a>`}
</div>
${links.length ? `<div class="ovnav-drawer" id="openvibe-drawer" role="menu">${drawerOrder(links).map(l => linkHTML(l, true)).join('')}<div class="ovnav-drawer-net">${networkLinksHTML(links, true)}</div>${u ? '' : '<div class="ovnav-display" hidden></div>'}</div>` : ''}
`;
@@@ -2128,6 +2124,7 @@
bindLinks(nav, links);
hookSearch(nav);
hookSwitcher(nav); hookPages(nav); hookSignIn(nav); hookFit(nav);
++ if (!root.OVTip && !document.getElementById('ov-tip-loader') && root.matchMedia && matchMedia('(hover: hover)').matches) { try { const sc = document.createElement('script'); sc.id = 'ov-tip-loader'; sc.src = siblingV('ov-tip.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ } }
bindChips(nav);
bindChipsAll(nav);
queueIcons();
ddiff --git a/ov-tip.js b/ov-tip.js
nnew file mode 100644
iindex 0000000..5d4b356
---- /dev/null
++++ b/ov-tip.js
@@@ -0,0 +1,95 @@
++/*
++ * openvibe-shared/ov-tip.js — the network's one small tooltip (the owner, 2026-11-10, on the bar's Sign In button:
++ * "Doesn't need the label really, and could have a tooltip … that looks really nice and custom animations"; and on the
++ * footer's count: "if you put your mouse over it, it says 'last 30 minutes' … little tidbits, extra info").
++ *
++ * <button aria-label="Sign in" data-ov-tip="Sign in" data-ov-tip-sub="Chat, follow, keep your things">…</button>
++ *
++ * Anything with data-ov-tip gets it: a small card that grows out of the thing it names, under it (above when there is
++ * no room), kept on the screen, with an optional quiet second line. It shows for a mouse after a short rest and for
++ * the keyboard's focus at once; a press, a scroll, Escape or leaving hides it. A finger never sees it (a touch goes
++ * straight to the action), so nothing may be said ONLY in a tooltip: the element keeps its own name (aria-label or
++ * text). Moving from one to the next within a moment shows the next at once. Calm fades; Off does not move.
++ *
++ * OVTip.show(el) OVTip.hide()
++ */
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OVTip) return;
++ let tip = null, on = null, timer = 0, warmUntil = 0;
++
++ function style() {
++ if (document.getElementById('ov-tip-style')) return;
++ const st = document.createElement('style');
++ st.id = 'ov-tip-style';
++ st.textContent = `
++.ovtip { position: fixed; z-index: 10050; left: 0; top: 0; max-width: min(260px, calc(100vw - 16px)); box-sizing: border-box; padding: 7px 11px 8px; border-radius: 10px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-elevated, var(--bg-tertiary, #172238)); color: var(--text-primary, #e6edf7); font: 600 12.5px/1.3 var(--font-sans, system-ui, sans-serif); box-shadow: 0 10px 26px -10px rgba(0, 0, 0, .65), 0 1px 3px rgba(0, 0, 0, .3); pointer-events: none; opacity: 0; transform: translateY(var(--ovtip-from, -5px)) scale(.94); transform-origin: var(--ovtip-ox, 50%) var(--ovtip-oy, 0); transition: opacity .14s ease, transform .22s cubic-bezier(.2, 1.35, .3, 1); }
++.ovtip.is-on { opacity: 1; transform: none; }
++.ovtip-sub { display: block; margin-top: 2px; font-weight: 500; font-size: 11.5px; color: var(--text-secondary, #96a7c2); }
++.ovtip::before { content: ""; position: absolute; left: var(--ovtip-ax, 50%); top: -5px; width: 8px; height: 8px; margin-left: -4px; background: inherit; border: inherit; border-right: 0; border-bottom: 0; transform: rotate(45deg); border-radius: 2px 0 0 0; }
++.ovtip.is-above::before { top: auto; bottom: -5px; transform: rotate(225deg); }
++html[data-ov-motion=reduced] .ovtip { transform: none; transition: opacity .14s ease; }
++html[data-ov-motion=off] .ovtip { transform: none; transition: none; }
++@media (prefers-reduced-motion: reduce) { html:not([data-ov-motion]) .ovtip { transform: none; transition: opacity .14s ease; } }
++@media print { .ovtip { display: none; } }`;
++ document.head.appendChild(st);
++ }
++
++ function place(el) {
++ const r = el.getBoundingClientRect(), vw = root.innerWidth, vh = root.innerHeight;
++ // Measured from the corner: a fixed box takes its width from the room to its right, and where the last one stood
++ // (near the right edge) there is little.
++ tip.style.left = '0px'; tip.style.top = '0px';
++ const w = tip.offsetWidth, h = tip.offsetHeight, gap = 9;
++ const above = r.bottom + gap + h > vh - 8 && r.top - gap - h >= 8;
++ const cx = r.left + r.width / 2;
++ const left = Math.max(8, Math.min(vw - w - 8, cx - w / 2));
++ const top = above ? r.top - gap - h : r.bottom + gap;
++ const ax = Math.max(12, Math.min(w - 12, cx - left));
++ tip.classList.toggle('is-above', above);
++ tip.style.left = `${Math.round(left)}px`; tip.style.top = `${Math.round(top)}px`;
++ tip.style.setProperty('--ovtip-ax', `${Math.round(ax)}px`); tip.style.setProperty('--ovtip-ox', `${Math.round(ax)}px`);
++ tip.style.setProperty('--ovtip-oy', above ? '100%' : '0'); tip.style.setProperty('--ovtip-from', above ? '5px' : '-5px');
++ }
++
++ function show(el) {
++ if (!el || !el.getAttribute) return;
++ const text = el.getAttribute('data-ov-tip');
++ if (!text || el.getAttribute('aria-expanded') === 'true') return;
++ style();
++ if (!tip) { tip = document.createElement('div'); tip.className = 'ovtip'; tip.setAttribute('aria-hidden', 'true'); document.body.appendChild(tip); }
++ tip.classList.remove('is-on');
++ tip.textContent = text;
++ const sub = el.getAttribute('data-ov-tip-sub');
++ if (sub) { const s = document.createElement('span'); s.className = 'ovtip-sub'; s.textContent = sub; tip.appendChild(s); }
++ on = el;
++ place(el);
++ // The next frame, so the card is seen to grow out of what it names.
++ (root.requestAnimationFrame || setTimeout)(() => { if (on === el && tip) tip.classList.add('is-on'); });
++ }
++
++ function hide() {
++ clearTimeout(timer); timer = 0;
++ if (on) warmUntil = Date.now() + 500;
++ on = null;
++ if (tip) tip.classList.remove('is-on');
++ }
++
++ const named = (t) => (t && t.closest ? t.closest('[data-ov-tip]') : null);
++ document.addEventListener('pointerover', (e) => {
++ if (e.pointerType && e.pointerType !== 'mouse') return;
++ const el = named(e.target);
++ if (!el || el === on) return;
++ clearTimeout(timer);
++ if (Date.now() < warmUntil) show(el); else timer = setTimeout(() => show(el), 320);
++ }, true);
++ document.addEventListener('pointerout', (e) => { const el = named(e.target); if (el && !el.contains(e.relatedTarget)) hide(); }, true);
++ document.addEventListener('focusin', (e) => { const el = named(e.target); if (el && el.matches && el.matches(':focus-visible')) show(el); });
++ document.addEventListener('focusout', hide);
++ document.addEventListener('pointerdown', hide, true);
++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
++ root.addEventListener('scroll', hide, { passive: true, capture: true });
++ root.addEventListener('resize', hide, { passive: true });
++
++ root.OVTip = { show, hide };
++}(typeof window !== 'undefined' ? window : globalThis));
ddiff --git a/ov-wallet.css b/ov-wallet.css
iindex d662d6f..08c37ba 100644
---- a/ov-wallet.css
++++ b/ov-wallet.css
@@@ -1,7 +1,7 @@
.ov-wallet{--wallet-bg:var(--bg-primary,#171b20);--wallet-card:var(--bg-secondary,#20262c);--wallet-text:var(--text-primary,#f4f5f6);--wallet-muted:var(--text-secondary,#a9b3bc);--wallet-line:var(--border-color,#38414b);box-sizing:border-box;width:min(860px,calc(100% - 32px));max-height:calc(100dvh - 48px);padding:0;border:1px solid var(--wallet-line);border-radius:24px;background:var(--wallet-bg);color:var(--wallet-text);font:400 15px/1.5 var(--font-family,system-ui,sans-serif);box-shadow:0 30px 100px #0006;overflow:hidden}
.ov-wallet::backdrop{background:#070b14a6;backdrop-filter:blur(5px)}
.ov-wallet *{box-sizing:border-box}.ov-wallet h2,.ov-wallet h3,.ov-wallet h4,.ov-wallet p{margin:0}.ov-wallet h2{font-size:28px;letter-spacing:-.7px;line-height:1.2}.ov-wallet h3{font-size:19px;line-height:1.4}.ov-wallet h4{font-size:15px;overflow-wrap:anywhere}.ov-wallet-head{display:flex;align-items:center;justify-content:space-between;padding:24px 28px;border-bottom:1px solid var(--wallet-line);gap:16px}.ov-wallet-eyebrow{font-size:11px;letter-spacing:1.5px;font-weight:700;color:var(--wallet-muted);margin-bottom:5px!important}.ov-wallet-close{width:44px;height:44px;flex-shrink:0;border:1px solid var(--wallet-line);border-radius:50%;background:transparent;color:inherit;font:28px/1 system-ui;cursor:pointer}
--.ov-wallet-body{max-height:calc(100dvh - 168px);overflow:auto;overscroll-behavior:contain;padding:24px 28px 32px;scrollbar-gutter:stable}.ov-wallet-status:empty{display:none}.ov-wallet-status{padding:12px;margin-bottom:16px!important;border:1px solid var(--wallet-line);border-radius:10px;font-size:14px}.ov-wallet-status.is-error{color:var(--wallet-text);border-color:#c77e51}.ov-wallet-balances{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.ov-wallet-balance{border:1px solid var(--wallet-line);border-radius:16px;padding:16px;display:flex;flex-direction:column;gap:12px;background:var(--wallet-card)}.ov-wallet-balance-title{display:flex;align-items:center;gap:8px}.ov-wallet-balance-title h3{font-size:14px}.ov-wallet-symbol{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;font-weight:800;color:#171b20;background:#dcb85d}.is-vibes .ov-wallet-symbol{background:#8ddac5}.is-balance .ov-wallet-symbol{background:#a3bafa}.ov-wallet-value{font-size:29px;font-weight:750;letter-spacing:-1px;line-height:1.1;overflow-wrap:anywhere}.ov-wallet-purpose{font-size:13px;color:var(--wallet-muted);flex:1}.ov-wallet-button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:9px 12px;border:1px solid var(--wallet-line);border-radius:10px;background:transparent;color:var(--wallet-text);font-family:inherit;font-size:13px;font-weight:600;line-height:1.35;text-align:center;text-decoration:none;cursor:pointer;white-space:normal;gap:6px}.ov-wallet-button:hover{background:var(--wallet-card);border-color:var(--wallet-muted)}.ov-wallet-button.is-primary{background:#8ddac5;color:#112c24;border-color:#8ddac5}.ov-wallet button:disabled{opacity:.5;cursor:default}.ov-wallet :focus-visible{outline:3px solid #8ddac5;outline-offset:3px}.ov-wallet-section{margin-top:30px}.ov-wallet-muted{color:var(--wallet-muted);font-size:13px}.ov-wallet-section>p{margin-top:4px;margin-bottom:14px}.ov-wallet-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.ov-wallet-item{border:1px solid var(--wallet-line);border-radius:14px;padding:10px;display:flex;flex-direction:column;gap:8px;min-width:0}.ov-wallet-preview{height:100px;display:flex;align-items:center;justify-content:center;border-radius:9px;background:var(--wallet-card);font-size:38px;overflow:hidden;text-align:center;padding:8px}.ov-wallet-preview img{width:100%;height:100%;object-fit:contain}.ov-wallet-sample{font-size:21px;font-weight:800;letter-spacing:-.4px;line-height:1.2;overflow-wrap:anywhere}.ov-wallet-preview.is-rainbow .ov-wallet-sample{background:linear-gradient(110deg,#f393b5,#b0adff,#8ddac5);background-clip:text;color:transparent}.ov-wallet-owned{padding:12px 0;font-size:13px;color:var(--wallet-muted)}.ov-wallet-owned-row{display:flex;gap:12px;align-items:center;padding:12px 0;border-bottom:1px solid var(--wallet-line)}.ov-wallet-owned-row .ov-wallet-preview{height:48px;width:48px;flex-shrink:0;font-size:24px}.ov-wallet-owned-row .ov-wallet-sample{font-size:12px}.ov-wallet-owned-row>div:nth-child(2){flex:1;min-width:0}.ov-wallet-owned-row .ov-wallet-button{flex-shrink:0}.ov-wallet-event{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:12px 0;border-bottom:1px solid var(--wallet-line);font-size:13px}.ov-wallet-event small{display:block;color:var(--wallet-muted);font-size:12px}.ov-wallet-event>span{flex-shrink:0}.ov-wallet-checkout{padding:18px;border:1px solid #8ddac5;border-radius:14px;margin-bottom:20px;display:flex;flex-wrap:wrap;gap:12px}.ov-wallet-checkout h3,.ov-wallet-checkout p{width:100%}.ov-wallet-checkout label{flex:1;min-width:130px;font-size:13px}.ov-wallet-checkout input,.ov-wallet-checkout select{display:block;margin-top:5px;min-height:44px;width:100%;padding:10px;color:inherit;background:var(--wallet-bg);border:1px solid var(--wallet-line);border-radius:8px;font:inherit}
++.ov-wallet-body{max-height:calc(100dvh - 168px);overflow:auto;overscroll-behavior:contain;padding:24px 28px 32px;scrollbar-gutter:stable}.ov-wallet-status:empty{display:none}.ov-wallet-status{padding:12px;margin-bottom:16px!important;border:1px solid var(--wallet-line);border-radius:10px;font-size:14px}.ov-wallet-status.is-error{color:var(--wallet-text);border-color:#c77e51}.ov-wallet-balances{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.ov-wallet-balance{border:1px solid var(--wallet-line);border-radius:16px;padding:16px;display:flex;flex-direction:column;gap:12px;background:var(--wallet-card)}.ov-wallet-balance-title{display:flex;align-items:center;gap:8px}.ov-wallet-balance-title h3{font-size:14px}.ov-wallet-symbol{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;font-weight:800;color:#171b20;background:#dcb85d}.is-vibes .ov-wallet-symbol{background:#8ddac5}.is-balance .ov-wallet-symbol{background:#a3bafa}.ov-wallet-value{font-size:29px;font-weight:750;letter-spacing:-1px;line-height:1.1;overflow-wrap:anywhere}.ov-wallet-purpose{font-size:13px;color:var(--wallet-muted);flex:1}.ov-wallet-button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:9px 12px;border:1px solid var(--wallet-line);border-radius:10px;background:transparent;color:var(--wallet-text);font-family:inherit;font-size:13px;font-weight:600;line-height:1.35;text-align:center;text-decoration:none;cursor:pointer;white-space:normal;gap:6px}.ov-wallet-button:hover{background:var(--wallet-card);border-color:var(--wallet-muted)}.ov-wallet-button.is-primary{background:#8ddac5;color:#112c24;border-color:#8ddac5}.ov-wallet button:disabled{opacity:.5;cursor:default}.ov-wallet :focus-visible{outline:3px solid #8ddac5;outline-offset:3px}.ov-wallet-section{margin-top:30px}.ov-wallet-muted{color:var(--wallet-muted);font-size:13px}.ov-wallet-section>p{margin-top:4px;margin-bottom:14px}.ov-wallet-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.ov-wallet-item{border:1px solid var(--wallet-line);border-radius:14px;padding:10px;display:flex;flex-direction:column;gap:8px;min-width:0}.ov-wallet-preview{height:100px;display:flex;align-items:center;justify-content:center;border-radius:9px;background:var(--wallet-card);font-size:38px;overflow:hidden;text-align:center;padding:8px}.ov-wallet-preview img{width:100%;height:100%;object-fit:contain}.ov-wallet-sample{font-size:21px;font-weight:800;letter-spacing:-.4px;line-height:1.2;overflow-wrap:anywhere}.ov-wallet-preview.is-rainbow .ov-wallet-sample{background:linear-gradient(110deg,#f393b5,#b0adff,#8ddac5);background-clip:text;color:transparent}.ov-wallet-owned{padding:12px 0;font-size:13px;color:var(--wallet-muted)}.ov-wallet-owned-row{display:flex;gap:12px;align-items:center;padding:12px 0;border-bottom:1px solid var(--wallet-line)}.ov-wallet-owned-row .ov-wallet-preview{height:48px;width:48px;flex-shrink:0;font-size:24px}.ov-wallet-owned-row .ov-wallet-sample{font-size:12px}.ov-wallet-owned-row>div:nth-child(2){flex:1;min-width:0}.ov-wallet-owned-row .ov-wallet-button{flex-shrink:0}.ov-wallet-event{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:12px 0;border-bottom:1px solid var(--wallet-line);font-size:13px}.ov-wallet-event small{display:block;color:var(--wallet-muted);font-size:12px}.ov-wallet-event a{display:inline-flex;align-items:center;min-height:44px;color:inherit;text-decoration:none;border-bottom:1px solid currentColor}.ov-wallet-event>span{flex-shrink:0}.ov-wallet-checkout{padding:18px;border:1px solid #8ddac5;border-radius:14px;margin-bottom:20px;display:flex;flex-wrap:wrap;gap:12px}.ov-wallet-checkout h3,.ov-wallet-checkout p{width:100%}.ov-wallet-checkout label{flex:1;min-width:130px;font-size:13px}.ov-wallet-checkout input,.ov-wallet-checkout select{display:block;margin-top:5px;min-height:44px;width:100%;padding:10px;color:inherit;background:var(--wallet-bg);border:1px solid var(--wallet-line);border-radius:8px;font:inherit}
@media(prefers-color-scheme:light){.ov-wallet{--wallet-bg:var(--bg-primary,#fafbfc);--wallet-card:var(--bg-secondary,#f0f3f5);--wallet-text:var(--text-primary,#20262c);--wallet-muted:var(--text-secondary,#5b6774);--wallet-line:var(--border-color,#d7dee4)}}
@media(max-width:600px){.ov-wallet{width:100%;max-width:100%;max-height:94dvh;margin:auto 0 0;border-radius:22px 22px 0 0;border-bottom:0}.ov-wallet-head{padding:18px 18px 14px}.ov-wallet-body{padding:16px 18px 28px;max-height:calc(94dvh - 94px);scrollbar-gutter:auto}.ov-wallet-eyebrow{font-size:12px;letter-spacing:1px}.ov-wallet-balances{grid-template-columns:1fr;gap:9px}.ov-wallet-balance{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;padding:12px 14px}.ov-wallet-value{grid-column:1;grid-row:2;font-size:26px}.ov-wallet-purpose{grid-column:1/-1;grid-row:3;font-size:12px}.ov-wallet-balance>.ov-wallet-button{grid-column:2;grid-row:1/3;align-self:center;max-width:132px}.ov-wallet-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.ov-wallet-preview{height:92px}.ov-wallet-item .ov-wallet-button{padding:9px 6px;font-size:12px}.ov-wallet-section{margin-top:24px}.ov-wallet-event{gap:8px}.ov-wallet-event>span{max-width:44%;text-align:right;overflow-wrap:anywhere}}
/* The close ring only for the keyboard (a tap that opens the sheet shows no ring), in the accent like every focus. */
ddiff --git a/ov-wallet.js b/ov-wallet.js
iindex b6715d3..fee2190 100644
---- a/ov-wallet.js
++++ b/ov-wallet.js
@@@ -219,7 +219,15 @@
const recent = section('Recent activity', 'Your last five money events.');
if (!data.recent_activity.length) recent.append(el('p', 'ov-wallet-muted', 'No activity yet.'));
for (const event of data.recent_activity) {
-- const row = el('div', 'ov-wallet-event'), info = el('div'); info.append(el('strong', '', event.label), el('small', '', `${event.site} · ${new Date(event.at).toLocaleDateString()}`));
++ // The reason is a sentence made by billing.money-reason@1 (Billing or Network); when it names
++ // something with a page, the sentence is the link to it. The raw ledger key is never drawn.
++ const row = el('div', 'ov-wallet-event'), info = el('div');
++ const url = event.subject && event.subject.url;
++ if (url && /^https:\/\//.test(url)) {
++ const a = el('a', '', event.label);
++ a.href = url; a.target = '_blank'; a.rel = 'noopener';
++ info.append(a, el('small', '', `${event.site} · ${new Date(event.at).toLocaleDateString()}`));
++ } else info.append(el('strong', '', event.label), el('small', '', `${event.site} · ${new Date(event.at).toLocaleDateString()}`));
const units = event.kind === 'balance' ? { currency: 'USD', scale: 9 } : { currency: event.kind, scale: 0 };
row.append(info, el('span', '', `${Number(event.amount) > 0 ? '+' : ''}${amount({ ...units, amount: event.amount })}${event.kind === 'balance' ? '' : event.kind === 'vibes' ? ' Vibes' : ' OpenCoins'}`)); recent.append(row);
}
ddiff --git a/package-lock.json b/package-lock.json
iindex 5d9ff60..19acca4 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.192.0",
++ "version": "4.193.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.192.0",
++ "version": "4.193.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 854d34c..46b31c0 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.192.0",
++ "version": "4.193.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -51,7 +51,9 @@
"./presence": "./presence.js",
"./purse": "./purse.js",
"./drop": "./ov-drop.js",
++ "./tip": "./ov-tip.js",
"./mini": "./mini.js",
++ "./email": "./email.js",
"./machines": "./machines.js",
"./nav-slots": "./nav-slots.js",
"./overlay": "./overlay.js",
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex 46a8c48..f2a3507 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -19,7 +19,9 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
// 4.189.0: the balances pill's icons and effects.
'purse.js',
// 4.191.0: the one dropdown (the site's name opens its pages).
-- 'ov-drop.js'];
++ 'ov-drop.js',
++ // 4.193.0: the one tooltip (Sign in, the menu button, short labels).
++ 'ov-tip.js'];
// 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-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'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
ddiff --git a/test/email.test.js b/test/email.test.js
nnew file mode 100644
iindex 0000000..28affed
---- /dev/null
++++ b/test/email.test.js
@@@ -0,0 +1,100 @@
++'use strict';
++// One frame for every mail (email.js): it says what happened in words, in the person's theme, with what a mail client
++// allows (tables, styles on the elements, no script, no SVG, nothing that moves); every string is text and a link is
++// kept only if it is https or mailto; the plain-text part says the same.
++const assert = require('node:assert/strict');
++const mail = require('../email');
++const themes = require('../builtin-themes');
++
++const base = {
++ site: { name: 'OpenVibe', url: 'https://openvibe.network', logo: 'https://openvibe.network/icons/logo-192.png' },
++ tone: 'alert', kicker: 'Still failing', title: 'openvibe.domains: 1 problem found by the browser check',
++ lead: 'After release 74bdff4, the automatic check found a problem on the page /.',
++ body: ['Two fold headings have no name a screen reader can say.'],
++ facts: [{ label: 'Page', value: '/', href: 'https://openvibe.domains/' }, { label: 'Release', value: '74bdff4' }, { label: 'Empty', value: '' }],
++ actions: [{ label: 'Open the page', href: 'https://openvibe.domains/' }, { label: 'See the release', href: 'https://git.openvibe.codes/x' }],
++ next: ['The check runs again after every release and once a day.'],
++ why: 'You get this because you run this site.', manage: { href: 'https://openvibe.network/notifications' }, preheader: 'Two fold headings have no name.',
++};
++
++// The default theme, a dark one: its own colours, written on the elements; the client is told it is dark.
++{
++ const { html, text } = mail.frame({ ...base, theme: 'vibe' });
++ const vibe = themes.BUILTIN_THEMES.find((t) => t.id === 'vibe').variables;
++ assert.ok(html.includes(`background:${vibe['--bg-primary']}`) && html.includes(`background:${vibe['--bg-card']}`), 'the page and the card are the theme\'s');
++ assert.match(html, /<meta name="color-scheme" content="dark">/);
++ // The tone is a lean and a glow, never a hard coloured border (the owner: "instead of just like this red … border").
++ assert.ok(!/border-(top|left):\s*3px/.test(html), 'no hard coloured bar on the card or the facts');
++ const lean = mail.mix(vibe['--bg-card'], vibe['--danger'], 0.2);
++ assert.ok(html.includes(`background-image:linear-gradient(180deg, ${lean} 0, ${vibe['--bg-card']} 130px)`), 'the card leans toward the tone at its top');
++ assert.ok(html.includes(`box-shadow:0 22px 60px -30px ${vibe['--danger']}`) && html.includes(`box-shadow:0 12px 30px -18px ${vibe['--danger']}`), 'the card and the facts glow in the tone');
++ assert.match(html, new RegExp(`border-radius:50%;background:${vibe['--danger']};[^"]*">!</span>Still failing</td>`), 'the kicker is a pill with a round mark');
++ assert.match(html, /<h1 [^>]*>openvibe\.domains: 1 problem found by the browser check<\/h1>/);
++ assert.ok(!/<script|<svg|@keyframes|animation:|position:\s*fixed|<link /i.test(html), 'nothing a mail client strips or that moves');
++ assert.ok(!/class="(?!ovm-)/.test(html), 'styles are on the elements (the two classes are the phone padding only)');
++ assert.equal((html.match(/<table /g) || []).length, (html.match(/<table role="presentation"/g) || []).length, 'every table is layout, and says so');
++ assert.match(html, /<img src="https:\/\/openvibe\.network\/icons\/logo-192\.png" width="32" height="32" alt=""/, 'the mark is a hosted picture beside the name');
++ assert.ok(!/🔥|🔔|🚨/u.test(html), 'no emoji stands in for the mark');
++ // One button (the first action), the others as plain links.
++ assert.equal((html.match(/display:inline-block;padding:12px 22px/g) || []).length, 1, 'one button');
++ assert.match(html, /<a href="https:\/\/openvibe\.domains\/" style="display:inline-block[^"]*">Open the page <span aria-hidden="true">&rarr;<\/span><\/a>/, 'the button points onward');
++ assert.match(html, /<a href="https:\/\/git\.openvibe\.codes\/x" style="color:[^"]*">See the release<\/a>/);
++ // Facts: a label and its value; a value with a link is the link; an empty one is left out.
++ assert.match(html, />Page<\/td><td [^>]*><a href="https:\/\/openvibe\.domains\/"[^>]*>\/<\/a><\/td>/);
++ assert.ok(!html.includes('>Empty<'), 'a fact with no value is not drawn');
++ assert.ok(html.includes('What happens next') && html.includes('The check runs again after every release and once a day.'));
++ assert.match(html, /You get this because you run this site\. <a href="https:\/\/openvibe\.network\/notifications"[^>]*>Change what is mailed to you<\/a>/);
++ assert.match(html, /<div style="display:none[^"]*">Two fold headings have no name\.<\/div>/, 'the line a mail list shows');
++ assert.ok(Buffer.byteLength(html) < 20000, 'far under the size where a mail client cuts a message');
++ assert.equal(text, ['STILL FAILING', 'openvibe.domains: 1 problem found by the browser check', '', 'After release 74bdff4, the automatic check found a problem on the page /.', 'Two fold headings have no name a screen reader can say.', '',
++ 'Page: / (https://openvibe.domains/)', 'Release: 74bdff4', '', 'Open the page: https://openvibe.domains/', 'See the release: https://git.openvibe.codes/x', '', 'What happens next', '- The check runs again after every release and once a day.', '', '--',
++ 'You get this because you run this site.', 'Change what is mailed to you: https://openvibe.network/notifications', 'OpenVibe · https://openvibe.network'].join('\n'), 'the plain-text part says the same');
++}
++
++// Every string is text; a link that is not https or mailto is dropped (its label stays as words where it is a fact).
++{
++ const { html, text } = mail.frame({ theme: 'vibe', title: '<img src=x onerror=1>', lead: '"quoted" & <b>bold</b>', facts: [{ label: '<i>L</i>', value: '<u>V</u>', href: 'javascript:alert(1)' }],
++ actions: [{ label: 'Bad', href: 'http://insecure.example/' }, { label: 'Worse', href: 'javascript:alert(1)' }, { label: 'Write to us', href: 'mailto:[email protected]' }], site: { name: 'A<b>', url: 'javascript:1', logo: 'data:image/png;base64,AAAA' }, manage: { href: 'ftp://x' } });
++ assert.ok(!/<img src=x|<b>bold|<u>V|<i>L|javascript:|http:\/\/insecure|data:image|ftp:/.test(html), 'nothing given is markup, and no unsafe address is kept');
++ assert.ok(html.includes('&lt;img src=x onerror=1&gt;') && html.includes('&quot;quoted&quot; &amp; &lt;b&gt;bold&lt;/b&gt;') && html.includes('&lt;u&gt;V&lt;/u&gt;'));
++ assert.match(html, /<a href="mailto:help@openvibe\.network" style="display:inline-block[^"]*">Write to us </, 'the first address that is safe is the button');
++ assert.ok(html.includes('href="https://openvibe.network"'), 'an unsafe site address falls back to the network\'s');
++ assert.ok(!text.includes('javascript:') && text.includes('Write to us: mailto:[email protected]'));
++}
++
++// A light theme: light surfaces, and the client is told it is light. An unknown theme is the default one.
++{
++ const light = themes.BUILTIN_THEMES.find((t) => t.mode === 'light');
++ assert.ok(light, 'there is a light theme');
++ const { html } = mail.frame({ theme: light.id, title: 'Welcome' });
++ assert.match(html, /<meta name="color-scheme" content="light">/);
++ assert.ok(html.includes(`background:${light.variables['--bg-primary']}`));
++ assert.equal(mail.frame({ theme: 'no-such-theme', title: 'x' }).html, mail.frame({ theme: themes.DEFAULT_THEME, title: 'x' }).html);
++ const c = mail.palette(light.id);
++ assert.ok(themes.contrast(themes.hexToRgb(c.text), themes.hexToRgb(c.card)) >= 7, 'the words stand well clear of the card');
++ assert.ok(themes.contrast(themes.hexToRgb(c.onButton), themes.hexToRgb(c.button)) >= 4.5, 'the button\'s words can be read');
++}
++
++// In every built-in theme the words can be read, the kicker's on its tint too, in every tone.
++for (const t of themes.BUILTIN_THEMES) {
++ const c = mail.palette(t.id);
++ for (const tone of ['plain', 'alert', 'warning', 'good']) {
++ const h = mail.frame({ theme: t.id, tone, kicker: 'Kicker', title: 'T' }).html;
++ const m = /border-radius:999px;background:(#[0-9a-f]{6});border:1px solid #[0-9a-f]{6};font:700 12px\/18px [^;]+;color:(#[0-9a-f]{6})/.exec(h);
++ assert.ok(m, `${t.id} ${tone}: the kicker pill is drawn`);
++ const r = themes.contrast(themes.hexToRgb(m[2]), themes.hexToRgb(m[1]));
++ assert.ok(r >= 4.5, `${t.id} ${tone}: the kicker's words on its tint are ${r.toFixed(2)}:1`);
++ }
++ for (const [name, fg, bg, min] of [['text on the card', c.text, c.card, 4.5], ['quiet text on the card', c.dim, c.card, 4.5], ['faint text on the card', c.faint, c.card, 3], ['a link on the card', c.accent, c.card, 3], ['the button', c.onButton, c.button, 4.5]]) {
++ const r = themes.contrast(themes.hexToRgb(fg), themes.hexToRgb(bg));
++ assert.ok(r >= min, `${t.id}: ${name} is ${r.toFixed(2)}:1, wanted ${min}:1`);
++ }
++}
++
++// A code to type is drawn large in its own box, and said in the text.
++{
++ const { html, text } = mail.frame({ theme: 'vibe', title: 'Your sign-in code', code: '482 913' });
++ assert.match(html, /letter-spacing:6px;[^"]*">482 913<\/td>/);
++ assert.ok(text.includes('Your code: 482 913'));
++}
++console.log(`email: one frame in ${themes.BUILTIN_THEMES.length} themes, words that can be read, text only, safe links, one button, the plain-text part`);
ddiff --git a/test/helpers/wallet-fixture.js b/test/helpers/wallet-fixture.js
iindex 44328e2..aeed800 100644
---- a/test/helpers/wallet-fixture.js
++++ b/test/helpers/wallet-fixture.js
@@@ -19,8 +19,8 @@ const wallet = {
},
checkout: { providers: [{ name: 'stripe', hosts: ['checkout.stripe.com'] }], vibes: { minimum: 100, maximum: 100000, price_tiers: [{ min: 100, usd_per_bit: 0.012 }] }, topup: { enabled: true, amounts_cents: [1000, 2500], fee_bps: 0 } },
recent_activity: [
-- { id: 'a', at: '2026-11-08T12:00:00Z', kind: 'coins', amount: '50', label: 'Daily quest completed', site: 'quest', detail: '' },
-- { id: 'b', at: '2026-11-08T10:00:00Z', kind: 'vibes', amount: '-25', label: 'Tipped a creator', site: 'live', detail: '' },
++ { id: 'a', at: '2026-11-08T12:00:00Z', kind: 'coins', amount: '50', label: 'for the quest Publish a video', subject: { name: 'Publish a video', url: 'https://openvibe.quest/quests/publish-a-video' }, site: 'OpenVibe.Quest', detail: '' },
++ { id: 'b', at: '2026-11-08T10:00:00Z', kind: 'vibes', amount: '-25', label: 'for a tip to @alice', subject: { name: '@alice', url: 'https://openvibe.live/@alice' }, site: 'OpenVibe', detail: '' },
],
};
function fixture() {
ddiff --git a/test/mini-chrome.test.js b/test/mini-chrome.test.js
iindex 637f782..1c2d74a 100644
---- a/test/mini-chrome.test.js
++++ b/test/mini-chrome.test.js
@@@ -91,6 +91,13 @@ const server = http.createServer((req, res) => {
await page.goto(`${base}/second`, { maxMs: 6000 }); await wait(300);
assert.equal(await page.evaluate(`!!document.querySelector('.ovmini')`), false, 'and it does not come back on the next page');
++ // A file that cannot be played here: the card says so and keeps the way back; nothing follows to the next page.
++ await page.goto(`${base}/second`, { maxMs: 6000 });
++ await page.evaluate(`OpenVibeMini.open({ page: '/watch', src: '/no-such-file.webm', title: 'The harbour at night', by: 'Kim', t: 3, playing: true })`);
++ assert.ok(await until(`!!document.querySelector('.ovmini.is-broken')`), 'a file that cannot play is said, not hidden');
++ assert.deepEqual(await page.evaluate(`(() => { const e = document.querySelector('.ovmini'); return [e.querySelector('.ovmini-by').textContent, e.querySelector('[data-m="play"]').hidden, e.querySelector('.ovmini-text').getAttribute('href'), OpenVibeMini.record()]; })()`), ['Open its page to keep watching', true, '/watch', null]);
++ await page.evaluate(`OpenVibeMini.close()`);
++
// A phone: one bar across the bottom, the picture at its left, 44 px buttons.
const phone = await h.openPage(browser, await browser.newContext(), { width: 390, height: 780 });
const untilP = async (expr) => { const end = Date.now() + 6000; while (Date.now() < end) { let v = false; try { v = await phone.evaluate(expr); } catch { v = false; } if (v) return v; await wait(60); } return false; };
ddiff --git a/test/navbar-fit-chrome.test.js b/test/navbar-fit-chrome.test.js
iindex eafdb83..42a5756 100644
---- a/test/navbar-fit-chrome.test.js
++++ b/test/navbar-fit-chrome.test.js
@@@ -48,11 +48,18 @@ const READ = `(() => { const nav = document.querySelector('.openvibe-navbar'); i
assert.ok(v.over <= 1, `${width}: the bar holds its row (${v.over} px over)`);
assert.deepEqual(v.shown, LINKS.slice(0, v.shown.length).map((l) => l[1]), `${width}: the links that stay are the first ones, in order, whole`);
seen[width] = v.shown.length;
++ // The menu button is at the right on a computer too (4.193.0): one of it, after the links, before the person;
++ // the logo (the network's sites) and the name (this site's pages) are the left end.
++ const m = await page.evaluate(`(() => { const x = (s) => { const e = document.querySelector(s); if (!e) return null; const r = e.getBoundingClientRect(); return r.width > 0 ? [Math.round(r.left), Math.round(r.right)] : null; };
++ return { byName: x('#openvibe-search-btn'), right: x('#openvibe-burger'), person: x('#openvibe-avatar-btn'), logo: x('#openvibe-switch-btn'), name: x('.openvibe-navbar-brand .name'), vw: innerWidth }; })()`);
++ assert.equal(m.byName, null, `${width}: no second menu button beside the name`);
++ assert.ok(m.right && m.person && m.right[1] <= m.person[0] && m.person[1] <= m.vw && m.vw - m.person[1] < 40, `${width}: the menu button, then the person, at the right end (${JSON.stringify(m)})`);
++ assert.ok(m.logo && m.name && m.logo[1] <= m.name[0] && m.name[1] < m.right[0], `${width}: the logo, then the name, at the left`);
await page.close();
}
assert.ok(seen[880] < LINKS.length, 'a narrow bar lets its last links step out');
assert.equal(seen[1400], LINKS.length, 'a wide bar shows them all');
assert.ok(seen[880] <= seen[940] && seen[940] <= seen[1010] && seen[1010] <= seen[1100] && seen[1100] <= seen[1400], 'more room, more links');
-- console.log(`navbar-fit-chrome: one row from 880 to 1400 px with eight links signed in (${Object.entries(seen).map(([w, n]) => `${w}:${n}`).join(' ')})`);
++ console.log(`navbar-fit-chrome: one row from 880 to 1400 px with eight links signed in, the menu button at the right (${Object.entries(seen).map(([w, n]) => `${w}:${n}`).join(' ')})`);
} finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/ov-tip-chrome.test.js b/test/ov-tip-chrome.test.js
nnew file mode 100644
iindex 0000000..341020f
---- /dev/null
++++ b/test/ov-tip-chrome.test.js
@@@ -0,0 +1,80 @@
++'use strict';
++// The one tooltip (ov-tip.js, 4.193.0) in a real headless Chrome: it shows for a mouse after a short rest, under what
++// it names and on the screen (above when there is no room below), with its quiet second line; the keyboard's focus
++// shows it at once; a press, Escape or leaving hides it; an opened button shows none; it is hidden from assistive
++// technology (the element keeps its own name).
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const http = require('node:http');
++const h = require('../browser-harness');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('ov-tip-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const root = path.join(__dirname, '..');
++const server = http.createServer((req, res) => {
++ if (req.url.split('?')[0] === '/ov-tip.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-tip.js'))); }
++ res.writeHead(200, { 'content-type': 'text/html' });
++ res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:0;height:600px">
++<button id="a" aria-label="Sign in" data-ov-tip="Sign in" style="position:fixed;right:6px;top:8px;width:36px;height:36px"></button>
++<button id="b" aria-label="Menu" data-ov-tip="Menu" data-ov-tip-sub="This site, search, every OpenVibe site" style="position:fixed;right:60px;top:8px;width:36px;height:36px"></button>
++<button id="low" aria-label="People online" data-ov-tip="3 online" data-ov-tip-sub="in the last 30 minutes" style="position:fixed;left:200px;bottom:6px;width:80px;height:30px"></button>
++<button id="open" aria-label="Open" aria-expanded="true" data-ov-tip="Never shown while open" style="position:fixed;left:20px;top:8px;width:36px;height:36px"></button>
++<script src="/ov-tip.js"></script>`);
++});
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-tip-'));
++ const browser = await h.launch({ tmpDir });
++ try {
++ const page = await h.openPage(browser, await browser.newContext(), { width: 800, height: 600 });
++ await page.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const until = async (expr) => { const end = Date.now() + 4000; while (Date.now() < end) { if (await page.evaluate(expr)) return true; await wait(40); } return false; };
++ const move = (x, y) => page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, pointerType: 'mouse' });
++ const ON = `!!document.querySelector('.ovtip.is-on')`;
++ const READ = (id) => `(() => { const t = document.querySelector('.ovtip'), r = t.getBoundingClientRect(), e = document.getElementById('${id}').getBoundingClientRect();
++ return { text: t.firstChild.textContent, sub: (t.querySelector('.ovtip-sub') || {}).textContent || '', under: r.top >= e.bottom, above: r.bottom <= e.top, left: r.left, right: innerWidth - r.right, lines: Math.round(r.height), hidden: t.getAttribute('aria-hidden') }; })()`;
++
++ // A mouse resting on the button at the right edge: the tooltip, after a moment, under it and on the screen.
++ await move(782, 26);
++ assert.equal(await page.evaluate(ON), false, 'not at once: a pointer passing over shows nothing');
++ assert.ok(await until(ON), 'after a short rest it shows');
++ await wait(260);
++ const a = await page.evaluate(READ('a'));
++ assert.deepEqual([a.text, a.sub, a.under, a.hidden], ['Sign in', '', true, 'true']);
++ assert.ok(a.right >= 7 && a.left >= 7, `kept on the screen though its button is at the edge (${JSON.stringify(a)})`);
++ // On to its neighbour: the next one shows at once, with its second line, and is not squeezed by where the last stood.
++ await move(722, 26);
++ assert.ok(await until(`(document.querySelector('.ovtip.is-on') || {}).textContent === 'MenuThis site, search, every OpenVibe site'`), 'moving to the next shows it without the wait');
++ await wait(260);
++ const b = await page.evaluate(READ('b'));
++ assert.deepEqual([b.text, b.sub, b.under], ['Menu', 'This site, search, every OpenVibe site', true]);
++ assert.ok(b.lines < 60, `its two lines are two lines, not a column squeezed against the edge (${b.lines}px tall)`);
++ // Leaving hides it; a press hides it.
++ await move(400, 300);
++ assert.ok(await until(`!${ON}`), 'leaving hides it');
++ await move(722, 26); assert.ok(await until(ON));
++ await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: 722, y: 26, button: 'left', clickCount: 1 });
++ await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 722, y: 26, button: 'left', clickCount: 1 });
++ assert.ok(await until(`!${ON}`), 'a press hides it');
++ await move(400, 300); await wait(600);
++ // Near the bottom of the screen it opens above.
++ await move(240, 580);
++ assert.ok(await until(ON));
++ await wait(260);
++ const low = await page.evaluate(READ('low'));
++ assert.deepEqual([low.text, low.sub, low.above], ['3 online', 'in the last 30 minutes', true], 'above when there is no room below');
++ await move(400, 300); await wait(600);
++ // An opened button shows none.
++ await move(38, 26); await wait(700);
++ assert.equal(await page.evaluate(ON), false, 'a button whose panel is open shows no tooltip');
++ await move(400, 300);
++ // The keyboard: focus shows it at once; Escape hides it.
++ await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
++ await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
++ assert.ok(await until(ON), 'the keyboard\'s focus shows it');
++ await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
++ assert.ok(await until(`!${ON}`), 'Escape hides it');
++ console.log('ov-tip-chrome: after a rest for a mouse, at once for the next and for the keyboard, under or above, on the screen, two lines whole, hidden by leaving, a press and Escape');
++ } finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/wallet-chrome.test.js b/test/wallet-chrome.test.js
iindex 8ebae4f..8679de1 100644
---- a/test/wallet-chrome.test.js
++++ b/test/wallet-chrome.test.js
@@@ -21,6 +21,9 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const metrics = await p.evaluate(`(() => { const d=document.querySelector('.ov-wallet'); return {open:!!(d&&d.open), width:document.documentElement.scrollWidth, inner:d&&d.scrollWidth, box:d&&d.clientWidth, lock:document.documentElement.style.overflow, text:d&&d.innerText, buttons:[...d.querySelectorAll('button,a')].map(b=>b.getBoundingClientRect().height)} })()`);
assert(metrics.open, 'first chip tap opens wallet'); assert.strictEqual(metrics.width, width); assert(metrics.inner <= metrics.box + 1);
assert.strictEqual(metrics.lock, 'hidden'); assert(metrics.text.includes('$0.00')); assert(metrics.text.includes('Buy Vibes')); assert(metrics.text.includes('Top up'));
++ assert(metrics.text.includes('for the quest Publish a video'), 'a money reason is a sentence, not a key');
++ assert(!/quest:publish|Daily quest completed/.test(metrics.text));
++ assert(await p.evaluate(`[...document.querySelectorAll('.ov-wallet-event a')].some(a=>a.getAttribute('href')==='https://openvibe.quest/quests/publish-a-video')`), 'the reason links to its subject');
assert(metrics.buttons.every((height) => height >= 44), '44px controls');
if (process.env.WALLET_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.WALLET_SCREENSHOT_DIR, { recursive: true });