openvibers/openvibe.shared Commits
4.193.0: the bar's right end, one tooltip, one frame for mail (#422)
Changes
@@@ -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 awatch page: "should … actually [be] like YouTube where it moves that video. Now you go to the search again or the index@@@ -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 deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.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)",nnew file mode 100644@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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: '✓', 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">→</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(' · ')}</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 };@@@ -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@@@ -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(),@@@ -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 keepsits 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, signedin, 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). Underthe pointer its parts lift in turn; while the menu loads the apps breathe; open, the bar and the apps crossinto 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 forevery control at the bar's right, 36 px (34 px on a phone under 400 px): Sign In, the orb, messages, thepicture, 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();nnew file mode 100644@@@ -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));@@@ -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. */@@@ -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);}@@@ -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",@@@ -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",@@@ -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'] },nnew file mode 100644@@@ -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">→<\/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('<img src=x onerror=1>') && html.includes('"quoted" & <b>bold</b>') && html.includes('<u>V</u>'));++ 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`);@@@ -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() {@@@ -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; };@@@ -48,11 +48,18 @@ const READ = `(() => { const nav = document.querySelector('.openvibe-navbar'); iassert.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); });nnew file mode 100644@@@ -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); });@@@ -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 });
@@ -1,6 +1,43 @@
@@ -1,6 +1,43 @@
# Changelog
# 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
**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
watch page: "should … actually [be] like YouTube where it moves that video. Now you go to the search again or the index
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.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
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.192.0",
+ "version": "4.193.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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: '✓', 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">→</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(' · ')}</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 };
@@ -25,7 +25,7 @@ const BROWSER = Object.freeze([
@@ -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.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
'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
'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-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-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
'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* A watch page says its video may come along:
* 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=…"]
* <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 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)
* (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 @@
@@ -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,
* 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
* 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.
* 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
* 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).
* in it. Across sites it will travel another way (plan, ask 265).
@@ -72,6 +75,8 @@
@@ -72,6 +75,8 @@
.ovmini-btn:active { transform: scale(.92); }
.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: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: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; }
.ovmini.is-leaving { animation: ovmini-out .18s ease both; }
@keyframes ovmini-in { from { opacity: 0; transform: translateY(14px) scale(.98); } }
@keyframes ovmini-in { from { opacity: 0; transform: translateY(14px) scale(.98); } }
@keyframes ovmini-out { to { opacity: 0; transform: translateY(10px); } }
@keyframes ovmini-out { to { opacity: 0; transform: translateY(10px); } }
@@ -135,7 +140,7 @@ html[data-ov-motion=off] .ovmini-bar i { transition: none; }
@@ -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 d = isFinite(video.duration) && video.duration > 0 ? video.duration : (r.dur || 0);
const pct = d ? Math.min(100, (video.currentTime / d) * 100) : 0;
const pct = d ? Math.min(100, (video.currentTime / d) * 100) : 0;
bars.forEach((b) => { b.style.width = pct.toFixed(2) + '%'; });
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);
soundBtn.hidden = !(video.muted && on && r.wantSound);
};
};
el.querySelectorAll('[data-m="back"]').forEach((a) => { a.href = back(); });
el.querySelectorAll('[data-m="back"]').forEach((a) => { a.href = back(); });
@@ -149,8 +154,14 @@ html[data-ov-motion=off] .ovmini-bar i { transition: none; }
@@ -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); } });
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); }));
['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 */ }
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;
video.playbackRate = r.rate || 1;
const was = r.playing;
const was = r.playing;
@@ -159,9 +170,16 @@ html[data-ov-motion=off] .ovmini-bar i { transition: none; }
@@ -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.
// 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);
video.play().catch(() => { r.wantSound = !r.muted; video.muted = true; return video.play(); }).catch(() => {}).then(paint);
}, { once: true });
}, { 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.volume = typeof r.vol === 'number' ? r.vol : 1;
video.muted = !!r.muted;
video.muted = !!r.muted;
- video.src = r.src;
+ arm(); source();
document.body.appendChild(el);
document.body.appendChild(el);
root.addEventListener('pagehide', leave);
root.addEventListener('pagehide', leave);
document.addEventListener('play', other, true);
document.addEventListener('play', other, true);
@@ -191,7 +209,7 @@ html[data-ov-motion=off] .ovmini-bar i { transition: none; }
@@ -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 || /^blob:/.test(src)) src = /^blob:/.test(v.currentSrc || '') ? '' : (v.currentSrc || '');
if (!src) return false;
if (!src) return false;
save({
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 || '',
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,
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(),
playing: !v.paused && !v.ended, at: Date.now(),
@@ -91,8 +91,8 @@
@@ -91,8 +91,8 @@
.openvibe-navbar .openvibe-navbar-brand .flame::after { content: ''; position: absolute; inset: -8px -4px -8px -12px; }
.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. */
/* 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; }
.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; }
.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
/* 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
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 @@
@@ -194,7 +194,7 @@
.ovnav-chips-pop button .ovnav-ic { color: var(--chip-c, inherit); width: 16px; height: 16px; }
.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 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; }
.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; } }
@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
/* 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
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 @@
@@ -287,6 +287,7 @@
.openvibe-navbar-right { display: flex; align-items: center; gap: 6px; }
.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. */
/* 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 + :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 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
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. */
into the close mark. Transform, opacity and fill only, and nothing moves while it rests. */
@@ -327,10 +328,10 @@
@@ -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) { 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 :is(.ovnav-orb, .openvibe-bell, .openvibe-navbar-avatar):active { transform: scale(.9); }
.openvibe-navbar .openvibe-navbar-brand .ovnav-orb { margin-left: 1px; }
.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). */
/* 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::after { inset: -6px -4px -6px -10px; }
.openvibe-navbar .ovnav-orb.ovnav-burger:hover, .openvibe-navbar .ovnav-orb.ovnav-burger[aria-expanded="true"] { background: none; }
.openvibe-navbar .ovnav-orb.ovnav-burger:hover, .openvibe-navbar .ovnav-orb.ovnav-burger[aria-expanded="true"] { background: none; }
@@ -353,24 +354,19 @@
@@ -353,24 +354,19 @@
}
}
.openvibe-navbar-avatar:hover { border-color: var(--accent, #3b82f6); }
.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
/* 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
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
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 @@
@@ -380,12 +376,12 @@
.openvibe-navbar .ovnav-featured-phone { width: 36px; height: 36px; }
.openvibe-navbar .ovnav-featured-phone { width: 36px; height: 36px; }
.openvibe-navbar .openvibe-navbar-avatar { width: 36px; height: 36px; box-sizing: border-box; }
.openvibe-navbar .openvibe-navbar-avatar { width: 36px; height: 36px; box-sizing: border-box; }
.openvibe-navbar .ovnav-chip { height: 36px; }
.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) {
@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, .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-orb svg { width: 24px; height: 24px; }
.openvibe-navbar .ovnav-chip, .openvibe-navbar .openvibe-navbar-login { height: 34px; }
.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
/* 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 @@
@@ -567,7 +563,7 @@
const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': '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
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -992,7 +988,7 @@
@@ -992,7 +988,7 @@
}
}
root.OpenVibeOrb = { svg: orbSVG };
root.OpenVibeOrb = { svg: orbSVG };
function orbHTML() {
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 @@
@@ -1193,7 +1189,7 @@
// site that turns the menu off (search: false) or asks for it (drawer: 'legacy').
// site that turns the menu off (search: false) or asks for it (drawer: 'legacy').
burger.addEventListener('click', (e) => {
burger.addEventListener('click', (e) => {
e.stopPropagation();
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);
const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); if (open) loadOvIcons(drawer);
});
});
drawer.addEventListener('click', (e) => {
drawer.addEventListener('click', (e) => {
@@ -2118,9 +2114,9 @@
@@ -2118,9 +2114,9 @@
${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''}
${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''}
${featuredPhoneHTML(links)}
${featuredPhoneHTML(links)}
<div id="openvibe-bell-mount"></div>
<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') :
${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>
</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>` : ''}
${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 @@
@@ -2128,6 +2124,7 @@
bindLinks(nav, links);
bindLinks(nav, links);
hookSearch(nav);
hookSearch(nav);
hookSwitcher(nav); hookPages(nav); hookSignIn(nav); hookFit(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);
bindChips(nav);
bindChipsAll(nav);
bindChipsAll(nav);
queueIcons();
queueIcons();
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -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));
@@ -1,7 +1,7 @@
@@ -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{--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::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 *{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(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}}
@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. */
/* The close ring only for the keyboard (a tap that opens the sheet shows no ring), in the accent like every focus. */
@@ -219,7 +219,15 @@
@@ -219,7 +219,15 @@
const recent = section('Recent activity', 'Your last five money events.');
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.'));
if (!data.recent_activity.length) recent.append(el('p', 'ov-wallet-muted', 'No activity yet.'));
for (const event of data.recent_activity) {
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 };
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);
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);
}
}
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.192.0",
+ "version": "4.193.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.192.0",
+ "version": "4.193.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -51,7 +51,9 @@
@@ -51,7 +51,9 @@
"./presence": "./presence.js",
"./presence": "./presence.js",
"./purse": "./purse.js",
"./purse": "./purse.js",
"./drop": "./ov-drop.js",
"./drop": "./ov-drop.js",
+ "./tip": "./ov-tip.js",
"./mini": "./mini.js",
"./mini": "./mini.js",
+ "./email": "./email.js",
"./machines": "./machines.js",
"./machines": "./machines.js",
"./nav-slots": "./nav-slots.js",
"./nav-slots": "./nav-slots.js",
"./overlay": "./overlay.js",
"./overlay": "./overlay.js",
@@ -19,7 +19,9 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
@@ -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.
// 4.189.0: the balances pill's icons and effects.
'purse.js',
'purse.js',
// 4.191.0: the one dropdown (the site's name opens its pages).
// 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.
// 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'] },
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'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -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">→<\/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('<img src=x onerror=1>') && html.includes('"quoted" & <b>bold</b>') && html.includes('<u>V</u>'));
+ 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`);
@@ -19,8 +19,8 @@ const wallet = {
@@ -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 } },
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: [
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() {
function fixture() {
@@ -91,6 +91,13 @@ const server = http.createServer((req, res) => {
@@ -91,6 +91,13 @@ const server = http.createServer((req, res) => {
await page.goto(`${base}/second`, { maxMs: 6000 }); await wait(300);
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');
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.
// 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 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; };
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; };
@@ -48,11 +48,18 @@ const READ = `(() => { const nav = document.querySelector('.openvibe-navbar'); i
@@ -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.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`);
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;
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();
await page.close();
}
}
assert.ok(seen[880] < LINKS.length, 'a narrow bar lets its last links step out');
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.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');
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 }); }
} finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -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); });
@@ -21,6 +21,9 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -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)} })()`);
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(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.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');
assert(metrics.buttons.every((height) => height >= 44), '44px controls');
if (process.env.WALLET_SCREENSHOT_DIR) {
if (process.env.WALLET_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.WALLET_SCREENSHOT_DIR, { recursive: true });
fs.mkdirSync(process.env.WALLET_SCREENSHOT_DIR, { recursive: true });