openvibers/openvibe.shared Commits

4.190.0: the one dropdown, the bar holds one row, the wallet in colour, who is online (#417)

OP OpenVibers committed 3 days ago · 4d3a95fe27 · 39 files, +1237 −69

* Uploads say when you already have the file; the wallet wears the currency icons; a change kind is a small mark

OVUpload: checkDuplicate and preflight (built by the upload-dedupe job), with the day written as a person would say
it and the notice as one block. The wallet sheet draws OpenCoins, Vibes and the money balance with purse.js icons.
Recent changes: New, Improved (was Better; the mark alone) and Fixed, each with a drawn mark.

* The Link computers picker (machines.js): one place to choose or add a computer, with its look

Built by the machines-one-place job (the reads, the roles, the form values); the tiles, the states and the add row are
the main session look.

* The footer strip: who is online first, with faces and the list an old forum had; live and the counts as small tiles

The read (/api/frame/online), its cleaning and the page-view marker are the whos-online job; where each thing sits and
how it looks is the main session.

* The site name opens its pages in the one dropdown (ov-drop.js); Jump back in links to the full history

ov-drop.js: a small list that opens out of what was pressed (under it, or above when there is no room), kept on
screen, rows arriving in turn, arrow keys, Escape, one at a time; OVDrop.open, attach and pages. The bar name opens
this site pages with Home first and the present page marked. The history link and the agent read are the
history-timeline job plumbing.

* Sign In has a drawn, moving icon; the menu button hops now and then; the bottom bar highlight is centred on its icon

The highlight was placed from offsetTop once, before the icons and the font arrived, and sat above its icon; it is
measured from where things are, centred both ways, and placed again when the bar or an icon changes size.

* The wallet sheet in the currencies colours, with motion; the gain and spend effect as a call any site can make

purse.js: OpenVibePurse.burst(element, id, delta) and .count(element, from, to, format); data-purse gives a holder its
currency colour; .ovp-play plays an icon move. ov-wallet: tinted cards, icon tiles, the number counted in, a purchase
counted down with pieces leaving its card; a failed read is said in a person words.

* The wallet cards rise without scaling (a control is 44 px at every moment)

* machines.js is an export of the package

* 4.190.0: the one dropdown and the site name pages, the bar holds one row, the wallet in colour, who is online

* One bar mount per page: the server bar goes into the page own mount (an empty 52 px band on seventeen sites)

* The test runner gives a failed browser file one more try, and names it

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex f33c3d9..013ed95 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,61 @@
# Changelog
++## 4.190.0 — 2026-11-10
++
++**The one dropdown, the wallet in colour, who is online, and five smaller things.** From the owner's notes of
++2026-11-10, each with a screenshot.
++
++- **The site's name opens its pages** ("clicking the actual name of the domain should have a sleek little minimalist
++ drop down … that just shows the pages … have the home button as an option … a custom, animated system for these drop
++ downs and positioning of them, used across all sites"). New `ov-drop.js`: a small list that opens out of what was
++ pressed (under it, or above when there is no room), kept on screen, its rows arriving in turn; arrow keys, Enter,
++ Escape with the focus given back, a press outside; one open at a time, with the page's other panels. `OVDrop.open`,
++ `.attach` and `.pages`. In the bar the name shows a small mark and lists Home first, each page with its icon and the
++ present page marked; with a modifier key it is still a link to the site's home.
++- **The wallet sheet in the currencies' colours, with motion** ("make this way nicer with animations and effects and
++ color … nicer for the different currencies … effects like animations for the coin loss … shared and unified, also
++ SDK API"). Each balance is a tinted card with its drawn icon in a tile and its action in the same colour; a light
++ crosses each card as the sheet opens and the numbers count in; after a purchase the number counts to the new amount
++ and the currency's pieces leave its card with the amount beside it. On a phone: the tile, the name over the number,
++ the action at the right. A read that fails is said in a person's words, never a program's.
++- **The effects as calls** (`purse.js`): `OpenVibePurse.burst(element, id, delta)` plays the gain or spend at any
++ element, inside an open dialog too, upward near the bottom of the screen; `.count(element, from, to, format)`;
++ `data-purse` gives a holder the currency's colour (`--pc`); `.ovp-play` plays an icon's own move.
++- **The footer strip says who is online** ("show how many users are across all sites … the last half hour … guests
++ and users, like the old forum style where it lists everyone … live smaller, only if someone's live … other
++ metrics"). One read, `/api/frame/online`: the count with a few faces, members and guests, and where members may be
++ named the list of them with their site; then small tiles that open what they count: live (only while someone is),
++ joined this week, the site's own items, the sites, the week's page views. The page-view beacon marks a signed-in
++ browser with `?in=1` (no identity), so that person is counted from their presence and not as a guest.
++- **Uploads say when you already have the file** ("tell the user they already have one"). `OVUpload.queue` takes
++ `checkDuplicate(file, sha256)`; the row says "You already uploaded this on 10 Nov 2026." with Open it, Use that one
++ and Upload again, as one block. `OVUpload.preflight` does it for a single picker. Files over `hashLimitBytes` (32 MiB)
++ or slower to hash than to send are not hashed.
++- **A change is New, Improved or Fixed, with a mark** ("what is this confusing tag … better next to everything?").
++ A star, an arrow up, a tick; an improvement shows the mark alone. "Better" is gone.
++- **One picker for a person's computers** (`machines.js`, with Network's /add-computer): each computer a tile with
++ its state and what it can run, "Auto", and "Add a computer" through OpenVibe Link.
++- **One bar mount per page: an empty band under the bar is gone from seventeen sites** ("why is there so much wasted
++ space or unnecessary margin … for no reason", with the space under the bar circled on openvibe.community). Since
++ 4.186.0 the server writes the bar's links itself, in its own `#navbar-mount`; a page whose body already had
++ `<div id="navbar-mount"></div>` then had two, the bar mounted in the first, and the second stayed an empty 52 px box.
++ `shell.page` puts the server's bar into the page's own mount where it has one.
++- **Sign In has its own icon** (an arrow into the ring; it steps in now and then, and the ring's gap runs once
++ pressed), and the menu button's three squares hop in turn every few seconds ("use a nice custom animated icon for the
++ sign in … make the menu button animated as well").
++- **The bottom bar's highlight is centred on its icon** ("this is all offset"). It was placed once from `offsetTop`,
++ before the icons and the brand font arrived, and stayed above its icon; it is measured from where things are and
++ placed again when the bar or an icon changes size.
++- Jump back in links to the full history on openvibe.my; the Dock's agent can read recent history on a press (the
++ history-timeline job's plumbing).
++
++- **The test runner gives a failed browser file one more try** (`retry` in `test-runner.js`, used by this package's
++ own run). Three releases in a row waited on the macOS runner, each time for different headless-Chrome files that
++ pass on Linux, on Windows and locally. A pass on the second try is named in the summary.
++
++Built by runner jobs and given their look here: the upload check (upload-dedupe), the computers picker
++(machines-one-place), the footer's read (whos-online-strip), the history link (history-timeline).
++
## 4.189.0 — 2026-11-10
**The balances are one pill, the menu and the Dock lead to each other, and the menu opens where you are.** Seven notes
ddiff --git a/README.md b/README.md
iindex aaa183c..3e0de4b 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.189.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.190.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
@@@ -831,7 +831,18 @@ One menu (4.187): the OpenVibe mark is a button that opens the OpenVibe menu
on its first tab, as the menu button does (there is no separate site
switcher). The site's name stays the link home; switcher: false keeps the
mark as a home link.
--The balances (4.189): one pill, `#openvibe-chips-all`, at every width, with
++New in 4.190: `ov-drop.js` is the one dropdown (`OVDrop.open(anchor, items,
++ opts)`, `.attach(button, items)`, `.pages(anchor, links, icon)`): it opens out
++ of what was pressed, under it or above, stays on screen, and one is open at a
++ time; the bar's site name opens the site's pages with it. `purse.js` gives any
++ site the currencies' effects: `OpenVibePurse.burst(el, id, delta)` plays the
++ gain or spend at an element (inside a dialog too), `.count(el, from, to,
++ format)` counts a number, `data-purse="coins|vibes|balance"` gives a holder
++ the currency's colour as `--pc`. `machines.js` is the one picker of a person's
++ Link computers (`[data-ov-machines]` or `OpenVibeMachines.mount`). `OVUpload`
++ takes `checkDuplicate(file, sha256)` and tells a person they already have a
++ file. The footer strip reads `/api/frame/online`.
++ The balances (4.189): one pill, `#openvibe-chips-all`, at every width, with
one `.ovnav-chip-mini` per balance the person has (OpenCoins, Vibes, the money
balance; only those above zero, the largest first) and one of them showing.
`purse.js`, loaded beside the navbar, draws each currency's icon
ddiff --git a/STATUS.json b/STATUS.json
iindex b8893a2..f3abf0c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.189.0",
++ "version": "4.190.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/appbar.js b/appbar.js
iindex 1e13de1..f8d912f 100644
---- a/appbar.js
++++ b/appbar.js
@@@ -18,6 +18,7 @@
'use strict';
let barEl = null;
let resizeBound = false;
++ let pillWatch = null;
function spec(ctx) {
const cfg = ctx.config.appBar;
@@@ -80,8 +81,12 @@
const ic = bar && bar.querySelector('.ovnav-ab.is-here .ovnav-ab-ic');
if (!pill || !ic) return;
if (instant) bar.classList.add('ab-still');
-- bar.style.setProperty('--ab-x', `${ic.offsetLeft + (ic.offsetWidth - 52) / 2}px`);
-- bar.style.setProperty('--ab-y', `${ic.offsetTop}px`);
++ // From where the icon and the bar are now (not offsetTop: a positioned tab or a late icon made that stale, and
++ // the pill sat above its icon), centred on the icon both ways.
++ const b = bar.getBoundingClientRect(), r = ic.getBoundingClientRect();
++ if (!r.width) return;
++ bar.style.setProperty('--ab-x', `${Math.round(r.left - b.left - bar.clientLeft + (r.width - 52) / 2)}px`);
++ bar.style.setProperty('--ab-y', `${Math.round(r.top - b.top - bar.clientTop + (r.height - 28) / 2)}px`);
const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (f) => setTimeout(f, 16);
if (instant) raf(() => raf(() => bar.classList.remove('ab-still')));
}
@@@ -137,6 +142,14 @@
if (de && de.classList) de.classList.add('ov-has-appbar');
placePill(bar, true);
if (!resizeBound && typeof window !== 'undefined' && typeof window.addEventListener === 'function') { resizeBound = true; window.addEventListener('resize', () => placePill(barEl, true)); }
++ // The icons and the brand font arrive after the bar is drawn, and a tab's label can wrap: place it again whenever
++ // the bar or the lit icon changes size, and once the fonts are in.
++ if (typeof ResizeObserver === 'function') {
++ if (pillWatch) pillWatch.disconnect();
++ pillWatch = new ResizeObserver(() => placePill(barEl, true));
++ pillWatch.observe(bar); bar.querySelectorAll('.ovnav-ab-ic').forEach((el) => pillWatch.observe(el));
++ }
++ if (document.fonts && document.fonts.ready && typeof document.fonts.ready.then === 'function') document.fonts.ready.then(() => placePill(barEl, true)).catch(() => {});
}
const api = { render, spec };
ddiff --git a/dock-actor.js b/dock-actor.js
iindex 78d4d55..e6ac6d9 100644
---- a/dock-actor.js
++++ b/dock-actor.js
@@@ -161,6 +161,26 @@
form.append(input, send);
form.addEventListener('submit', (e) => { e.preventDefault(); const text = input.value.trim(); if (!text) return; act('/tell', { text }, send).then((ok) => { if (ok) input.value = ''; }); });
container.append(form);
++ const history = h('div', 'ovdac-history');
++ const read = h('button', 'ovdac-btn', 'Read recent history'); read.type = 'button';
++ const rows = h('div', 'ovdac-note'); rows.hidden = true;
++ read.addEventListener('click', async () => {
++ read.disabled = true;
++ try {
++ const t = typeof o.token === 'function' ? await o.token() : o.token;
++ if (!t) throw new Error('Sign in to read history.');
++ const url = String(o.networkBase || 'https://openvibe.network').replace(/\/+$/, '') + '/api/v1/history/agents/recent?limit=10';
++ const response = await fetch(url, { headers: { accept: 'application/json', authorization: `Bearer ${t}` }, credentials: 'omit' });
++ const result = await response.json().catch(() => ({}));
++ if (response.status === 403) { rows.textContent = 'Your agent history setting is off. Turn it on in My settings to allow this read.'; }
++ else if (!response.ok) { rows.textContent = 'Recent history is unavailable.'; }
++ else { rows.textContent = (result.items || []).map((x) => `${x.type}: ${x.title}`).join(' · ') || 'No recent history.'; }
++ rows.hidden = false;
++ } catch { rows.textContent = 'Recent history is unavailable.'; rows.hidden = false; }
++ finally { read.disabled = false; }
++ });
++ history.append(read, rows);
++ container.append(history);
}
async function act(path, body, btn) {
ddiff --git a/dock.js b/dock.js
iindex 2673d75..0ea49b4 100644
---- a/dock.js
++++ b/dock.js
@@@ -112,7 +112,7 @@
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
-- const DOCK_REV = { 'dock-vibe.js': '41d5083aeeb8', 'dock-runs.js': 'b734e5e7ba11', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'c2163d1ab4e4', 'dock-agent.js': 'de6612c136a8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
++ const DOCK_REV = { 'dock-vibe.js': '41d5083aeeb8', 'dock-runs.js': 'b734e5e7ba11', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '21c2d71b5e8e', 'dock-agent.js': 'de6612c136a8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
@@@ -578,7 +578,7 @@
});
// Actor v3 (A3): your Actor first, on every site; it shows nothing for a person without one.
load('dock-actor.js', () => !!(root.OpenVibeDockActor && root.OpenVibeDockActor.mount)).then((on) => {
-- if (on && !mounts[id + 'Actor'] && panel.isConnected) mounts[id + 'Actor'] = root.OpenVibeDockActor.mount(panel.querySelector('.ovdk-actor'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), active: () => isOpen() && tab === 'agent' });
++ if (on && !mounts[id + 'Actor'] && panel.isConnected) mounts[id + 'Actor'] = root.OpenVibeDockActor.mount(panel.querySelector('.ovdk-actor'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), networkBase: o.apiBase || 'https://openvibe.network', active: () => isOpen() && tab === 'agent' });
});
load('dock-agent.js', () => !!(root.OpenVibeDockAgent && root.OpenVibeDockAgent.mount)).then((on) => {
if (on && !mounts[id + 'View'] && panel.isConnected) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.querySelector('.ovdk-agent'), { token: o.token, base: directBase(o.agentBase), frameBase: frameBase(), active: () => isOpen() && tab === 'agent' });
ddiff --git a/files.js b/files.js
iindex 948d668..616ae3e 100644
---- a/files.js
++++ b/files.js
@@@ -25,7 +25,7 @@ const BROWSER = Object.freeze([
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
'dock-runs.js', // the Dock's content-aware Agents rows and overview, loaded with that tab
'ai-marks-client.js', // maker marks on a run receipt, loaded only in Agents
-- 'presence.js', 'purse.js', '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', 'overlay.js', 'nav-slots.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent
ddiff --git a/footer.js b/footer.js
iindex 35d9c40..342ecf6 100644
---- a/footer.js
++++ b/footer.js
@@@ -218,6 +218,20 @@
account: '<circle cx="12" cy="9" r="3.2"/><path d="M5.5 19a6.5 6.5 0 0 1 13 0"/>',
themes: '<path d="M12 4.500a7.5 7.5 0 1 0 0 15c1 0 1.6-.8 1.2-1.7-.5-1.1.3-2.3 1.5-2.300h1.600a3.2 3.2 0 0 0 3.2-3.200C19.5 8 16.2 4.5 12 4.500z"/><circle cx="8.5" cy="11" r=".7"/><circle cx="11.5" cy="8" r=".7"/><circle cx="15" cy="9" r=".7"/>',
tools: '<path d="M14.5 5.500a4 4 0 0 0-5.2 5.200L4.5 15.500a1.8 1.8 0 0 0 2.5 2.500l4.8-4.800a4 4 0 0 0 5.2-5.200l-2.6 2.6-2.1-.5-.5-2.100z"/>',
++ // 4.188.0: the strip's icons, drawn by openvibe-shared/glyphs.js (the same hand) for the counts and the
++ // items a site declares. A name the set does not carry falls back to the arrow above.
++ members: '<circle cx="9" cy="8" r="3.2"/><path d="M3 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5M16 4.8a3.2 3.2 0 0 1 0 6.4M18 14.8c1.9.6 3 2.4 3 5.2"/>',
++ video: '<rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3"/>',
++ clipboard: '<rect x="5.5" y="5" width="13" height="16" rx="2"/><path d="M9 5V3.5h6V5M9 11h6M9 15h4"/>',
++ image: '<rect x="3" y="5" width="18" height="14" rx="3"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 8"/>',
++ news: '<path d="M4 5h13v14a2 2 0 0 0 2 2H6a2 2 0 0 1-2-2Z"/><path d="M17 9h3v10a2 2 0 0 1-2 2M7.5 9h6M7.5 12.5h6M7.5 16h3.5"/>',
++ clapper: '<path d="M3.5 10h17v9a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1Z"/><path d="M3.5 10 2.9 6.6l16.4-3 .7 3.6ZM8 5.7l2.3 3.3M13.3 4.7l2.3 3.3"/>',
++ film: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 4v16M16.5 4v16M3 9h4.5M3 15h4.5M16.5 9H21M16.5 15H21"/>',
++ game: '<rect x="2.5" y="7" width="19" height="11" rx="5"/><path d="M7 11v3M5.5 12.5h3M15.5 12h.01M18 13.5h.01"/>',
++ live: '<circle cx="12" cy="12" r="2.5"/><path d="M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14"/>',
++ chart: '<path d="M4 4v16h16"/><path d="m7.5 14.5 3.5-4 3 2.5 5-6"/>',
++ star: '<path d="m12 4 2.4 5 5.4.6-4 3.7 1.1 5.4L12 16l-4.9 2.7 1.1-5.4-4-3.7 5.4-.6Z"/>',
++ sites: '<rect x="4" y="4" width="6.5" height="6.5" rx="1.7"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.7"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.7"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1.7"/>',
};
const icon = (name) => `<svg class="ovf-ic" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.800" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${(ICONS[name] || ICONS.arrow)}</svg>`;
@@@ -290,7 +304,7 @@
const networkLinks = network.slice(0, 8);
return `<div class="ovf-inner${compact ? ' ovf-inner--expanded' : ''}">
-- ${c.pulse === false ? '' : '<p class="ovf-pulse" data-ovf-pulse hidden></p>'}
++ ${c.pulse === false ? '' : '<div class="ovf-pulse" data-ovf-pulse hidden></div>'}
<div class="ovf-cols">
<div class="ovf-col ovf-col--brand">
<a class="ovf-brand" href="/"><span class="ov-mark" data-size="38" data-wide></span><span>${esc(brand)}</span></a>
@@@ -437,6 +451,39 @@
.ovf-pulse{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;margin:0 0 clamp(18px,2.5vw,24px);padding:9px 14px;border-radius:12px;font-size:.8rem;color:var(--text-secondary,#b6bdd2);
background:color-mix(in srgb, var(--accent,#60a5fa) 6%, var(--bg-tertiary,#1a1b28));border:1px solid var(--border,rgba(255,255,255,.08));animation:ovfIn .4s ease-out both}
.ovf-pulse a{color:inherit;text-decoration:none;font-weight:600}
++.ovf-now{flex:1 1 100%;min-width:0;display:grid;gap:8px}
++.ovf-who>summary,.ovf-who--flat{list-style:none;display:flex;flex-wrap:wrap;align-items:center;gap:4px 9px;min-height:36px;color:var(--text-primary,#f1f4fb)}
++.ovf-who>summary{cursor:pointer;border-radius:10px}
++.ovf-who>summary::-webkit-details-marker{display:none}
++.ovf-who>summary::after{content:"";width:.45em;height:.45em;margin:0 4px 3px auto;border:solid var(--text-muted,#8b93ad);border-width:0 1.8px 1.8px 0;transform:rotate(45deg);transition:transform .2s}
++.ovf-who[open]>summary::after{transform:translateY(3px) rotate(-135deg)}
++.ovf-who>summary:focus-visible{outline:2px solid var(--accent,#60a5fa);outline-offset:3px}
++.ovf-who-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--success-text,#34d399);box-shadow:0 0 0 3px color-mix(in srgb,var(--success-text,#34d399) 22%,transparent);animation:ovf-on 2.4s ease-in-out infinite}
++@keyframes ovf-on{50%{box-shadow:0 0 0 calc(3px + 3px*var(--ov-move,1)) color-mix(in srgb,var(--success-text,#34d399) 6%,transparent)}}
++.ovf-who-n{font-weight:500}.ovf-who-n b{font-size:1.05em;font-weight:800;font-variant-numeric:tabular-nums}
++.ovf-who-sub{color:var(--text-muted,#8b93ad)}
++.ovf-who-faces{display:inline-flex;padding-left:7px}
++.ovf-who-faces img,.ovf-who-faces i{width:24px;height:24px;margin-left:-7px;border-radius:50%;box-shadow:0 0 0 2px var(--bg-secondary,#0f1420);object-fit:cover}
++.ovf-who i{display:grid;place-items:center;font-style:normal;font-size:.72rem;font-weight:800;color:var(--text-primary,#f1f4fb);background:color-mix(in srgb,var(--accent,#3b82f6) 38%,var(--bg-secondary,#0f1420))}
++.ovf-who-list{list-style:none;display:flex;flex-wrap:wrap;gap:6px;margin:8px 0 0;padding:0;animation:ovf-list .3s cubic-bezier(.2,.9,.3,1.1) both}
++@keyframes ovf-list{from{opacity:0;transform:translateY(calc(-6px*var(--ov-move,1)))}}
++.ovf-who-list a{display:inline-flex;align-items:center;gap:7px;min-height:36px;box-sizing:border-box;padding:3px 12px 3px 4px;border-radius:999px;color:var(--text-primary,#f1f4fb);background:color-mix(in srgb,var(--text-primary,#f1f4fb) 6%,transparent);transition:background-color .15s,transform .12s}
++.ovf-who-list a:hover{background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent);color:var(--text-primary,#f1f4fb)}
++.ovf-who-list a:active{transform:scale(.96)}
++.ovf-who-list img,.ovf-who-list i{flex:none;width:26px;height:26px;border-radius:50%;object-fit:cover}
++.ovf-who-list small{color:var(--text-muted,#8b93ad);font-weight:500}
++.ovf-who-more{margin:8px 2px 0;color:var(--text-muted,#8b93ad)}
++.ovf-tiles{display:flex;gap:6px;margin:0 -14px;padding:0 14px 2px;overflow-x:auto;overscroll-behavior-x:contain;scrollbar-width:none}
++.ovf-tiles::-webkit-scrollbar{display:none}
++.ovf-tile{flex:none;display:inline-flex;align-items:center;gap:6px;min-height:36px;box-sizing:border-box;padding:0 12px 0 10px;border-radius:999px;white-space:nowrap;font-weight:500!important;color:var(--text-secondary,#b6bdd2);background:color-mix(in srgb,var(--text-primary,#f1f4fb) 5%,transparent);transition:background-color .15s,transform .12s}
++a.ovf-tile:hover{color:var(--text-primary,#f1f4fb);background:color-mix(in srgb,var(--accent,#3b82f6) 15%,transparent)}
++a.ovf-tile:active{transform:scale(.96)}
++.ovf-tile b{color:var(--text-primary,#f1f4fb);font-weight:800;font-variant-numeric:tabular-nums}
++.ovf-tile small{color:var(--text-muted,#8b93ad)}
++.ovf-tile .ovf-ic{font-size:1.15em;color:var(--accent-light,var(--accent,#60a5fa))}
++.ovf-tile.is-live{background:color-mix(in srgb,var(--live-red-text,#ef4444) 15%,transparent)}
++.ovf-tile.is-channel{padding:0 12px;color:var(--text-primary,#f1f4fb)}
++@media (pointer:coarse){.ovf-who>summary,.ovf-who--flat,.ovf-tile,.ovf-who-list a{min-height:44px}}
.ovf-pulse a:hover{color:var(--accent,#60a5fa)}
.ovf-pulse .ovf-sep{color:var(--text-muted,#8b93ad)}
.ovf-pulse-live{display:inline-flex;align-items:center;gap:7px;color:var(--text-primary,#f1f4fb)!important}
@@@ -532,10 +579,17 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
// ── The pulse: the network right now ───────────────────────
// Read once the footer is in view (nobody who never scrolls there costs a request), then every minute while it is.
++ // One read fills the strip (owner, 2026-11-10): GET /api/frame/online says who was on the network in the last
++ // thirty minutes (members by name only when their presence lets it, guests as a count), the live channels, a few
++ // counts that open what they count, and this site's own items. The old /api/frame/pulse line is the fallback
++ // until the main session's design lands, and its four facts are kept here from the same read.
const PULSE_URL = `${NETWORK_URL}/api/frame/pulse`;
++ const ONLINE_URL = `${NETWORK_URL}/api/frame/online`;
const PULSE_MS = 60_000;
let _pulse = null;
let _pulseAt = 0;
++ let _online = null;
++ let _onlineAt = 0;
let _pulseObserver = null;
let _pulseTimer = null;
const fmtN = (n) => (n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(n));
@@@ -551,9 +605,74 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
if (p.views_7d > 0) bits.push(`<span>${fmtN(p.views_7d)} page views this week</span>`);
return bits.join('<span class="ovf-sep" aria-hidden="true">·</span>');
}
++ /** A number from the read, or null; the read is ours but nothing on a page trusts its input. */
++ const onlineN = (v) => (v === null || v === undefined || !Number.isFinite(Number(v)) ? null : Math.max(0, Math.floor(Number(v))));
++ const onlineUrl = (u) => (/^https:\/\/[^\s"'<>]+$/i.test(String(u || '')) ? String(u) : '');
++ const onlineText = (v, max) => String(v == null ? '' : v).replace(/[<>]/g, '').replace(/\s+/g, ' ').trim().slice(0, max);
++ /** The read, cleaned: only what the strip draws, every string capped, every link https. */
++ function cleanOnline(o) {
++ if (!o || typeof o !== 'object') return null;
++ const people = (Array.isArray(o.people) ? o.people : []).slice(0, 60).map((p) => {
++ const username = onlineText(p && p.username, 40);
++ const avatar = onlineUrl(p && p.avatar);
++ return username && /^[A-Za-z0-9_.-]+$/.test(username)
++ ? { name: onlineText(p.name, 60) || username, username, avatar, site: onlineText(p.site, 40) }
++ : null;
++ }).filter(Boolean);
++ const row = (r, withLine) => {
++ const label = onlineText(r && r.label, 40), link = onlineUrl(r && r.link);
++ if (!label || !link) return null;
++ const value = onlineN(r.value);
++ const out = { label, link, icon: onlineText(r.icon, 40) };
++ if (value != null && r.value !== undefined) out.value = value;
++ else if (withLine) out.line = onlineText(r.line, 80);
++ return out;
++ };
++ const live = o.live && typeof o.live === 'object'
++ ? { channels: onlineN(o.live.channels), watching: onlineN(o.live.watching), first: (Array.isArray(o.live.first) ? o.live.first : []).slice(0, 3).map((s) => ({ name: onlineText(s && s.name, 100), url: onlineUrl(s && s.url) })).filter((s) => s.name && s.url) }
++ : null;
++ return {
++ members: onlineN(o.members), guests: onlineN(o.guests), total: onlineN(o.total), more: onlineN(o.more),
++ people, live,
++ counts: (Array.isArray(o.counts) ? o.counts : []).map((c) => row({ ...c, link: c && (c.link || c.url) }, false)).filter(Boolean),
++ items: (Array.isArray(o.items) ? o.items : []).map((i) => row(i, true)).filter(Boolean),
++ sites: onlineN(o.sites), views_7d: onlineN(o.views_7d),
++ };
++ }
++ /** The strip from the one read: the old facts it carries, who is online, the counts and this site's own items. */
++ function onlineHTML(o) {
++ // Who is on the network, first: a count with a few faces, and (where anyone may be named) the list an old forum
++ // had under its pages: every member by name, then how many more and how many guests. A guest is a number only.
++ let who = '';
++ if (o.total > 0) {
++ const face = (p, size) => (p.avatar ? `<img src="${esc(p.avatar)}" alt="" width="${size}" height="${size}" loading="lazy" decoding="async">` : `<i aria-hidden="true">${esc(p.name.charAt(0).toUpperCase())}</i>`);
++ const parts = [o.members > 0 ? `${fmtN(o.members)} member${o.members === 1 ? '' : 's'}` : '', o.guests > 0 ? `${fmtN(o.guests)} guest${o.guests === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ');
++ const head = `<span class="ovf-who-dot" aria-hidden="true"></span><span class="ovf-who-n"><b>${fmtN(o.total)}</b> online in the last 30 min</span>${o.people.length ? `<span class="ovf-who-faces" aria-hidden="true">${o.people.slice(0, 5).map((p) => face(p, 24)).join('')}</span>` : ''}${parts ? `<span class="ovf-who-sub">${parts}</span>` : ''}`;
++ const list = o.people.map((p) => `<li><a href="${NETWORK_URL}/@${encodeURIComponent(p.username)}">${face(p, 26)}<span>${esc(p.name)}</span>${p.site ? `<small>${esc(p.site)}</small>` : ''}</a></li>`).join('');
++ who = list
++ ? `<details class="ovf-who"><summary>${head}</summary><ul class="ovf-who-list">${list}</ul>${o.more > 0 ? `<p class="ovf-who-more">and ${fmtN(o.more)} more</p>` : ''}</details>`
++ : `<div class="ovf-who ovf-who--flat">${head}</div>`;
++ }
++ // Then a row of small tiles, each opening what it counts: live (only while someone is), the counts, this
++ // site's own items, and the two facts the strip always had.
++ const tile = (href, cls, inner) => (href ? `<a class="ovf-tile${cls}" href="${esc(href)}">${inner}</a>` : `<span class="ovf-tile${cls}">${inner}</span>`);
++ const row = (r) => tile(r.link, '', `${icon(r.icon)}${r.value != null ? `<b>${esc(fmtN(r.value))}</b>` : ''}<span>${esc(r.label)}</span>${r.line ? `<small>${esc(r.line)}</small>` : ''}`);
++ const tiles = [];
++ const live = o.live;
++ if (live && live.channels > 0) {
++ tiles.push(tile('https://openvibe.live/', ' is-live', `<span class="ovf-dot" aria-hidden="true"></span><b>${fmtN(live.channels)}</b><span>live now</span>${live.watching > 0 ? `<small>${fmtN(live.watching)} watching</small>` : ''}`));
++ live.first.forEach((c) => tiles.push(tile(c.url, ' is-channel', `<span>${esc(c.name)}</span>`)));
++ }
++ o.counts.forEach((c) => tiles.push(row(c)));
++ o.items.forEach((i) => tiles.push(row(i)));
++ if (o.sites > 0) tiles.push(tile(`${NETWORK_URL}/#network`, '', `${icon('sites')}<b>${fmtN(o.sites)}</b><span>sites, one account</span>`));
++ if (o.views_7d > 0) tiles.push(tile('', '', `${icon('chart')}<b>${fmtN(o.views_7d)}</b><span>page views this week</span>`));
++ const more = o.counts.length || o.items.length;
++ return `<div class="ovf-now">${who}${tiles.length ? `<div class="ovf-tiles${more ? ' ovf-online-actions' : ''}">${tiles.join('')}</div>` : ''}</div>`;
++ }
function paintPulse() {
-- if (!_mounted || !_pulse) return;
-- const html = pulseHTML(_pulse);
++ if (!_mounted) return;
++ const html = _online ? onlineHTML(_online) : (_pulse ? pulseHTML(_pulse) : '');
for (const el of _mounted.querySelectorAll('[data-ovf-pulse]')) { el.innerHTML = html; el.hidden = !html; }
}
function readPulse() {
@@@ -567,18 +686,33 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
paintPulse();
}).catch(() => { /* a quiet footer is fine */ });
}
++ /** The strip's one read; the old pulse endpoint only when it does not answer, until the design lands. */
++ function readNetwork() {
++ if (typeof fetch === 'undefined' || (typeof document !== 'undefined' && document.hidden)) return;
++ if (_online && Date.now() - _onlineAt < PULSE_MS - 1000) { paintPulse(); return; }
++ // This site's own items come back only when it asks as itself (its service id, or its site id).
++ const site = (_cfg.service || detectService() || '');
++ const url = site ? `${ONLINE_URL}?site=${encodeURIComponent(site)}` : ONLINE_URL;
++ fetch(url, { credentials: 'omit' }).then(r => (r.ok ? r.json() : null)).then((o) => {
++ const clean = cleanOnline(o);
++ if (!clean) { readPulse(); return; }
++ _online = clean;
++ _onlineAt = Date.now();
++ paintPulse();
++ }).catch(() => { readPulse(); });
++ }
function watchPulse() {
if (!_mounted || _cfg.pulse === false || typeof window === 'undefined') return;
-- if (_pulse) paintPulse();
++ if (_online || _pulse) paintPulse();
if (_pulseObserver) return;
-- if (typeof IntersectionObserver !== 'function') { readPulse(); return; }
++ if (typeof IntersectionObserver !== 'function') { readNetwork(); return; }
_pulseObserver = new IntersectionObserver((entries) => {
const seen = entries.some(e => e.isIntersecting);
clearInterval(_pulseTimer);
_pulseTimer = null;
if (!seen) return;
-- readPulse();
-- _pulseTimer = setInterval(readPulse, PULSE_MS);
++ readNetwork();
++ _pulseTimer = setInterval(readNetwork, PULSE_MS);
}, { rootMargin: '200px 0px' });
_pulseObserver.observe(_mounted);
}
@@@ -587,7 +721,7 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
let _shippedLoading = false;
// shipped.js's content hash (scripts/sibling-revs.js writes it), and the footer's "shipped" line is fetched only when the
// footer comes near the screen (4.138.0): most visits never scroll there, and it is 12 KB.
-- const FOOTER_REV = { 'shipped.js': '143a6975e27a' };
++ const FOOTER_REV = { 'shipped.js': 'c999118b94ee' };
let _shippedSeen = null;
function shippedWhenNear(el) {
if (typeof IntersectionObserver !== 'function' || !el || !el.getBoundingClientRect) { loadShipped(); return; }
@@@ -669,5 +803,5 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
return `<style id="ovf-css">${CSS}</style><footer id="${id}" class="ovf" data-variant="${c.variant === 'compact' ? 'compact' : 'full'}">${buildHTML(c)}</footer>`;
}
-- return { init, ssr, setVariant, claim, buildHTML, render, CSS, NETWORK, LEGAL, detectService, pulseHTML, autoFor, get config() { return { ..._cfg }; }, get variant() { return shownVariant(_cfg.variant); } };
++ return { init, ssr, setVariant, claim, buildHTML, render, CSS, NETWORK, LEGAL, detectService, pulseHTML, onlineHTML, cleanOnline, autoFor, get config() { return { ..._cfg }; }, get variant() { return shownVariant(_cfg.variant); } };
});
ddiff --git a/history.js b/history.js
iindex 028c7f8..8a92649 100644
---- a/history.js
++++ b/history.js
@@@ -65,6 +65,9 @@
type: String(entry.type || 'page').slice(0, 32),
title: String(entry.title || (typeof document !== 'undefined' ? document.title : '') || url).slice(0, 200),
url,
++ image_url: String(entry.image_url || entry.picture || '').slice(0, 2000) || null,
++ private: entry.private === true,
++ mature: entry.mature === true,
icon: entry.icon || ICONS[entry.type] || null,
service: entry.service || inf.service || null,
sub: entry.sub || inf.sub || null,
ddiff --git a/machines.js b/machines.js
nnew file mode 100644
iindex 0000000..dc5e023
---- /dev/null
++++ b/machines.js
@@@ -0,0 +1,156 @@
++/* Shared picker for a person's OpenVibe Link machines. Pages provide a signed-in
++ * Network token through OpenVibeNavbar and may declare data-needs for filtering. */
++(function (root) {
++ 'use strict';
++
++ function safe(value) { return String(value == null ? '' : value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
++ function canRun(machine) {
++ const roles = Array.isArray(machine.roles) ? machine.roles : [];
++ return roles.map((role) => ({ 'codes.worker': 'coding jobs', 'desks.host': 'browser desks', 'ai.host': 'your AI models', 'bot.robot': 'robot controls' }[role] || role)).join(', ') || 'Link connection';
++ }
++ function needsMachine(machine, needs) {
++ if (!needs) return true;
++ const roles = Array.isArray(machine.roles) ? machine.roles : [];
++ const map = { desktop: ['desks.host'], browser: ['desks.host'], models: ['ai.host'], worker: ['codes.worker'] };
++ return (map[needs] || [needs]).some((role) => roles.includes(role));
++ }
++ function sessionToken() {
++ try { const nav = root.OpenVibeNavbar; return nav && nav.session && nav.session.token || null; } catch (_) { return null; }
++ }
++ const svg = (d) => `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
++ const G = {
++ pc: svg('<rect x="3" y="4.5" width="18" height="12" rx="2.5"/><path d="M8.5 20h7M12 16.5V20"/>'),
++ phone: svg('<rect x="7" y="2.5" width="10" height="19" rx="3"/><path d="M10.5 18h3"/>'),
++ auto: svg('<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9L12 17.5l-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.5 15.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z"/>'),
++ plus: svg('<path d="M12 5.5v13M5.5 12h13"/>'),
++ lock: svg('<rect x="5" y="10.5" width="14" height="9.5" rx="2.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>'),
++ off: svg('<path d="M4 4l16 16M8.6 8.7A6.5 6.5 0 0 0 5.5 12M18.5 12a6.5 6.5 0 0 0-5-6.3M2.5 9a12 12 0 0 1 3-2.3M21.5 9a12 12 0 0 0-6-3.4M12 18.5v.01"/>'),
++ };
++ // "2026-11-10T08:14:00Z" → "3 h ago": when a computer was last seen, as a person would say it.
++ function ago(iso) {
++ const t = Date.parse(iso); if (!isFinite(t)) return String(iso || '');
++ const m = Math.max(0, Math.round((Date.now() - t) / 60000));
++ if (m < 2) return 'just now'; if (m < 60) return `${m} min ago`;
++ const h = Math.round(m / 60); if (h < 24) return `${h} h ago`;
++ const d = Math.round(h / 24); return d < 30 ? `${d} ${d === 1 ? 'day' : 'days'} ago` : new Date(t).toISOString().slice(0, 10);
++ }
++ const CSS = `
++.ovmc { display: grid; gap: 8px; }
++.ovmc p { margin: 0; }
++.ovmc-list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
++.ovmc-row { position: relative; margin: 0; padding: 0; border: 0; background: none; }
++.ovmc-row > label { position: relative; display: flex; align-items: center; gap: 12px; min-height: 60px; box-sizing: border-box; padding: 8px 14px 8px 10px; border-radius: 16px; cursor: pointer; background: color-mix(in srgb, var(--text-primary, #e6edf7) 4.5%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); transition: background-color .15s, box-shadow .2s, transform .12s; }
++.ovmc-row > label:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }
++.ovmc-row > label:active { transform: scale(.985); }
++.ovmc-row input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
++.ovmc-row:has(input:checked) > label { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); }
++.ovmc-row:has(input:focus-visible) > label { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.ovmc-ic { flex: none; display: grid; place-items: center; width: 42px; height: 42px; border-radius: 12px; color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 13%, transparent); }
++.ovmc-row.is-off .ovmc-ic { color: var(--text-muted, #93a0b4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }
++.ovmc-row .ovk-row-main { display: grid; gap: 2px; min-width: 0; flex: 1; }
++.ovmc-row .ovk-row-top { display: flex; align-items: center; gap: 8px; min-width: 0; }
++.ovmc-row strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
++.ovmc-row .ovk-row-sub { font-size: 12.5px; color: var(--text-secondary, #a8b3c4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovmc-state { flex: none; display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-muted, #93a0b4); }
++.ovmc-state::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .6; }
++.ovmc-state.is-on { color: var(--success-text, #34d399); }
++.ovmc-state.is-on::before { opacity: 1; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 22%, transparent); animation: ovmc-on 2.4s ease-in-out infinite; }
++@keyframes ovmc-on { 50% { box-shadow: 0 0 0 calc(3px + 3px * var(--ov-move, 1)) color-mix(in srgb, currentColor 6%, transparent); } }
++.ovmc-tick { flex: none; width: 22px; height: 22px; border-radius: 50%; box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--text-primary, #e6edf7) 26%, transparent); transition: background-color .15s, box-shadow .15s; }
++.ovmc-row:has(input:checked) .ovmc-tick { background: var(--accent, #3b82f6); box-shadow: none; position: relative; }
++.ovmc-row:has(input:checked) .ovmc-tick::after { content: ""; position: absolute; left: 8px; top: 4.5px; width: 5px; height: 10px; border-right: 2.4px solid #fff; border-bottom: 2.4px solid #fff; transform: rotate(45deg); animation: ovmc-tick .25s cubic-bezier(.2, 1.6, .4, 1) both; }
++@keyframes ovmc-tick { from { transform: rotate(45deg) scale(0); } }
++.ovmc-note { display: flex; align-items: center; gap: 12px; padding: 8px 12px 8px 10px; border-radius: 16px; font-size: 14px; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 4.5%, transparent); }
++.ovmc-note .ovmc-ic { color: var(--text-muted, #93a0b4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }
++.ovmc-wait { display: grid; gap: 6px; }
++.ovmc-wait span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
++.ovmc-wait i { display: block; height: 60px; border-radius: 16px; background: linear-gradient(100deg, color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent) 40%, color-mix(in srgb, var(--text-primary, #e6edf7) 11%, transparent) 50%, color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent) 60%) 0 0 / 220% 100%; animation: ovmc-wait 1.4s ease-in-out infinite; }
++.ovmc-wait i + i { opacity: .6; animation-delay: .15s; }
++@keyframes ovmc-wait { to { background-position: -120% 0; } }
++.ovmc-add a.ovk-btn { display: flex; align-items: center; justify-content: flex-start; gap: 12px; width: 100%; min-height: 60px; box-sizing: border-box; padding: 8px 14px 8px 10px; border-radius: 16px; text-align: left; text-decoration: none; color: inherit; background: none; border: 1.5px dashed color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); transition: background-color .15s, border-color .15s, transform .12s; }
++.ovmc-add a.ovk-btn:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); border-color: var(--accent, #3b82f6); }
++.ovmc-add a.ovk-btn:active { transform: scale(.985); }
++.ovmc-add b { display: block; font-size: 15px; }
++.ovmc-add small { display: block; font-size: 12.5px; font-weight: 500; color: var(--text-secondary, #a8b3c4); }`;
++ function styles() {
++ if (!root.document || root.document.getElementById('ovmc-style')) return;
++ const st = root.document.createElement('style'); st.id = 'ovmc-style'; st.textContent = CSS; root.document.head.appendChild(st);
++ }
++ function mount(host, options) {
++ styles();
++ options = options || {};
++ if (!host) throw new TypeError('A machine picker host is required');
++ const needs = options.needs || host.getAttribute('data-needs') || '';
++ const inputName = options.name || host.getAttribute('data-name') || 'machine';
++ const autoLabel = options.autoLabel || host.getAttribute('data-auto-label') || '';
++ const principalName = options.principalName || host.getAttribute('data-principal-name') || '';
++ let principalValue = host.getAttribute('data-principal-value') || '';
++ let selected = options.value || host.getAttribute('data-value') || (host.querySelector('input[type="hidden"]') || {}).value || '';
++ let machines = [];
++ let state = 'loading';
++ const addUrl = (options.addUrl || host.getAttribute('data-add-url') || 'https://openvibe.link/add-computer');
++ function paint() {
++ const hidden = host.querySelector('input[type="hidden"]');
++ const value = hidden ? hidden.value : selected;
++ let html = '';
++ const row = (id, checked, glyph, name, meta, sub, cls) => `<li class="ovk-row ovmc-row${cls || ''}"><label class="ovk-row--static"><input type="radio" name="${safe(inputName)}__choice" value="${safe(id)}"${checked ? ' checked' : ''}><span class="ovmc-ic" aria-hidden="true">${glyph}</span><span class="ovk-row-main"><span class="ovk-row-top"><strong>${safe(name)}</strong>${meta}</span>${sub ? `<span class="ovk-row-sub"><span>${safe(sub)}</span></span>` : ''}</span><span class="ovmc-tick" aria-hidden="true"></span></label></li>`;
++ const auto = (checked) => (autoLabel ? row('', checked, G.auto, autoLabel, '', 'OpenVibe picks where it runs') : '');
++ const note = (glyph, text) => `<p class="ovmc-note" role="status"><span class="ovmc-ic" aria-hidden="true">${glyph}</span><span>${text}</span></p>`;
++ if (state === 'loading') html = '<p class="ovmc-wait" role="status"><span>Loading your Link computers…</span><i></i><i></i></p>';
++ else if (state === 'signed-out') html = note(G.lock, 'Sign in to choose a computer.');
++ else if (state === 'unreachable') html = note(G.off, 'Link could not be reached. Your current choice is kept.');
++ else if (!machines.length) html = note(G.pc, needs ? 'None of your computers can run this yet.' : 'You have no computers on Link yet.') + (autoLabel ? `<ul class="ovk-list ovmc-list">${auto(true)}</ul>` : '');
++ else {
++ html = '<ul class="ovk-list ovmc-list">' + auto(!value) + machines.map((m) => {
++ const system = m.system || m.os || m.kind || 'Link computer';
++ const meta = `<span class="ovk-row-meta ovmc-state${m.online ? ' is-on' : ''}">${m.online ? 'Online' : (m.last_seen_at ? `Seen ${safe(ago(m.last_seen_at))}` : 'Offline')}</span>`;
++ return row(m.id, m.id === value, /phone|android|ios/i.test(system) ? G.phone : G.pc, m.name || m.id, meta, `${system} · Can run: ${canRun(m)}`, m.online ? '' : ' is-off');
++ }).join('') + '</ul>';
++ }
++ html += `<p class="ovmc-add"><a class="ovk-btn ovk-btn--quiet" href="${safe(addUrl)}?return_to=${encodeURIComponent(location.href)}"><span class="ovmc-ic" aria-hidden="true">${G.plus}</span><span><b>Add a computer</b><small>Through OpenVibe Link, once, for every site</small></span></a></p>`;
++ host.classList.add('ovmc');
++ host.innerHTML = html;
++ let hiddenInput = host.querySelector('input[type="hidden"]');
++ if (!hiddenInput) { hiddenInput = document.createElement('input'); hiddenInput.type = 'hidden'; host.appendChild(hiddenInput); }
++ hiddenInput.name = inputName; hiddenInput.value = selected;
++ host.value = selected;
++ if (principalName) {
++ let principal = host.querySelector('input[type="hidden"][data-machine-principal]');
++ if (!principal) { principal = document.createElement('input'); principal.type = 'hidden'; principal.setAttribute('data-machine-principal', ''); host.appendChild(principal); }
++ principal.name = principalName;
++ principal.value = (machines.find((m) => m.id === selected) || {}).principal_id || principalValue;
++ }
++ host.querySelectorAll('input[type="radio"]').forEach((radio) => radio.addEventListener('change', () => {
++ if (!radio.checked) return;
++ selected = radio.value; hiddenInput.value = selected;
++ host.value = selected;
++ const chosen = machines.find((m) => m.id === selected);
++ if (principalName) {
++ const principal = host.querySelector('input[type="hidden"][data-machine-principal]');
++ principalValue = chosen && chosen.principal_id || '';
++ if (principal) principal.value = principalValue;
++ }
++ host.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { id: selected, machine: chosen || null } }));
++ }));
++ }
++ async function load() {
++ const token = options.getToken ? await options.getToken() : sessionToken();
++ if (!token) { state = 'signed-out'; paint(); return; }
++ state = 'loading'; paint();
++ try {
++ const api = options.apiBase || host.getAttribute('data-api-base') || 'https://openvibe.network';
++ const response = await (options.fetch || fetch)(`${api.replace(/\/$/, '')}/api/v1/machines`, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' }, credentials: 'omit' });
++ if (!response.ok) throw new Error(`Link returned ${response.status}`);
++ const data = await response.json();
++ machines = (Array.isArray(data.machines) ? data.machines : []).filter((m) => m && typeof m.id === 'string' && needsMachine(m, needs));
++ state = 'ready';
++ } catch (_) { state = 'unreachable'; }
++ paint();
++ }
++ paint(); load();
++ return { reload: load, get value() { return selected; }, setValue(id) { selected = String(id || ''); paint(); } };
++ }
++ root.OpenVibeMachines = { mount, needsMachine, canRun };
++ if (root.document) root.document.querySelectorAll('[data-ov-machines]').forEach((host) => mount(host));
++ if (typeof module === 'object' && module.exports) module.exports = root.OpenVibeMachines;
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/navbar.js b/navbar.js
iindex 134eb78..6b7189e 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -73,6 +73,9 @@
.openvibe-navbar .openvibe-navbar-brand .name a.b-sub { color: var(--text-primary, #e6edf7); font-weight: 900; }
.openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 800; }
.openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 800; }
++ .openvibe-navbar .openvibe-navbar-brand .name a.has-drop::after { content: ""; flex: none; display: inline-block; width: 5px; height: 5px; margin: 0 2px 3px 6px; border: solid currentColor; border-width: 0 1.8px 1.8px 0; transform: rotate(45deg); opacity: .65; transition: transform .2s; }
++ .openvibe-navbar .openvibe-navbar-brand .name a.has-drop[aria-expanded="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }
++ .openvibe-navbar .openvibe-navbar-brand .name a.has-drop[aria-expanded="true"]::after { transform: translateY(2px) rotate(225deg); }
.openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 700; }
.openvibe-navbar .openvibe-navbar-brand .name a:hover { color: var(--accent-light, var(--accent, #60a5fa)); background: var(--accent-glow, rgba(59,130,246,.12)); }
.openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0; opacity: .5; }
@@@ -255,6 +258,9 @@
.openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; }
.openvibe-navbar-links { display: flex; align-items: center; gap: 2px; margin-left: 2px; }
++ .openvibe-navbar-links > *, .openvibe-navbar-right { flex: none; white-space: nowrap; }
++ @media (min-width: 861px) { .openvibe-navbar .openvibe-navbar-brand { flex: none; } }
++ .openvibe-navbar-links > .ovnav-out { display: none; }
.openvibe-navbar-links a {
padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500;
color: var(--text-secondary, #b0b0b8); text-decoration: none;
@@@ -312,6 +318,9 @@
/* A close mark that arrives already made (the Dock's sheet) forms from the button it came from. */
.ovmb.is-forming :is(.ovmb-bar, .ovmb-app) { animation: ovmb-form .5s cubic-bezier(.2,1.3,.3,1) both; }
@keyframes ovmb-form { from { transform: none; } }
++ .ovnav-orb:not([aria-expanded="true"]):not(.is-dock) .ovmb:not(.is-x) .ovmb-app { animation: ovmb-idle 9s ease-in-out 3s infinite; }
++ .ovnav-orb .ovmb .ovmb-a1 { animation-delay: 3.09s !important; } .ovnav-orb .ovmb .ovmb-a2 { animation-delay: 3.18s !important; }
++ @keyframes ovmb-idle { 0%, 4%, 100% { transform: none; } 2% { transform: translateY(calc(-2.2px * var(--ov-move, 1))); } }
@keyframes ovmb-wave { 0%, 100% { transform: translateY(0); } 40% { transform: translateY(calc(-2.6px * var(--ov-move, 1))); } }
@keyframes ovmb-busy { 0%, 100% { transform: scale(.72); opacity: .5; } 50% { transform: scale(1.18); opacity: 1; } }
.openvibe-navbar .ovnav-orb[aria-expanded="true"] { color: var(--text-primary, #e6edf7); }
@@@ -352,6 +361,16 @@
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; } }
/* 4.148.0 (the owner, 11-08: "the things need to be more consistent in the nav bar in size"): one height for
every control at the bar's right, 36 px (34 px on a phone under 400 px): Sign In, the orb, messages, the
picture, the balances and a featured link. The orb draws its dots at 26 px (24) so it reads as large as
@@@ -548,7 +567,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': '4235b5850241', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'b64ea0c39d34', 'history.js': '1e6598f78ba4', 'appbar.js': '4e34b7d60372', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '135d730d03fd', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e', 'purse.js': 'd1d5b341c610' };
++ 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' };
// 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">.
@@@ -1019,16 +1038,19 @@
let _renderAbort = null;
const onDocument = () => (_renderAbort ? { signal: _renderAbort.signal } : undefined);
-- /** One anonymous page-view count per page load (host only), which is what orders the network's
-- * navigation. Skipped when the browser asks not to be tracked. */
++ /** One anonymous page-view count per page load (host only), which orders the network's navigation; skipped when
++ * the browser asks not to be tracked. `?in=1` says this browser is signed in, never who; it waits 2.5 s for that. */
let _counted = false;
function countView() {
if (_counted || _config.countViews === false) return; _counted = true;
try {
-- if (navigator.globalPrivacyControl || navigator.doNotTrack === '1') return;
-- if (!/^https:$/.test(location.protocol)) return;
-- const url = 'https://openvibe.network/api/frame/hit';
-- if (navigator.sendBeacon) navigator.sendBeacon(url);
++ if (navigator.globalPrivacyControl || navigator.doNotTrack === '1' || !/^https:$/.test(location.protocol)) return;
++ const hit = 'https://openvibe.network/api/frame/hit';
++ let sent = false;
++ const send = () => { if (sent) return; sent = true; try { if (navigator.sendBeacon) navigator.sendBeacon(_config.user && !_config.user.is_anon ? `${hit}?in=1` : hit); } catch { /* */ } };
++ if (_config.user || _config.auth === 'external') return send();
++ document.addEventListener('openvibe-navbar-auth', send, { once: true });
++ setTimeout(send, 2500);
} catch { /* */ }
}
@@@ -2096,14 +2118,14 @@
<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>` : ''}
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
-- `<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
++ `<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>`}
</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>` : ''}
`;
if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
bindLinks(nav, links);
hookSearch(nav);
-- hookSwitcher(nav);
++ hookSwitcher(nav); hookPages(nav); hookSignIn(nav); hookFit(nav);
bindChips(nav);
bindChipsAll(nav);
queueIcons();
@@@ -2334,6 +2356,47 @@
try { if (Date.now() - Number(localStorage.getItem(key) || 0) < 30 * 60000) return; localStorage.setItem(key, String(Date.now())); } catch { /* storage off: still record */ }
fetch('https://openvibe.network/api/themes/me/sites/visit', { method: 'POST', headers: { Authorization: `Bearer ${_config.token}`, 'Content-Type': 'application/json' }, credentials: 'include', keepalive: true, body: JSON.stringify({ site: _config.service }) }).catch(() => {});
}
++ // The site's name opens its pages (ov-drop.js); with a modifier key it is a link.
++ function hookPages(nav) {
++ const a = nav.querySelector('.openvibe-navbar-brand .name a.b-tld');
++ if (!a || typeof a.removeAttribute !== 'function' || !flatLinks(rawLinks()).some((l) => l && l.href && !l.hidden)) return;
++ a.classList.add('has-drop'); a.setAttribute('aria-haspopup', 'menu'); a.setAttribute('aria-expanded', 'false'); a.removeAttribute('title');
++ a.addEventListener('click', (e) => {
++ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
++ e.preventDefault();
++ const go = () => root.OVDrop && root.OVDrop.pages(a, flatLinks(rawLinks()), navIcon);
++ if (root.OVDrop) return go();
++ if (document.getElementById('ov-drop-loader')) return;
++ const sc = document.createElement('script'); sc.id = 'ov-drop-loader'; sc.src = siblingV('ov-drop.js'); sc.async = true; sc.onload = go; document.head.appendChild(sc);
++ });
++ }
++ function hookSignIn(nav) {
++ const b = nav.querySelector('#openvibe-login-btn');
++ if (!b || !b.classList || typeof b.addEventListener !== 'function') return;
++ b.addEventListener('click', (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey) return; b.classList.add('is-going'); setTimeout(() => b.classList.remove('is-going'), 6000); });
++ }
++ // Links leave from the end until the bar holds one row (they stay in the menu); the site's "More" leaves last.
++ function fitLinks() {
++ const nav = (this && this.nodeType === 1 ? this : null) || _navEl, box = nav && nav.querySelector && nav.querySelector('.openvibe-navbar-links');
++ if (!box || !box.children || !nav.clientWidth) return;
++ const kids = Array.from(box.children);
++ kids.forEach((k) => k.classList.remove('ovnav-out'));
++ const more = (k) => !!(k.querySelector && k.querySelector('[data-link-id="ovnav-more"]'));
++ const order = kids.filter((k) => !more(k)).reverse().concat(kids.filter(more));
++ for (let i = 0; i < order.length && nav.scrollWidth > nav.clientWidth + 1; i += 1) order[i].classList.add('ovnav-out');
++ }
++ let _fitWatch = null, _fitSeen = null;
++ function hookFit(nav) {
++ fitLinks.call(nav);
++ if (typeof ResizeObserver !== 'function' || typeof MutationObserver !== 'function') return;
++ if (_fitWatch) { _fitWatch.disconnect(); _fitSeen.disconnect(); }
++ let t = 0;
++ const soon = () => { clearTimeout(t); t = setTimeout(() => fitLinks.call(nav), 60); };
++ _fitWatch = new ResizeObserver(soon); _fitWatch.observe(nav);
++ _fitSeen = new MutationObserver((list) => { if (list.some((m) => !(m.target.closest && m.target.closest('.openvibe-navbar-links')))) soon(); });
++ _fitSeen.observe(nav, { childList: true, subtree: true, characterData: true });
++ if (document.fonts && document.fonts.ready && document.fonts.ready.then) document.fonts.ready.then(soon, () => {});
++ }
function hookSwitcher(nav) {
const b = nav.querySelector('#openvibe-switch-btn');
if (!b) return;
ddiff --git a/ov-drop.js b/ov-drop.js
nnew file mode 100644
iindex 0000000..be965c2
---- /dev/null
++++ b/ov-drop.js
@@@ -0,0 +1,147 @@
++/* ov-drop.js — the one dropdown (4.191.0). The owner, 2026-11-10: "clicking the actual name of the domain should have a
++ sleek little minimalist drop down with nice animation effects that just shows the pages … a custom, animated system
++ for these drop downs and positioning of them, used across all sites."
++
++ A small list that opens out of the thing pressed: under it (above it when there is no room below), kept on screen,
++ growing from where the press was, its rows arriving one after another. Arrow keys move, Enter chooses, Escape and a
++ press outside close it and give the focus back. One is open at a time, and it closes with every other panel on the
++ page (panels.js).
++
++ OVDrop.open(anchor, items, opts) items: [{ label, href?, onClick?, icon? (SVG text), sub?, current?, danger? }]
++ or a function that returns them; a falsy item is a hairline between groups
++ opts: { label (what the list is, said to a screen reader and shown small on top),
++ align: 'start' | 'end', minWidth, onClose }
++ OVDrop.attach(button, items, opts) the button opens and closes it, with aria-expanded kept true to what is shown
++ OVDrop.pages(anchor, links, icon) a site's pages from its bar links, Home first, the present page marked
++ OVDrop.close() close the one that is open */
++(function (root) {
++ 'use strict';
++ if (!root || !root.document || root.OVDrop) return;
++ const doc = root.document;
++ const CSS = `
++.ovdrop { position: fixed; z-index: 10030; left: 0; top: 0; box-sizing: border-box; min-width: 200px; max-width: min(320px, calc(100vw - 16px)); max-height: min(70vh, 460px); overflow: auto; overscroll-behavior: contain; padding: 6px; border-radius: 16px;
++ background: color-mix(in srgb, var(--bg-elevated, var(--bg-secondary, #111826)) 94%, transparent); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 44px rgba(0,0,0,.42), 0 2px 6px rgba(0,0,0,.2); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px);
++ color: var(--text-primary, #e6edf7); font: 600 14.5px/1.25 var(--font, system-ui, -apple-system, 'Segoe UI', sans-serif); transform-origin: var(--ovdrop-x, 24px) var(--ovdrop-y, 0); opacity: 0; transform: translateY(calc(var(--ovdrop-from, -6px) * var(--ov-move, 1))) scale(.94); transition: opacity .14s ease-out, transform .16s ease-in; pointer-events: none; }
++.ovdrop.is-open { opacity: 1; transform: none; pointer-events: auto; transition: opacity .16s ease-out, transform .26s cubic-bezier(.2, 1.25, .3, 1); }
++.ovdrop-label { margin: 0; padding: 7px 10px 6px; font-size: 11.5px; font-weight: 700; color: var(--text-muted, #93a0b4); }
++.ovdrop-item { all: unset; box-sizing: border-box; display: flex; align-items: center; gap: 11px; width: 100%; min-height: 44px; padding: 0 12px 0 10px; border-radius: 11px; cursor: pointer; color: inherit; opacity: 0; transform: translateY(calc(-5px * var(--ov-move, 1))); transition: background-color .12s, opacity .2s ease-out, transform .28s cubic-bezier(.2, 1.2, .3, 1); transition-delay: 0s, calc(var(--i, 0) * 22ms + 30ms), calc(var(--i, 0) * 22ms + 30ms); }
++.ovdrop.is-open .ovdrop-item { opacity: 1; transform: none; }
++.ovdrop-item:hover, .ovdrop-item:focus-visible { background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }
++.ovdrop-item:focus-visible { box-shadow: inset 0 0 0 2px var(--accent, #3b82f6); }
++.ovdrop-item:active { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); }
++.ovdrop-ic { flex: none; display: grid; place-items: center; width: 20px; height: 20px; color: var(--text-muted, #93a0b4); }
++.ovdrop-ic svg { width: 18px; height: 18px; display: block; }
++.ovdrop-t { flex: 1; min-width: 0; display: grid; gap: 1px; }
++.ovdrop-t b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovdrop-t small { font-size: 12px; font-weight: 500; color: var(--text-secondary, #a8b3c4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovdrop-item[aria-current] { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }
++.ovdrop-item[aria-current] .ovdrop-ic { color: var(--accent-light, var(--accent, #60a5fa)); }
++.ovdrop-item[aria-current]::after { content: ""; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--accent-light, var(--accent, #60a5fa)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }
++.ovdrop-item.is-danger { color: var(--danger-text, #f87171); }
++.ovdrop-sep { height: 1px; margin: 5px 8px; background: var(--border, rgba(255,255,255,.1)); }
++:root:root[data-ov-motion=off] .ovdrop, :root:root[data-ov-motion=off] .ovdrop-item { transition: none; }`;
++ function style() {
++ if (doc.getElementById('ovdrop-style')) return;
++ const st = doc.createElement('style'); st.id = 'ovdrop-style'; st.textContent = CSS; doc.head.appendChild(st);
++ }
++ const safeHref = (u) => { const s = String(u || ''); return /^(https?:\/\/|\/(?!\/)|#|\?|mailto:)/i.test(s) ? s : ''; };
++ let cur = null; // { el, anchor, opts, off: [fns] }
++
++ /** Where the list sits: under the anchor, or above it when there is more room there; never off the screen. */
++ function place() {
++ if (!cur) return;
++ const { el, anchor, opts } = cur;
++ const a = anchor.getBoundingClientRect(), w = el.offsetWidth, h = el.offsetHeight, vw = root.innerWidth, vh = root.innerHeight, gap = 8;
++ if (!a.width && !a.height) return close();
++ let left = opts.align === 'end' ? a.right - w : a.left - 6;
++ left = Math.max(8, Math.min(vw - w - 8, left));
++ const below = vh - a.bottom - gap - 8, above = a.top - gap - 8, up = h > below && above > below;
++ const top = up ? Math.max(8, a.top - gap - h) : a.bottom + gap;
++ el.style.left = `${Math.round(left)}px`; el.style.top = `${Math.round(top)}px`;
++ el.style.setProperty('--ovdrop-x', `${Math.round(Math.max(12, Math.min(w - 12, a.left + a.width / 2 - left)))}px`);
++ el.style.setProperty('--ovdrop-y', up ? '100%' : '0');
++ el.style.setProperty('--ovdrop-from', up ? '6px' : '-6px');
++ }
++ function close(refocus) {
++ if (!cur) return;
++ const { el, anchor, opts, off } = cur; cur = null;
++ off.forEach((fn) => { try { fn(); } catch { /* already gone */ } });
++ el.classList.remove('is-open');
++ if (anchor.setAttribute && anchor.hasAttribute('aria-expanded')) anchor.setAttribute('aria-expanded', 'false');
++ setTimeout(() => el.remove(), 180);
++ if (refocus && anchor.isConnected && anchor.focus) anchor.focus({ preventScroll: true });
++ if (typeof opts.onClose === 'function') { try { opts.onClose(); } catch { /* the caller's */ } }
++ }
++ function open(anchor, items, opts) {
++ if (!anchor || !anchor.getBoundingClientRect) return null;
++ if (cur && cur.anchor === anchor) { close(); return null; }
++ close(); style();
++ opts = opts || {};
++ const list = (typeof items === 'function' ? items() : items) || [];
++ const el = doc.createElement('div'); el.className = 'ovdrop'; el.setAttribute('role', 'menu');
++ if (opts.label) { el.setAttribute('aria-label', opts.label); const l = doc.createElement('p'); l.className = 'ovdrop-label'; l.textContent = opts.label; el.appendChild(l); }
++ if (opts.minWidth) el.style.minWidth = `${Number(opts.minWidth) || 200}px`;
++ let i = 0;
++ for (const it of list) {
++ if (!it) { const s = doc.createElement('div'); s.className = 'ovdrop-sep'; s.setAttribute('role', 'separator'); el.appendChild(s); continue; }
++ const href = safeHref(it.href), row = doc.createElement(href ? 'a' : 'button');
++ row.className = `ovdrop-item${it.danger ? ' is-danger' : ''}`; row.setAttribute('role', 'menuitem'); row.style.setProperty('--i', String(i)); i += 1;
++ if (href) row.href = href; else row.type = 'button';
++ if (it.current) row.setAttribute('aria-current', 'page');
++ if (it.icon) { const ic = doc.createElement('span'); ic.className = 'ovdrop-ic'; ic.setAttribute('aria-hidden', 'true'); ic.innerHTML = String(it.icon); row.appendChild(ic); }
++ const t = doc.createElement('span'); t.className = 'ovdrop-t'; const b = doc.createElement('b'); b.textContent = it.label || ''; t.appendChild(b);
++ if (it.sub) { const sm = doc.createElement('small'); sm.textContent = it.sub; t.appendChild(sm); }
++ row.appendChild(t);
++ row.addEventListener('click', (e) => { if (typeof it.onClick === 'function') { if (!href) e.preventDefault(); try { it.onClick(e); } catch { /* the caller's */ } } close(); });
++ el.appendChild(row);
++ }
++ doc.body.appendChild(el);
++ const rows = () => Array.from(el.querySelectorAll('.ovdrop-item'));
++ const onKey = (e) => {
++ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); return close(true); }
++ if (e.key === 'Tab') return close();
++ const r = rows(), at = r.indexOf(doc.activeElement);
++ const go = (n) => { e.preventDefault(); if (r.length) r[(n + r.length) % r.length].focus(); };
++ if (e.key === 'ArrowDown') go(at + 1); else if (e.key === 'ArrowUp') go(at < 0 ? r.length - 1 : at - 1);
++ else if (e.key === 'Home') go(0); else if (e.key === 'End') go(r.length - 1);
++ };
++ const onDown = (e) => { if (!el.contains(e.target) && !anchor.contains(e.target)) close(); };
++ const onMove = () => place();
++ const onSize = () => close();
++ doc.addEventListener('keydown', onKey, true); doc.addEventListener('pointerdown', onDown, true);
++ root.addEventListener('scroll', onMove, { passive: true, capture: true }); root.addEventListener('resize', onSize);
++ const off = [() => doc.removeEventListener('keydown', onKey, true), () => doc.removeEventListener('pointerdown', onDown, true),
++ () => root.removeEventListener('scroll', onMove, { capture: true }), () => root.removeEventListener('resize', onSize)];
++ // One panel at a time, with the bar's menus and every other registered panel.
++ if (root.OpenVibePanels && root.OpenVibePanels.register) { try { root.OpenVibePanels.closeAll(); off.push(root.OpenVibePanels.register({ el, openClass: 'is-open', id: 'drop', close: () => close() })); } catch { /* optional */ } }
++ cur = { el, anchor, opts, off };
++ if (anchor.setAttribute) anchor.setAttribute('aria-expanded', 'true');
++ place();
++ void el.offsetWidth; el.classList.add('is-open');
++ // The keyboard lands on the current row (or the first); a touch or a click leaves the focus where it was.
++ if (opts.focus !== false && anchor.matches && anchor.matches(':focus-visible')) { const r = rows(); (r.find((x) => x.hasAttribute('aria-current')) || r[0] || el).focus({ preventScroll: true }); }
++ return { el, close: () => close() };
++ }
++ function attach(button, items, opts) {
++ if (!button || button.__ovdrop) return;
++ button.__ovdrop = true;
++ button.setAttribute('aria-haspopup', 'menu'); button.setAttribute('aria-expanded', 'false');
++ button.addEventListener('click', (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; e.preventDefault(); open(button, items, opts); });
++ }
++ /**
++ * A site's pages, from the bar's links (the navbar calls this when the site's name is pressed): Home first, the
++ * page you are on marked. links: [{ label, href, icon, hidden }]; icon(name) gives an icon's SVG text.
++ */
++ function pages(anchor, links, icon) {
++ const path = (h) => String(h).split(/[?#]/)[0].replace(/\/+$/, '') || '/';
++ const list = (links || []).filter((l) => l && l.label && l.href && !l.hidden && !/^https?:/i.test(l.href));
++ const at = path(root.location.pathname);
++ let best = -1, len = -1;
++ list.forEach((l, i) => { const q = path(l.href); const n = q === at ? 1e4 : (q !== '/' && at.startsWith(`${q}/`) ? q.length : -1); if (n > len) { best = i; len = n; } });
++ const draw = (name) => { try { return typeof icon === 'function' ? icon(name) : ''; } catch { return ''; } };
++ const items = list.map((l, i) => ({ label: l.label, href: l.href, icon: draw(l.icon || 'fa-arrow-right'), current: i === best }));
++ if (!list.some((l) => path(l.href) === '/')) items.unshift({ label: 'Home', href: anchor.getAttribute('href') || '/', icon: draw('fa-house'), current: at === '/' });
++ return open(anchor, items, { label: `Pages on ${anchor.textContent.trim()}` });
++ }
++ root.OVDrop = { open, close: () => close(), attach, pages, get isOpen() { return !!cur; } };
++})(typeof window !== 'undefined' ? window : null);
ddiff --git a/ov-home.js b/ov-home.js
iindex 1d52465..f83a138 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -466,6 +466,8 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
rail.addEventListener('scroll', ends, { passive: true });
requestAnimationFrame(ends);
wrap.append(rail);
++ const all = h('a', 'ovh-link', 'View full history'); all.href = 'https://openvibe.my/my#history';
++ wrap.append(all);
return wrap;
}
ddiff --git a/ov-upload.css b/ov-upload.css
iindex b69e7a1..15feb39 100644
---- a/ov-upload.css
++++ b/ov-upload.css
@@@ -56,6 +56,11 @@
@keyframes ov-upload-sheen { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
@keyframes ov-upload-pulse { 50% { opacity: .5; } }
@keyframes ov-upload-check { from { transform: scale(.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }
++/* "You already uploaded this": the note, then the three ways on, as one block under the file's row. */
++.ov-upload__duplicate-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; padding: 10px 12px; border-radius: 14px; background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 24%, transparent); animation: ov-upload-in .3s cubic-bezier(.2, .9, .3, 1.1) both; }
++.ov-upload__duplicate-note { flex: 1 0 100%; margin: 0 0 2px; font-size: 13.5px; font-weight: 650; color: var(--text-primary, #e6edf7); }
++.ov-upload__duplicate-actions .ovk-btn { min-height: 40px; }
++@media (pointer: coarse) { .ov-upload__duplicate-actions .ovk-btn { min-height: 44px; flex: 1 1 auto; } }
@keyframes ov-upload-draw { to { stroke-dashoffset: 0; } }
@keyframes ov-upload-in { from { opacity: 0; transform: translateY(6px); } }
@keyframes ov-upload-bob { 50% { transform: translateY(-3px); } }
ddiff --git a/ov-upload.js b/ov-upload.js
iindex 591e902..b2e7c7d 100644
---- a/ov-upload.js
++++ b/ov-upload.js
@@@ -18,6 +18,30 @@
if (seconds < 3600) return 'About ' + Math.ceil(seconds / 60) + ' min left';
return 'About ' + Math.ceil(seconds / 3600) + ' hr left';
}
++ // SubtleCrypto has no streaming digest API. Read in slices to keep file access responsive,
++ // then hash only modest files so hashing cannot become an unbounded pre-upload cost.
++ async function sha256(file, maxBytes) {
++ if (!globalThis.crypto || !globalThis.crypto.subtle || file.size > (maxBytes || 32 * 1024 * 1024)) return null;
++ var sampleSize = Math.min(file.size, 1024 * 1024);
++ var t0 = typeof performance !== 'undefined' ? performance.now() : Date.now();
++ var sample = await file.slice(0, sampleSize).arrayBuffer();
++ await globalThis.crypto.subtle.digest('SHA-256', sample);
++ var measuredMs = Math.max(1, (typeof performance !== 'undefined' ? performance.now() : Date.now()) - t0);
++ var uploadMbps = typeof navigator !== 'undefined' && navigator.connection && Number(navigator.connection.downlink) > 0
++ ? Number(navigator.connection.downlink) : 10;
++ var estimatedHashMs = (file.size / Math.max(1, sampleSize)) * measuredMs + 4;
++ var estimatedUploadMs = 600 + (file.size / (uploadMbps * 125000)) * 1000;
++ if (estimatedHashMs > estimatedUploadMs) return null;
++ var chunks = [], size = 0, step = 1024 * 1024;
++ for (var at = 0; at < file.size; at += step) {
++ var part = await file.slice(at, Math.min(file.size, at + step)).arrayBuffer();
++ chunks.push(part); size += part.byteLength;
++ }
++ var all = new Uint8Array(size), offset = 0;
++ chunks.forEach(function (part) { all.set(new Uint8Array(part), offset); offset += part.byteLength; });
++ var digest = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', all));
++ return Array.from(digest, function (b) { return b.toString(16).padStart(2, '0'); }).join('');
++ }
// Seconds are smoothed by measured bytes per second, not the last network burst.
function rate(previous, deltaBytes, deltaSeconds, alpha) {
if (deltaBytes <= 0 || deltaSeconds <= 0) return previous || 0;
@@@ -28,6 +52,12 @@
var ICON_UP = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15V4m0 0-4.5 4.5M12 4l4.5 4.5"/><path d="M4 14v3a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-3"/></svg>';
var ICON_FILE = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>';
var ICON_X = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>';
++ // "2026-11-10T08:14:00Z" → "10 Nov 2026": the day a person would say, the same in every locale's tests.
++ var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
++ function day(value) {
++ var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value || ''));
++ return m && MONTHS[Number(m[2]) - 1] ? Number(m[3]) + ' ' + MONTHS[Number(m[2]) - 1] + ' ' + m[1] : (value ? String(value) : 'an earlier day');
++ }
function node(tag, className, text) {
var el = document.createElement(tag);
if (className) el.className = className;
@@@ -93,6 +123,25 @@
render();
return { update: update, destroy: function () { if (timer) clearInterval(timer); } };
}
++ async function preflight(file, lookup, row, callbacks, maxBytes) {
++ if (typeof lookup !== 'function') return null;
++ var hash = await sha256(file, maxBytes);
++ if (!hash) return null;
++ var existing = await lookup(file, hash);
++ if (!existing) return null;
++ var target = existing.object || existing;
++ var actions = node('div', 'ov-upload__duplicate-actions');
++ var note = node('p', 'ov-upload__duplicate-note', 'You already uploaded this on ' + day(target.date || target.created_at) + '.');
++ var open = node('a', 'ovk-btn ovk-btn--quiet', 'Open it'); open.href = target.address || target.url || '#';
++ var use = node('button', 'ovk-btn ovk-btn--quiet', 'Use that one'); use.type = 'button';
++ use.addEventListener('click', function () { if (callbacks && callbacks.onUse) callbacks.onUse(target); });
++ var again = node('button', 'ovk-btn ovk-btn--quiet', 'Upload again'); again.type = 'button';
++ again.addEventListener('click', function () { actions.remove(); if (callbacks && callbacks.onAgain) callbacks.onAgain(); });
++ actions.append(note, open, use, again); row.append(actions);
++ row.dispatchEvent(new CustomEvent('ov-upload:duplicate', { detail: { file: file, existing: target } }));
++ if (callbacks && callbacks.onDuplicate) callbacks.onDuplicate(target);
++ return target;
++ }
function picker(root, options) {
options = options || {};
var input = node('input', 'ov-upload__input');
@@@ -146,7 +195,7 @@
var done = items.reduce(function (n, item) { return n + item.done; }, 0);
var state = items.some(function (item) { return item.state === 'sending'; }) ? 'sending'
: items.some(function (item) { return item.state === 'failed'; }) ? 'failed'
-- : items.length && items.every(function (item) { return item.state === 'done'; }) ? 'done' : 'waiting';
++ : items.length && items.every(function (item) { return item.state === 'done' || item.state === 'duplicate'; }) ? 'done' : 'waiting';
total.update({ done: done, total: bytes, state: state, message: state === 'failed' ? 'A file needs a retry.' : '' });
totalRoot.hidden = items.length < 2;
}
@@@ -154,18 +203,38 @@
while (active < (options.concurrency || 3)) {
var item = items.find(function (x) { return x.state === 'waiting'; });
if (!item) break;
-- item.state = 'sending'; active++;
++ item.state = 'checking'; active++;
item.meter.update({ state: 'sending', done: item.done, total: item.file.size }); refresh();
(function (entry) {
entry.controller = new AbortController();
-- Promise.resolve().then(function () { return options.upload(entry.file, {
++ Promise.resolve().then(async function () {
++ if (options.checkDuplicate && !entry.keepBoth) {
++ var hash = await sha256(entry.file, options.hashLimitBytes);
++ if (hash) {
++ var existing = await options.checkDuplicate(entry.file, hash);
++ entry.sha256 = hash;
++ if (existing) {
++ existing = existing.object || existing;
++ entry.state = 'duplicate'; entry.duplicate = existing;
++ entry.meter.update({ state: 'done', done: entry.file.size, total: entry.file.size, message: 'You already uploaded this on ' + day(existing.date || existing.created_at) + '.' });
++ entry.duplicateActions = duplicateActions(entry, existing);
++ if (options.onDuplicate) options.onDuplicate(entry.file, existing);
++ root.dispatchEvent(new CustomEvent('ov-upload:duplicate', { detail: { file: entry.file, existing: existing } }));
++ refresh(); return { __duplicate: true };
++ }
++ }
++ }
++ return options.upload(entry.file, {
signal: entry.controller.signal,
++ keepBoth: !!entry.keepBoth,
++ sha256: entry.sha256 || null,
onProgress: function (value) {
entry.done = Math.max(0, Math.min(entry.file.size, Number(value.uploadedBytes == null ? value.done : value.uploadedBytes) || 0));
entry.meter.update({ state: 'sending', done: entry.done, total: entry.file.size }); refresh();
}
}); }).then(function (result) {
if (entry.removed) return;
++ if (result && result.__duplicate) return;
entry.done = entry.file.size; entry.state = 'done';
entry.meter.update({ state: 'done', done: entry.done, total: entry.file.size });
if (options.onDone) options.onDone(entry.file, result);
@@@ -177,6 +246,16 @@
})(item);
}
}
++ function duplicateActions(entry, existing) {
++ var actions = node('div', 'ov-upload__duplicate-actions');
++ var open = node('a', 'ovk-btn ovk-btn--quiet', 'Open it'); open.href = existing.url || existing.address || '#';
++ open.addEventListener('click', function () { if (options.onOpenDuplicate) options.onOpenDuplicate(entry.file, existing); });
++ var use = node('button', 'ovk-btn ovk-btn--quiet', 'Use that one'); use.type = 'button';
++ use.addEventListener('click', function () { entry.done = entry.file.size; entry.state = 'done'; entry.meter.update({ state: 'done', done: entry.done, total: entry.file.size, message: 'Using the picture you already uploaded.' }); refresh(); if (options.onDone) options.onDone(entry.file, existing); root.dispatchEvent(new CustomEvent('ov-upload:done', { detail: { file: entry.file, result: existing, duplicate: true } })); });
++ var again = node('button', 'ovk-btn ovk-btn--quiet', 'Upload again'); again.type = 'button';
++ again.addEventListener('click', function () { entry.keepBoth = true; entry.state = 'waiting'; entry.meter.update({ state: 'waiting', done: 0, total: entry.file.size, message: '' }); actions.remove(); entry.duplicate = null; pump(); });
++ actions.append(open, use, again); entry.row.append(actions); return actions;
++ }
var pick = picker(pickRoot, {
label: options.label, accept: options.accept, multiple: options.multiple !== false,
onFile: function (file, row) {
@@@ -197,5 +276,5 @@
pick.rows.before(totalRoot); // the total sits between the picker and the files
return { picker: pick, total: total, items: items };
}
-- return { progress: progress, picker: picker, queue: queue, rate: rate, timeLeft: timeLeft, size: size };
++ return { progress: progress, picker: picker, queue: queue, preflight: preflight, rate: rate, timeLeft: timeLeft, size: size, sha256: sha256 };
});
ddiff --git a/ov-wallet.css b/ov-wallet.css
iindex 0532ceb..d662d6f 100644
---- a/ov-wallet.css
++++ b/ov-wallet.css
@@@ -6,3 +6,31 @@
@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. */
.ov-wallet-close:focus{outline:none}.ov-wallet-close:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
++/* Each balance in its currency's colour (4.190.0; --pc comes from purse.js through data-purse): a tinted card, the icon
++ in a tile, a light that crosses the card once as the sheet opens, and the action in the same colour. */
++.ov-wallet-balance[data-purse]{position:relative;overflow:hidden;border-color:color-mix(in srgb,var(--pc) 30%,var(--wallet-line));background:radial-gradient(130% 150% at 0% 0%,color-mix(in srgb,var(--pc) 17%,transparent),transparent 60%),var(--wallet-card);transition:border-color .3s,box-shadow .3s;animation:ov-wallet-in .45s cubic-bezier(.2,.9,.3,1.1) backwards}
++.ov-wallet-balance[data-purse]:nth-child(2){animation-delay:.06s}.ov-wallet-balance[data-purse]:nth-child(3){animation-delay:.12s}
++@keyframes ov-wallet-in{from{opacity:0;transform:translateY(calc(12px*var(--ov-move,1)))}}
++.ov-wallet-balance[data-purse]::after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(105deg,transparent 38%,color-mix(in srgb,var(--pc) 20%,transparent) 50%,transparent 62%);transform:translateX(-110%);animation:ov-wallet-sheen 1.1s ease-out .3s both}
++.ov-wallet-balance[data-purse]:nth-child(2)::after{animation-delay:.42s}.ov-wallet-balance[data-purse]:nth-child(3)::after{animation-delay:.54s}
++@keyframes ov-wallet-sheen{to{transform:translateX(110%)}}
++.ov-wallet-balance[data-purse]:is(.is-gain,.is-loss){border-color:color-mix(in srgb,var(--pc) 70%,transparent);box-shadow:0 0 0 3px color-mix(in srgb,var(--pc) 16%,transparent)}
++.ov-wallet-symbol.is-drawn{width:44px;height:44px;border-radius:14px;color:inherit;background:color-mix(in srgb,var(--pc) 16%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--pc) 32%,transparent)}
++.ov-wallet-symbol.is-drawn svg{width:28px;height:28px;filter:drop-shadow(0 2px 3px color-mix(in srgb,var(--pd,#000) 50%,transparent))}
++.ov-wallet-balance[data-purse]:hover .ov-wallet-symbol svg{animation:ov-wallet-nod .5s cubic-bezier(.3,1.5,.4,1)}
++@keyframes ov-wallet-nod{40%{transform:translateY(calc(-3px*var(--ov-move,1))) rotate(calc(-8deg*var(--ov-move,1))) scale(1.08)}}
++.ov-wallet-balance[data-purse] .ov-wallet-balance-title{gap:12px}
++.ov-wallet-balance[data-purse] .ov-wallet-balance-title h3{font-size:14px;color:var(--wallet-muted);font-weight:650}
++.ov-wallet-balance[data-purse] .ov-wallet-value{font-variant-numeric:tabular-nums}
++.ov-wallet-balance[data-purse] .ov-wallet-button{position:relative;z-index:1;border-radius:999px;border-color:color-mix(in srgb,var(--pc) 48%,transparent);background:color-mix(in srgb,var(--pc) 14%,transparent);transition:background-color .15s,transform .12s}
++.ov-wallet-balance[data-purse] .ov-wallet-button:hover:not(:disabled){background:color-mix(in srgb,var(--pc) 26%,transparent)}
++.ov-wallet-balance[data-purse] .ov-wallet-button:active:not(:disabled){transform:scale(.96)}
++/* On a phone: the tile, then the name over the number, and the action at the right; what it is for under them. */
++@media(max-width:600px){.ov-wallet-balance[data-purse]{grid-template-columns:auto minmax(0,1fr) auto;gap:2px 12px;padding:12px 14px}
++.ov-wallet-balance[data-purse] .ov-wallet-balance-title{display:contents}
++.ov-wallet-balance[data-purse] .ov-wallet-symbol{grid-column:1;grid-row:1/3;align-self:center}
++.ov-wallet-balance[data-purse] .ov-wallet-balance-title h3{grid-column:2;grid-row:1;align-self:end;font-size:13px}
++.ov-wallet-balance[data-purse] .ov-wallet-value{grid-column:2;grid-row:2;font-size:25px}
++.ov-wallet-balance[data-purse]>.ov-wallet-button{grid-column:3;grid-row:1/3}
++.ov-wallet-balance[data-purse] .ov-wallet-purpose{grid-column:1/-1;grid-row:3;margin-top:6px}}
++html[data-ov-motion=off] .ov-wallet-balance[data-purse],html[data-ov-motion=off] .ov-wallet-balance[data-purse]::after{animation:none}
ddiff --git a/ov-wallet.js b/ov-wallet.js
iindex 1c69dce..b6715d3 100644
---- a/ov-wallet.js
++++ b/ov-wallet.js
@@@ -47,6 +47,7 @@
// make its saved state "hidden". Only a page without it needs the wallet's own lock.
const ownLock = !root.OVSurfaces;
let disposed = false, data = null, allItems = null, equipped = {}, kinds = [], storeItems = [];
++ const shown = {}; // what each balance last showed, so a change counts from it
const pending = new Map();
const session = { close() {
if (disposed) return; disposed = true; controller.abort(); dialog.close(); dialog.remove();
@@@ -68,7 +69,14 @@
if (!r.ok) throw new Error(value.detail || 'This is temporarily unavailable. Please try again.');
return value;
}
-- function message(error) { if (!disposed) { status.textContent = error.message || String(error); status.classList.add('is-error'); status.scrollIntoView({ block: 'nearest' }); } }
++ // What went wrong, in a person's words: a program's own error (a TypeError from a read that came back in another
++ // shape, a failed fetch) is never shown as it is.
++ function said(error) {
++ if (error && error.name === 'TypeError' && /fetch|network|load/i.test(error.message || '')) return 'Your wallet could not be reached. Check your connection and try again.';
++ if (!error || (error.name && error.name !== 'Error') || !error.message) return 'Part of your wallet could not be loaded. Close Wallet and open it again.';
++ return error.message;
++ }
++ function message(error) { if (!disposed) { status.textContent = said(error); status.classList.add('is-error'); status.scrollIntoView({ block: 'nearest' }); } }
function button(label, action, cls) {
const b = el('button', cls || 'ov-wallet-button', label); b.type = 'button';
b.addEventListener('click', async () => {
@@@ -147,11 +155,26 @@
const balances = el('div', 'ov-wallet-balances'); body.append(balances);
for (const id of ['coins', 'vibes', 'balance']) {
const b = data.balances[id], card = el('article', `ov-wallet-balance is-${id}`);
-- const title = el('div', 'ov-wallet-balance-title'); title.append(el('span', 'ov-wallet-symbol', { coins: 'O', vibes: 'V', balance: '$' }[id]), el('h3', '', b.label));
-- card.append(title, el('p', 'ov-wallet-value', amount(b)), el('p', 'ov-wallet-purpose', b.purpose));
++ const numeric = Number(b.amount) / Math.pow(10, b.scale || 0);
++ const words = (n) => amount({ ...b, amount: String(Math.round(n * Math.pow(10, b.scale || 0))) });
++ // The currency's own icon and colour (purse.js, the one the bar's pill has); the letter where that file is not on the page.
++ const purse = root.OpenVibePurse || null;
++ const symbol = el('span', 'ov-wallet-symbol', purse ? null : { coins: 'O', vibes: 'V', balance: '$' }[id]);
++ if (purse) { symbol.innerHTML = purse.icon(id, 30); symbol.classList.add('is-drawn', 'ovp-play'); symbol.setAttribute('aria-hidden', 'true'); card.setAttribute('data-purse', id); }
++ const title = el('div', 'ov-wallet-balance-title'); title.append(symbol, el('h3', '', b.label));
++ const value = el('p', 'ov-wallet-value', amount(b));
++ card.append(title, value, el('p', 'ov-wallet-purpose', b.purpose));
if (id === 'coins') { const link = el('a', 'ov-wallet-button', b.action); link.href = 'https://openvibe.network/my#coins'; card.append(link); }
else { const action = button(b.action, () => checkout(id === 'vibes' ? 'vibes' : 'topup')); action.disabled = data.money_writes_frozen; card.append(action); }
balances.append(card);
++ // The number counts in as the sheet opens; after a purchase it counts to the new amount and the currency's
++ // pieces leave (or reach) its card. The full amount is what a screen reader hears, at once.
++ if (purse && isFinite(numeric)) {
++ const was = shown[id]; shown[id] = numeric;
++ value.setAttribute('aria-label', amount(b));
++ if (was == null) purse.count(value, numeric * .82, numeric, words);
++ else if (was !== numeric) { purse.count(value, was, numeric, words); card.classList.add(numeric > was ? 'is-gain' : 'is-loss'); setTimeout(() => { if (card.isConnected) purse.burst(card, id, numeric - was); }, 60); }
++ }
}
const store = section('Store', 'A little more you. Items travel with your OpenVibe account.');
const grid = el('div', 'ov-wallet-grid'); store.append(grid);
ddiff --git a/package-lock.json b/package-lock.json
iindex 58e1fd1..69f26eb 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.189.0",
++ "version": "4.190.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.189.0",
++ "version": "4.190.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 7789d02..53d4c82 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.189.0",
++ "version": "4.190.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -50,6 +50,8 @@
"./dock": "./dock.js",
"./presence": "./presence.js",
"./purse": "./purse.js",
++ "./drop": "./ov-drop.js",
++ "./machines": "./machines.js",
"./nav-slots": "./nav-slots.js",
"./overlay": "./overlay.js",
"./dock-vibe": "./dock-vibe.js",
ddiff --git a/purse.js b/purse.js
iindex f4a7f50..777c229 100644
---- a/purse.js
++++ b/purse.js
@@@ -11,7 +11,10 @@
OpenVibePurse.mount(button) the pill, after each draw of the bar
OpenVibePurse.set(id, value) a balance changed ('coins', '1,234')
OpenVibePurse.icon(id, size) the currency's icon as SVG text ('coins' | 'vibes' | 'balance'), for other surfaces
-- OpenVibePurse.play(id, delta) the gain or spend effect alone (openvibe.design shows it) */
++ OpenVibePurse.play(id, delta) the gain or spend effect at the bar's pill
++ OpenVibePurse.burst(el, id, delta) the same effect at any element: a buy button, a card, a tip
++ OpenVibePurse.count(el, from, to, format) count a number from one value to another
++ A holder with data-purse="coins|vibes|balance" has the currency's colour as --pc; .ovp-play plays its icon's move. */
(function (root) {
'use strict';
if (!root || !root.document || root.OpenVibePurse) return;
@@@ -27,7 +30,7 @@
balance: '<g class="ovp-note"><rect x="2.2" y="5.6" width="19.6" height="12.8" rx="3.2" fill="var(--pc)"/><circle cx="12" cy="12" r="3.1" fill="none" stroke="var(--pd)" stroke-width="2.1"/><path d="M5.6 9.2v.01M18.4 14.8v.01" stroke="var(--pd)" stroke-width="2.2" stroke-linecap="round"/><path d="M5 8.4h3.4" stroke="#fff" stroke-width="1.3" stroke-linecap="round" opacity=".6"/></g>',
};
const CSS = `
--.ovnav-chip-mini, .ovp-bit, .ovp-delta, .ovp-icw { --pc: var(--text-secondary, #a8b3c4); --pd: color-mix(in oklab, var(--pc) 42%, #000); }
++[data-purse], .ovnav-chip-mini, .ovp-bit, .ovp-delta, .ovp-icw { --pc: var(--text-secondary, #a8b3c4); --pd: color-mix(in oklab, var(--pc) 42%, #000); }
/* Each colour leans toward the page's text, so it is brighter on a dark theme and deeper on a light one. */
[data-purse="coins"] { --pc: color-mix(in oklab, #f6b21d 88%, var(--text-primary, #e6edf7)); }
[data-purse="vibes"] { --pc: color-mix(in oklab, #1fc78c 86%, var(--text-primary, #e6edf7)); }
@@@ -44,6 +47,11 @@
.is-purse .is-on .ovp-gem { transform-origin: 12px 12px; animation: ovpGem .7s cubic-bezier(.3, 1.4, .4, 1) both; }
.is-purse .is-on .ovp-note { transform-origin: 12px 12px; animation: ovpNote .6s cubic-bezier(.3, 1.3, .4, 1) both; }
.is-purse .is-on .ovp-glint { animation: ovpGlint 2.8s ease-in-out .6s infinite; }
++/* Anywhere else: .ovp-play on an icon's holder plays its move once (take the class off and put it back to replay). */
++.ovp-play .ovp-spin { transform-origin: 12px 12px; animation: ovpSpin .7s cubic-bezier(.3, .9, .3, 1) both; }
++.ovp-play .ovp-gem { transform-origin: 12px 12px; animation: ovpGem .7s cubic-bezier(.3, 1.4, .4, 1) both; }
++.ovp-play .ovp-note { transform-origin: 12px 12px; animation: ovpNote .6s cubic-bezier(.3, 1.3, .4, 1) both; }
++.ovp-play .ovp-glint { animation: ovpGlint 2.8s ease-in-out .6s infinite; }
@keyframes ovpSpin { from { transform: scaleX(calc(1 - 2 * var(--ov-move, 1))); } 50% { transform: scaleX(calc(1 - .9 * var(--ov-move, 1))); } }
@keyframes ovpGem { from { transform: rotate(calc(-28deg * var(--ov-move, 1))) scale(.6); } }
@keyframes ovpNote { from { transform: translateX(calc(-5px * var(--ov-move, 1))) rotate(calc(-12deg * var(--ov-move, 1))); } }
@@@ -128,13 +136,14 @@
tour();
}
/** A few of the currency's pieces: into the pill from below on a gain, out of it and down on a spend. */
-- function pieces(id, rect, gain, k) {
++ function pieces(id, rect, gain, k, at) {
if (!ICONS[id] || typeof doc.body.animate !== 'function' || k === 0) return;
-- const cx = rect.left + 14, cy = rect.top + rect.height / 2, n = k < 1 ? 3 : 7;
++ // at: { x, y } where the pieces meet (the pill's icon by default), host: the layer they are drawn in, up: they fly above it.
++ const cx = at && at.x != null ? at.x : rect.left + 14, cy = at && at.y != null ? at.y : rect.top + rect.height / 2, n = k < 1 ? 3 : 7, host = (at && at.host) || doc.body, sign = at && at.up ? -1 : 1;
for (let i = 0; i < n; i += 1) {
const b = doc.createElement('span'); b.className = 'ovp-bit'; b.setAttribute('data-purse', id); b.innerHTML = svg(13, ICONS[id]);
-- doc.body.appendChild(b);
-- const spread = ((i / (n - 1)) - .5) * 2, dx = spread * 34 * k + (Math.random() - .5) * 10, dy = (38 + Math.random() * 26) * k, turn = (Math.random() - .5) * 260 * k;
++ host.appendChild(b);
++ const spread = ((i / (n - 1)) - .5) * 2, dx = spread * 34 * k + (Math.random() - .5) * 10, dy = sign * (38 + Math.random() * 26) * k, turn = (Math.random() - .5) * 260 * k;
const far = `translate(${cx - 6 + dx}px, ${cy - 6 + dy}px) rotate(${turn}deg) scale(.9)`, near = `translate(${cx - 6}px, ${cy - 6}px) rotate(0deg) scale(.55)`;
const mid = `translate(${cx - 6 + dx * .7}px, ${cy - 6 + dy * (gain ? .75 : .25) - (gain ? 0 : 9 * k)}px) rotate(${turn / 2}deg) scale(1)`;
const frames = gain ? [{ transform: far, opacity: 0 }, { transform: mid, opacity: 1, offset: .3 }, { transform: near, opacity: .9, offset: .92 }, { transform: near, opacity: 0 }]
@@@ -144,12 +153,13 @@
}
}
/** "+15" or "−200" under the pill: it rises into the pill on a gain and falls away on a spend. */
-- function delta(id, rect, d, k) {
++ function delta(id, rect, d, k, at) {
if (typeof doc.body.animate !== 'function') return;
++ const host = (at && at.host) || doc.body, up = !!(at && at.up);
const t = doc.createElement('span'); t.className = 'ovp-delta'; t.setAttribute('data-purse', id);
t.textContent = `${d > 0 ? '+' : '−'}${Math.abs(d) >= 1e4 ? short(Math.abs(d)) : Math.abs(d).toLocaleString()}`;
-- doc.body.appendChild(t);
-- const x = Math.max(6, Math.min(root.innerWidth - t.offsetWidth - 6, rect.left + rect.width / 2 - t.offsetWidth / 2)), y = rect.bottom + 6, m = 16 * (k || .01);
++ host.appendChild(t);
++ const x = Math.max(6, Math.min(root.innerWidth - t.offsetWidth - 6, rect.left + rect.width / 2 - t.offsetWidth / 2)), y = up ? rect.top - t.offsetHeight - 6 : rect.bottom + 6, m = (up ? -16 : 16) * (k || .01);
const frames = d > 0 ? [{ transform: `translate(${x}px, ${y + m}px)`, opacity: 0 }, { transform: `translate(${x}px, ${y}px)`, opacity: 1, offset: .22 }, { transform: `translate(${x}px, ${y}px)`, opacity: 1, offset: .78 }, { transform: `translate(${x}px, ${y - m * .6}px) scale(.9)`, opacity: 0 }]
: [{ transform: `translate(${x}px, ${y - m * .5}px)`, opacity: 0 }, { transform: `translate(${x}px, ${y}px)`, opacity: 1, offset: .22 }, { transform: `translate(${x}px, ${y}px)`, opacity: 1, offset: .75 }, { transform: `translate(${x}px, ${y + m}px)`, opacity: 0 }];
const a = t.animate(frames, { duration: 1900, easing: 'ease-out', fill: 'both' });
@@@ -166,11 +176,26 @@
later(() => { btn.classList.remove('is-gain', 'is-loss'); }, 1500);
later(() => { busy = false; show(home); idle(); }, 3400);
}
-- /** Count the number from what it was to what it is, in the bar's short form. */
-- function count(el, from, to) {
-- if (!el || motion() === 0 || typeof root.requestAnimationFrame !== 'function') return;
++ /**
++ * The gain or spend effect at any element (4.190.0): the currency's pieces fly into it (a gain) or out of it (a
++ * spend) and the amount shows beside it. It works inside an open dialog, and goes upward near the bottom of the
++ * screen. burst(button, 'coins', -200) when someone spends; burst(card, 'vibes', 50) when they receive.
++ */
++ function burst(el, id, d) {
++ if (!el || !el.getBoundingClientRect || !d || !ICONS[id]) return;
++ style();
++ const rect = el.getBoundingClientRect(); if (!rect.width) return;
++ const icon = el.querySelector && el.querySelector('.ovp-ic'), ir = icon ? icon.getBoundingClientRect() : rect;
++ const at = { x: ir.left + ir.width / 2, y: ir.top + ir.height / 2, host: (el.closest && el.closest('dialog[open]')) || doc.body, up: rect.bottom > root.innerHeight - 110 };
++ const k = motion();
++ pieces(id, rect, d > 0, k, at); delta(id, rect, d, k, at);
++ }
++ /** Count a number from what it was to what it is: in the bar's short form, or with the format given. */
++ function count(el, from, to, fmt) {
++ if (!el || typeof root.requestAnimationFrame !== 'function') return;
++ if (motion() === 0) { el.textContent = (fmt || short)(to); return; }
const t0 = root.performance.now(), ms = 800;
-- const step = (t) => { const p = Math.min(1, (t - t0) / ms), e = 1 - Math.pow(1 - p, 3); if (!el.isConnected) return; el.textContent = short(from + (to - from) * e); if (p < 1) root.requestAnimationFrame(step); };
++ const step = (t) => { const p = Math.min(1, (t - t0) / ms), e = 1 - Math.pow(1 - p, 3); if (!el.isConnected) return; el.textContent = (fmt || short)(p < 1 ? from + (to - from) * e : to); if (p < 1) root.requestAnimationFrame(step); };
root.requestAnimationFrame(step);
}
function set(id, value) {
@@@ -181,6 +206,6 @@
m.setAttribute('data-v', String(value));
play(id, n - was); count(m.querySelector('.ovnav-chip-mv'), was, n);
}
-- root.OpenVibePurse = { mount, set, play, icon: (id, size) => (ICONS[id] ? svg(size || 20, ICONS[id]) : ''), ids: Object.keys(ICONS) };
++ root.OpenVibePurse = { mount, set, play, burst, count, icon: (id, size) => { style(); return ICONS[id] ? svg(size || 20, ICONS[id]) : ''; }, ids: Object.keys(ICONS) };
const now = doc.getElementById('openvibe-chips-all'); if (now) mount(now);
})(typeof window !== 'undefined' ? window : null);
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex 8f66263..46a8c48 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -17,7 +17,9 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
// 4.173.0: the wallet sheet (4.170.0) went out without ?v=, so a fix to it reached phones up to a day late.
'ov-wallet.js', 'ov-wallet.css',
// 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).
++ 'ov-drop.js'];
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
ddiff --git a/shell.js b/shell.js
iindex 1d0c6d9..c95839b 100644
---- a/shell.js
++++ b/shell.js
@@@ -153,6 +153,13 @@ function faviconLink(o) {
// specificity, which any site's own rule beats.
const NAVBAR_RESERVE = '<style>body{margin:0}:where(body){font-family:var(--ov-font,\'Inter\',-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,\'Helvetica Neue\',Arial,sans-serif)}#navbar-mount{min-height:52px}</style>';
++// One bar mount per page (4.190.0). A page whose own body has <div id="navbar-mount"></div> gets the server's bar in
++// that place; before, it got a second mount, and the empty one held 52 px under the bar on seventeen sites.
++const OWN_MOUNT = /<div id="navbar-mount"(?:\s[^>]*)?>\s*<\/div>/;
++function oneMount(nav, body) {
++ return OWN_MOUNT.test(body) ? [body.replace(OWN_MOUNT, () => nav)] : [nav, body];
++}
++
function page(o = {}) {
const name = o.name || o.service || 'OpenVibe';
const navbar = navbarOptions(o, name);
@@@ -185,8 +192,7 @@ function page(o = {}) {
'</head>',
`<body${attrs(bodyAttributes)}>`,
summary ? summary.body : '',
-- frame.serverNav({ name, service: navbar.service, home: o.home, links: navbar.links }),
-- o.body || '',
++ ...oneMount(frame.serverNav({ name, service: navbar.service, home: o.home, links: navbar.links }), o.body || ''),
frame.footer(o.footer || { service: name, variant: 'compact' }),
'</body>',
'</html>',
ddiff --git a/shipped.js b/shipped.js
iindex 97f4795..ce6cb89 100644
---- a/shipped.js
++++ b/shipped.js
@@@ -104,7 +104,14 @@
'.ov-shipped-dev-text b{font-size:.95rem;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}',
`.ov-shipped-dev-text>span{font-size:.82rem;color:${MUTED};display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}`,
'.ov-shipped .ov-shipped-row{display:flex;align-items:center;gap:8px;min-width:0}',
-- '.ov-shipped-kind{flex:none;font-size:.66rem;font-weight:800;letter-spacing:.04em;text-transform:uppercase;padding:2px 7px;border-radius:999px}',
++ // The kind of a change: a drawn mark (a star, an arrow up, a tick) and, for New and Fixed, the word. An improvement
++ // is the mark alone: most rows are improvements, and the word beside every one of them read as noise.
++ '.ov-shipped-kind{flex:none;display:inline-flex;align-items:center;gap:5px;font-size:.72rem;font-weight:700;line-height:1.2;padding:2px 9px 2px 6px;border-radius:999px}',
++ '.ov-shipped-kind::before{content:"";flex:none;width:11px;height:11px;background:var(--k,currentColor)}',
++ '.ov-shipped-kind.is-new::before{--k:#22c55e;clip-path:polygon(50% 0,63% 37%,100% 50%,63% 63%,50% 100%,37% 63%,0 50%,37% 37%)}',
++ '.ov-shipped-kind.is-better::before{--k:var(--accent-light,var(--accent,#60a5fa));clip-path:polygon(50% 0,100% 52%,68% 52%,68% 100%,32% 100%,32% 52%,0 52%)}',
++ '.ov-shipped-kind.is-fixed::before{--k:#f59e0b;clip-path:polygon(6% 54%,22% 38%,40% 56%,78% 12%,94% 28%,40% 88%)}',
++ '.ov-shipped-kind.is-better{gap:0;padding:5px;font-size:0}',
`.ov-shipped-kind.is-new{background:color-mix(in srgb,#22c55e 20%,transparent);color:${TEXT}}.ov-shipped-kind.is-better{background:color-mix(in srgb,var(--accent,#3b82f6) 20%,transparent);color:${TEXT}}.ov-shipped-kind.is-fixed{background:color-mix(in srgb,#f59e0b 22%,transparent);color:${TEXT}}`,
`.ov-shipped-areas{display:inline-flex;gap:4px;flex:none}.ov-shipped-areas span{font-size:.68rem;padding:1px 7px;border-radius:999px;border:1px solid ${BORDER};color:${MUTED};white-space:nowrap}`,
'@media (max-width:520px){.ov-shipped .ov-shipped-row{flex-wrap:wrap;row-gap:2px}.ov-shipped .ov-shipped-row .ov-shipped-subject{flex:1 1 60%;white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.ov-shipped-areas{order:5}}',
@@@ -250,8 +257,8 @@
// tags or topics"): the site's newest devblog post leads with its picture, then the last changes as rows, each with
// what kind of change it is (New, Better, Fixed: Blog's blog.changelog.tag task), its plain words and up to two
// areas. Entries without those fields (before the task ran) read as before: their subject, no kind, no areas.
-- const KINDS = { new: 'New', better: 'Better', fixed: 'Fixed' };
-- function kindTag(e) { const k = e && KINDS[e.kind]; return k ? el('span', { class: `ov-shipped-kind is-${e.kind}` }, k) : null; }
++ const KINDS = { new: 'New', better: 'Improved', fixed: 'Fixed' };
++ function kindTag(e) { const k = e && KINDS[e.kind]; return k ? el('span', { class: `ov-shipped-kind is-${e.kind}`, title: k }, k) : null; }
function areaChips(e, n) {
const areas = Array.isArray(e && e.areas) ? e.areas.filter((a) => typeof a === 'string' && a).slice(0, n) : [];
if (!areas.length) return null;
@@@ -471,7 +478,7 @@
const loading = el('p', { class: 'ov-shipped-empty' }, 'Loading…');
function drawKinds() {
kinds.textContent = '';
-- for (const [k, label] of [['', 'All changes'], ['new', 'New'], ['better', 'Better'], ['fixed', 'Fixed']]) {
++ for (const [k, label] of [['', 'All changes'], ['new', 'New'], ['better', 'Improved'], ['fixed', 'Fixed']]) {
const b = el('button', { type: 'button', class: 'ov-shipped-chip', 'aria-pressed': String(kindNow === k) }, label);
b.addEventListener('click', () => {
kindNow = k;
ddiff --git a/test-runner.js b/test-runner.js
iindex d6920a0..208dbaf 100644
---- a/test-runner.js
++++ b/test-runner.js
@@@ -72,6 +72,7 @@ function runOne(file, { dir, cwd, env, timeoutMs, nodeArgs }) {
* timeoutMs (60000), parallel (a number, 'auto' = min(8, cpus-1), default min(4, cpus-1); resolved value returned), env (added to process.env; NODE_ENV=test by default),
* hide (a RegExp: output lines of a failed file left out, e.g. /^\[DB\] /), pad (file-name column, 44),
* match (which files, default /\.test\.js$/), serial (files to run alone after parallel files),
++ * retry (a RegExp: a failed file that matches is run once more; a pass on the second try counts, and is named in the summary),
* nodeArgs ([]), log (console.log).
*/
async function run(opts = {}) {
@@@ -97,16 +98,26 @@ async function run(opts = {}) {
const queue = [], serial = [];
for (const file of files) (opts.serial && opts.serial.test(file) ? serial : queue).push(file);
const results = [];
++ // A file the caller marks as worth a second try (browser tests on a slow machine) gets one.
++ const runFile = async (file) => {
++ let r = await runOne(file, cfg);
++ if (r.state === 'fail' && opts.retry && opts.retry.test(file)) {
++ log(`↻ ${file.padEnd(pad)} failed once (exit ${r.code}); a second try`);
++ const again = await runOne(file, cfg);
++ r = { ...again, ms: r.ms + again.ms, retried: true };
++ }
++ return r;
++ };
await Promise.all(Array.from({ length: parallel }, async () => {
while (queue.length) {
-- const r = await runOne(queue.shift(), cfg);
++ const r = await runFile(queue.shift());
results.push(r);
const mark = r.state === 'pass' ? '✓' : r.state === 'skip' ? '○' : '✗';
log(`${mark} ${r.file.padEnd(pad)} ${String(r.ms).padStart(6)}ms${r.state === 'skip' ? ` ${r.skips.join('; ')}` : ''}`);
}
}));
for (const file of serial) {
-- const r = await runOne(file, cfg);
++ const r = await runFile(file);
results.push(r);
const mark = r.state === 'pass' ? '✓' : r.state === 'skip' ? '○' : '✗';
log(`${mark} ${r.file.padEnd(pad)} ${String(r.ms).padStart(6)}ms${r.state === 'skip' ? ` ${r.skips.join('; ')}` : ''}`);
@@@ -119,12 +130,14 @@ async function run(opts = {}) {
log(r.output.split('\n').filter((l) => !(opts.hide && opts.hide.test(l))).slice(-40).join('\n'));
}
const passed = results.length - failed.length - skipped.length;
-- const summary = skipped.length
++ const second = results.filter((r) => r.retried && r.state === 'pass').map((r) => r.file);
++ const summary = (skipped.length
? `${passed}/${results.length} test files passed, ${skipped.length} skipped (${skipped.map((r) => `${r.file}: ${r.skips.join('; ')}`).join(' | ')})${strict ? ' — strict: skips fail the run' : ''}`
-- : `${passed}/${results.length} test files passed`;
++ : `${passed}/${results.length} test files passed`)
++ + (second.length ? `; passed on a second try: ${second.join(', ')}` : '');
log(`\n${summary}`);
const exitCode = failed.length || (strict && skipped.length) ? 1 : 0;
-- return { results, passed, skipped: skipped.length, failed: failed.length, strict, exitCode, summary, parallel };
++ return { results, passed, skipped: skipped.length, failed: failed.length, strict, exitCode, summary, parallel, secondTry: second };
}
/** run(), then exit with its status: what a test/run.js calls. */
ddiff --git a/test/footer-alive.test.js b/test/footer-alive.test.js
iindex affecf2..025ba36 100644
---- a/test/footer-alive.test.js
++++ b/test/footer-alive.test.js
@@@ -79,7 +79,7 @@ function load(host = 'openvibe.live') {
assert.ok(f.pulseHTML({ live_now: 0, watching: 0, sites: 40, views_7d: 1500 }).includes('Nobody live right now · go live'), 'quiet, with a way in');
assert.ok(f.pulseHTML({ live_now: 0, sites: 40, views_7d: 1500 }).includes('1.5k page views'));
assert.strictEqual(f.pulseHTML({ live_now: null, watching: null, sites: null, views_7d: null }), '', 'nothing known: nothing shown');
-- assert.ok(SRC.includes("new IntersectionObserver(") && SRC.includes('setInterval(readPulse, PULSE_MS)'), 'read when the footer is in view, every minute while it is');
++ assert.ok(SRC.includes("new IntersectionObserver(") && SRC.includes('setInterval(readNetwork, PULSE_MS)'), 'read when the footer is in view, every minute while it is');
assert.ok(SRC.includes('.ovf-dot{') && !SRC.includes('prefers-reduced-motion'), 'the live dot pulses; Calm slows it (the loader) and only motion off stops it');
}
ddiff --git a/test/footer-online.test.js b/test/footer-online.test.js
nnew file mode 100644
iindex 0000000..1e60fc3
---- /dev/null
++++ b/test/footer-online.test.js
@@@ -0,0 +1,89 @@
++'use strict';
++// The footer strip's one read (owner, 2026-11-10): openvibe.network GET /api/frame/online fills the strip with who
++// was on the network in the last thirty minutes, the live channels, the counts that open what they count and this
++// site's own items. The old /api/frame/pulse line is the fallback. Nothing from the read is trusted: every string is
++// capped, every link must be https, and a guest is a number.
++// node test/footer-online.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const SRC = fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8');
++function load(host = 'openvibe.live') {
++ const ctx = vm.createContext({ console, URLSearchParams });
++ ctx.globalThis = ctx; ctx.window = ctx;
++ ctx.location = { protocol: 'https:', hostname: host, origin: `https://${host}`, pathname: '/@kim', href: `https://${host}/@kim` };
++ ctx.localStorage = { getItem: () => null, setItem() {} };
++ vm.runInContext(SRC, ctx);
++ return ctx.OpenVibeFooter;
++}
++
++const f = load();
++const read = {
++ updated: '2026-11-10T10:00:00.000Z',
++ members: 3, guests: 7, total: 10, more: 2,
++ people: [{ name: 'Ann', username: 'ann', avatar: 'https://openvibe.network/avatar/ann?s=64', site: 'Live' },
++ { name: 'Bo<b>b</b>', username: 'bob', avatar: 'javascript:alert(1)', site: 'Games<script>' }],
++ live: { channels: 2, watching: 41, first: [{ name: 'Eve', url: 'https://openvibe.live/@eve' }, { name: 'Fay', url: 'http://evil.example/' }] },
++ counts: [
++ { id: 'joined', label: 'Joined this week', value: 23, icon: 'members', url: 'https://openvibe.network/stats' },
++ { id: 'videos', label: 'Videos today', value: 4, icon: 'video', url: 'https://openvibe.video/' },
++ { label: 'Unsafe', value: 1, icon: 'video', url: 'http://openvibe.video/' },
++ ],
++ items: [
++ { label: 'The Rundown', icon: 'news', link: 'https://openvibe.live/rundown', line: 'What streamers talked about' },
++ { label: 'Clips', icon: 'clapper', link: 'https://openvibe.live/clips', value: 1284 },
++ ],
++ sites: 56, views_7d: 81234,
++};
++
++// ── the read is cleaned before it reaches a page ──
++const clean = f.cleanOnline(read);
++assert.deepStrictEqual(clean.people.map((p) => p.username), ['ann', 'bob']);
++assert.strictEqual(clean.people[1].avatar, '', 'a picture must be https');
++assert.ok(!clean.people[1].site.includes('<'), 'markup is gone');
++assert.deepStrictEqual(clean.live.first.map((s) => s.url), ['https://openvibe.live/@eve'], 'an http channel link is dropped');
++assert.deepStrictEqual(clean.counts.map((c) => c.link), ['https://openvibe.network/stats', 'https://openvibe.video/'], 'an http count link is dropped');
++assert.strictEqual(clean.items.length, 2, 'a site item with no value and no line is dropped, as on the server');
++assert.ok(!JSON.stringify(clean).includes('evil.example'));
++
++// ── the strip: the old facts, then who is online, then the counts and the site's own items ──
++const html = f.onlineHTML(clean);
++assert.ok(html.includes('<a class="ovf-tile is-live" href="https://openvibe.live/"><span class="ovf-dot" aria-hidden="true"></span><b>2</b><span>live now</span><small>41 watching</small></a>'), 'live is one small tile, with its dot and its way in');
++assert.ok(html.indexOf('ovf-who') < html.indexOf('is-live'), 'who is online comes first; live is among the tiles');
++assert.ok(html.includes('<b>56</b><span>sites, one account</span>') && html.includes('<b>81k</b><span>page views this week</span>'), 'the old facts are kept, as tiles');
++assert.ok(html.includes('<details class="ovf-who">') && html.includes('<b>10</b> online in the last 30 min') && html.includes('3 members · 7 guests'), 'who is online opens the list; guests are a count');
++assert.ok(/class="ovf-who-faces" aria-hidden="true"><img src="https:\/\/openvibe\.network\/avatar\/ann\?s=64"[^>]*><i aria-hidden="true">B<\/i>/.test(html), 'a few faces beside the count: a picture, or a first letter where there is none');
++assert.ok(html.includes('href="https://openvibe.network/@ann"') && html.includes('https://openvibe.network/avatar/ann?s=64') && html.includes('<small>Live</small>'), 'a named member links to their profile with their picture and site');
++assert.ok(html.includes('and 2 more'), 'the rest of the list is a count');
++assert.ok(!html.includes('guest') || !/guest[^s]/.test(html.replace(/7 guests/, '')), 'nothing about a guest but the count');
++assert.ok(html.includes('href="https://openvibe.network/stats"') && html.includes('23') && html.includes('Joined this week'), 'a count opens what it counts');
++assert.ok(html.includes('href="https://openvibe.live/rundown"') && html.includes('What streamers talked about'), 'a site\'s own fixed line');
++assert.ok(html.includes('href="https://openvibe.live/clips"') && html.includes('1.3k'), 'a site\'s own number');
++assert.ok(html.includes('class="ovf-ic"'), 'a count and an item carry their glyph');
++assert.ok(html.includes('data-icon=') === false, 'the glyph is drawn, not left for another script');
++assert.ok(!html.includes('http://'), 'no insecure link reaches the page');
++
++// Nobody live: nothing at all about live (the owner: show it only while someone is). Nobody online: no disclosure.
++assert.ok(!f.onlineHTML(f.cleanOnline({ ...read, live: { channels: 0, watching: 0, first: [] } })).includes('live now'), 'nobody live: no live line');
++assert.ok(f.pulseHTML({ live_now: 0, watching: 0, sites: 40, views_7d: 1500 }).includes('Nobody live right now · go live'), 'the old line is the fallback');
++assert.ok(!f.onlineHTML(f.cleanOnline({ ...read, members: 0, guests: 0, total: 0, people: [] })).includes('online in the last'), 'nobody online: no list');
++assert.ok(!f.onlineHTML(f.cleanOnline({ ...read, counts: [], items: [] })).includes('ovf-online-actions'), 'no counts and no items: only the old facts');
++assert.ok(f.onlineHTML(f.cleanOnline({ ...read, people: [], members: 0, total: 7 })).includes('<div class="ovf-who ovf-who--flat">'), 'guests only: a line, with nothing to open');
++
++// The slot stays in the footer; the strip is read from /api/frame/online, with the old line as the fallback.
++assert.ok(f.buildHTML({ service: 'live', variant: 'full' }).includes('class="ovf-pulse" data-ovf-pulse'), 'the strip slot');
++assert.ok(SRC.includes('const ONLINE_URL = `${NETWORK_URL}/api/frame/online`'));
++assert.ok(SRC.includes('readNetwork()') && SRC.includes('setInterval(readNetwork, PULSE_MS)'), 'read when the footer is in view, every minute while it is');
++assert.ok(SRC.includes('readPulse(); return;'), 'the old pulse line when the new read does not answer');
++assert.ok(SRC.includes('?site=${encodeURIComponent(site)}'), 'the read asks as this site, so its own items come back');
++
++// The page-view beacon says only whether the browser is signed in (a boolean, no identity): the network
++// counts that person from their presence heartbeat and leaves them out of the guest count.
++const NB = fs.readFileSync(path.join(__dirname, '..', 'navbar.js'), 'utf8');
++assert.ok(NB.includes('sendBeacon(_config.user && !_config.user.is_anon ? `${hit}?in=1` : hit)'), 'the beacon marks a signed-in browser, and never an anonymous account');
++assert.ok(NB.includes("document.addEventListener('openvibe-navbar-auth', send, { once: true })"), 'it waits for the shared sign-in state, once');
++assert.ok(!/sendBeacon\([^)]*username|sendBeacon\([^)]*token/.test(NB), 'no identity travels with it');
++
++console.log('footer-online: one read fills the strip, a guest is a number, a live member is named with their picture and site, counts and the site\'s own items open what they count');
ddiff --git a/test/history-relay.test.js b/test/history-relay.test.js
nnew file mode 100644
iindex 0000000..444fcae
---- /dev/null
++++ b/test/history-relay.test.js
@@@ -0,0 +1,27 @@
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++(async () => {
++ const source = fs.readFileSync(path.join(__dirname, '..', 'history.js'), 'utf8');
++ const originalFetch = global.fetch;
++ let request;
++ global.fetch = async (url, init) => { request = { url, init }; return { ok: true }; };
++ try {
++ delete global.OpenVibeHistory;
++ delete require.cache[require.resolve('../history')];
++ require('../history');
++ const { record } = global.OpenVibeHistory;
++ await record({ type: 'document', title: 'A page', url: 'https://openvibe.design/a', picture: 'https://openvibe.design/a.png', private: true, mature: false }, { token: 'fixture' });
++ assert.ok(request.url.endsWith('/api/history'));
++ const body = JSON.parse(request.init.body);
++ assert.deepStrictEqual([body.image_url, body.private, body.mature], ['https://openvibe.design/a.png', true, false]);
++ assert.ok(source.includes('entry.image_url || entry.picture'));
++
++ const dockActor = fs.readFileSync(path.join(__dirname, '..', 'dock-actor.js'), 'utf8');
++ assert.ok(dockActor.includes('/api/v1/history/agents/recent?limit=10'), 'the Dock reads only the opt-in agent endpoint');
++ assert.ok(dockActor.includes('Your agent history setting is off'), 'the Dock handles an off switch');
++ } finally { global.fetch = originalFetch; }
++ console.log('history relay: local fixture passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/machines-chrome.test.js b/test/machines-chrome.test.js
nnew file mode 100644
iindex 0000000..7f0c688
---- /dev/null
++++ b/test/machines-chrome.test.js
@@@ -0,0 +1,32 @@
++'use strict';
++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('machines-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const root = path.join(__dirname, '..');
++const server = http.createServer((req, res) => {
++ if (req.url === '/machines.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'machines.js'))); }
++ if (req.url.startsWith('/api/v1/machines')) { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ machines: [{ id: 'dev_1', principal_id: 'lnk_01J8Z4N0A1B2C3D4E5F6G7H8J0', name: 'Studio', kind: 'onboard', system: 'Linux', online: true, roles: ['ai.host'] }] })); }
++ res.writeHead(200, { 'content-type': 'text/html' }); res.end(`<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="/kit.css"><form><div data-ov-machines data-needs="models" data-name="machine" data-principal-name="principal" data-auto-label="Auto" data-value="dev_old" data-api-base="http://127.0.0.1:${server.address().port}"></div></form><script>window.OpenVibeNavbar={session:{token:"fixture"}}</script><script src="/machines.js"></script>`);
++});
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-machines-'));
++ const browser = await h.launch({ tmpDir });
++ try {
++ const page = await h.openPage(browser, await browser.newContext(), { width: 800, height: 700 });
++ await page.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });
++ const until = async (expr) => { const end = Date.now() + 5000; while (Date.now() < end) { const v = await page.evaluate(expr); if (v) return v; await new Promise((r) => setTimeout(r, 50)); } return false; };
++ assert.ok(await until(`Array.from(document.querySelectorAll('.ovk-row strong')).some((n) => n.textContent === 'Studio')`));
++ assert.equal(await page.evaluate(`document.querySelector('input[type=hidden][name=machine]').value`), 'dev_old', 'initial choice is preserved');
++ assert.equal(await page.evaluate(`document.querySelector('input[type=hidden][name=principal]').value`), '', 'unlisted selections keep a blank mapped value');
++ assert.ok(await until(`Array.from(document.querySelectorAll('.ovk-row strong')).some((n) => n.textContent === 'Auto')`));
++ await page.evaluate(`document.querySelector('input[type=radio][value="dev_1"]').click()`);
++ assert.equal(await page.evaluate(`document.querySelector('input[type=hidden][name=machine]').value`), 'dev_1');
++ assert.equal(await page.evaluate(`document.querySelector('input[type=hidden][name=principal]').value`), 'lnk_01J8Z4N0A1B2C3D4E5F6G7H8J0');
++ console.log('machines-chrome: ok');
++ } finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/machines.test.js b/test/machines.test.js
nnew file mode 100644
iindex 0000000..d740eed
---- /dev/null
++++ b/test/machines.test.js
@@@ -0,0 +1,15 @@
++'use strict';
++const assert = require('node:assert/strict');
++const machines = require('../machines');
++const files = require('../files');
++
++const sample = [
++ { id: 'dev_1', roles: ['codes.worker', 'ai.host'] },
++ { id: 'dev_2', roles: ['desks.host'] },
++];
++assert.equal(machines.needsMachine(sample[0], 'models'), true);
++assert.equal(machines.needsMachine(sample[1], 'models'), false);
++assert.equal(machines.needsMachine(sample[1], 'desktop'), true);
++assert.equal(machines.canRun(sample[0]), 'coding jobs, your AI models');
++assert.equal(files.isBrowserFile('machines.js'), true);
++console.log('machines fixture: ok');
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex 755e354..6b868b1 100644
---- a/test/nav-icons.test.js
++++ b/test/nav-icons.test.js
@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
--const NAVBAR_BROTLI_BUDGET = 48.75 * 1024; // 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
++const NAVBAR_BROTLI_BUDGET = 49.8 * 1024; // 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
// ── size budget and generated block ──────────────────────────
{
ddiff --git a/test/navbar-fit-chrome.test.js b/test/navbar-fit-chrome.test.js
nnew file mode 100644
iindex 0000000..eafdb83
---- /dev/null
++++ b/test/navbar-fit-chrome.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++// The bar holds one row at every width (4.190.0), in a real headless Chrome. The owner, 2026-11-10, with openvibe.chat in
++// Chrome's desktop mode on a phone: "Global chat" on two lines, the balances cut, the bell under the Ask button. With
++// eight links and a signed-in person: nothing wraps, nothing is cut, and the links that do not fit step out whole.
++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('navbar-fit-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const root = path.join(__dirname, '..');
++const LINKS = [['vibe', 'Vibe'], ['global', 'Global chat'], ['rooms', 'Rooms'], ['messages', 'Messages'], ['inbox', 'Inbox'], ['servers', 'Servers'], ['explore', 'Explore'], ['admin', 'Admin']];
++const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Bar</title></head><body style="margin:0;min-height:120vh">
++<script>const J = (b, ok = true, status = 200) => Promise.resolve({ ok, status, json: async () => b, text: async () => JSON.stringify(b) });
++ window.fetch = (u) => { const s = String(u); if (s.includes('/api/frame?')) return J({ nav: [], groups: [], soon: [], footer: {} });
++ if (s.includes('/wallet')) return J({ bits_per_usd: 100, balances: { coins: { amount: '160799', scale: 0, currency: 'opencoins' }, vibes: { amount: '400', scale: 0, currency: 'vibes-bits' }, balance: { amount: '0', scale: 2, currency: 'USD' } } });
++ return J({}, false, 404); };</script>
++<script src="/shared/navbar.js"></script>
++<script>OpenVibeNavbar.init({ service: 'chat', auth: 'external', apiBase: 'https://openvibe.network', user: { id: 7, subject: 'usr_7', username: 'maya', display_name: 'Maya' }, token: 'tok', presence: false, dock: false,
++ links: ${JSON.stringify(LINKS.map(([id, label]) => ({ id, label, href: `/${id}`, icon: 'fa-circle' })))} });</script></body></html>`;
++const server = http.createServer((req, res) => {
++ const u = req.url.split('?')[0];
++ if (u.startsWith('/shared/')) { const f = path.join(root, u.slice(8)); if (fs.existsSync(f) && fs.statSync(f).isFile()) { res.writeHead(200, { 'content-type': f.endsWith('.woff2') ? 'font/woff2' : 'application/javascript' }); return res.end(fs.readFileSync(f)); } res.writeHead(404); return res.end(''); }
++ res.writeHead(200, { 'content-type': 'text/html' }); res.end(html);
++});
++const READ = `(() => { const nav = document.querySelector('.openvibe-navbar'); if (!nav) return null; const r = nav.getBoundingClientRect();
++ const parts = [...nav.querySelectorAll('.openvibe-navbar-links > *, .openvibe-navbar-right > *, .openvibe-navbar-brand')].filter((e) => e.getBoundingClientRect().width > 0).map((e) => e.getBoundingClientRect());
++ const links = [...nav.querySelectorAll('.openvibe-navbar-links > *')];
++ return { h: Math.round(r.height), low: parts.filter((q) => q.bottom > r.bottom + 1 || q.top < r.top - 1).length, tall: parts.filter((q) => q.height > r.height + 1).length, off: parts.filter((q) => q.right > innerWidth + 1).length,
++ over: nav.scrollWidth - nav.clientWidth, shown: links.filter((e) => e.getBoundingClientRect().width > 0).map((e) => e.textContent.trim()), out: links.filter((e) => e.classList.contains('ovnav-out')).length, total: links.length }; })()`;
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-navfit-'));
++ const browser = await h.launch({ tmpDir });
++ try {
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const seen = {};
++ for (const width of [880, 940, 1010, 1100, 1400]) {
++ const page = await h.openPage(browser, await browser.newContext(), { width, height: 500 });
++ await page.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 8000 });
++ let v = null;
++ const end = Date.now() + 6000;
++ while (Date.now() < end) { v = await page.evaluate(READ); if (v && v.total === LINKS.length && v.over <= 1) break; await wait(120); }
++ await wait(400); v = await page.evaluate(READ);
++ assert.ok(v && v.total === LINKS.length, `${width}: the bar drew its links`);
++ assert.deepEqual([v.low, v.tall, v.off], [0, 0, 0], `${width}: nothing wraps under the bar, nothing is taller than the bar, nothing runs off the right edge (${JSON.stringify(v)})`);
++ assert.ok(v.over <= 1, `${width}: the bar holds its row (${v.over} px over)`);
++ assert.deepEqual(v.shown, LINKS.slice(0, v.shown.length).map((l) => l[1]), `${width}: the links that stay are the first ones, in order, whole`);
++ seen[width] = v.shown.length;
++ 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(' ')})`);
++ } finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/ov-drop-chrome.test.js b/test/ov-drop-chrome.test.js
nnew file mode 100644
iindex 0000000..ce0e959
---- /dev/null
++++ b/test/ov-drop-chrome.test.js
@@@ -0,0 +1,67 @@
++'use strict';
++// The one dropdown (ov-drop.js, 4.191.0) in a real headless Chrome: it opens under what was pressed and stays on screen,
++// opens above when there is no room below, marks the present page, moves with the arrow keys, closes with Escape (the
++// focus goes back) and with a press outside, and the bar's name opens the site's pages with Home first.
++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-drop-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const root = path.join(__dirname, '..');
++const server = http.createServer((req, res) => {
++ const u = req.url.split('?')[0];
++ if (u === '/ov-drop.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-drop.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:1400px">
++<a id="name" href="/" style="position:absolute;left:300px;top:20px">Chat</a>
++<button id="low" style="position:fixed;right:4px;bottom:6px">More</button>
++<script src="/ov-drop.js"></script>
++<script>
++ window.picked = '';
++ OVDrop.attach(document.getElementById('low'), [{ label: 'Rename', onClick: () => { window.picked = 'rename'; } }, null, { label: 'Delete', danger: true, onClick: () => { window.picked = 'delete'; } }], { label: 'This room', align: 'end' });
++ document.getElementById('name').addEventListener('click', (e) => { e.preventDefault(); OVDrop.pages(e.currentTarget, [{ label: 'Servers', href: '/servers', icon: 'i' }, { label: 'Vibe', href: '/vibe' }, { label: 'Elsewhere', href: 'https://example.org/' }, { label: 'Hidden', href: '/x', hidden: true }], (n) => '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="6"/></svg>'); });
++</script>`);
++});
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-drop-'));
++ const browser = await h.launch({ tmpDir });
++ try {
++ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 700 });
++ await page.goto(`http://127.0.0.1:${server.address().port}/servers/abc`, { maxMs: 5000 });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const until = async (expr) => { const end = Date.now() + 5000; while (Date.now() < end) { const v = await page.evaluate(expr); if (v) return v; await wait(50); } return false; };
++
++ // The site's name: its pages, Home first, the present page marked, an outside address and a hidden link left out.
++ await page.evaluate(`document.getElementById('name').click()`);
++ assert.ok(await until(`!!document.querySelector('.ovdrop.is-open')`), 'it opens');
++ const a = await page.evaluate(`(() => { const d = document.querySelector('.ovdrop'), r = d.getBoundingClientRect(), n = document.getElementById('name').getBoundingClientRect();
++ return { rows: [...d.querySelectorAll('.ovdrop-item')].map((x) => x.textContent.trim() + (x.hasAttribute('aria-current') ? '*' : '')), left: r.left, right: r.right, top: r.top, under: r.top >= n.bottom, vw: innerWidth,
++ label: d.getAttribute('aria-label'), role: d.getAttribute('role'), expanded: document.getElementById('name').getAttribute('aria-expanded'), icons: d.querySelectorAll('.ovdrop-ic svg').length }; })()`);
++ assert.deepEqual(a.rows, ['Home', 'Servers*', 'Vibe'], 'Home first, then the links; the page you are on is marked (/servers/abc is under Servers)');
++ assert.ok(a.under && a.left >= 8 && a.right <= a.vw - 8, 'under the name and wholly on the screen, though the name is near the right edge');
++ assert.deepEqual([a.label, a.role, a.expanded, a.icons], ['Pages on Chat', 'menu', 'true', 3], 'named, a menu, the name says it is open, each row has its icon');
++ // Escape closes it; the name says so.
++ await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
++ assert.ok(await until(`!document.querySelector('.ovdrop.is-open') && document.getElementById('name').getAttribute('aria-expanded') === 'false'`), 'Escape closes it');
++
++ // A button at the bottom of the screen: the list opens above it, aligned to its end; a row's action runs once.
++ await page.evaluate(`document.getElementById('low').click()`);
++ assert.ok(await until(`!!document.querySelector('.ovdrop.is-open')`));
++ const b = await page.evaluate(`(() => { const d = document.querySelector('.ovdrop.is-open'), r = d.getBoundingClientRect(), n = document.getElementById('low').getBoundingClientRect();
++ return { above: r.bottom <= n.top, right: r.right, vw: innerWidth, vh: innerHeight, bottom: r.bottom, sep: d.querySelectorAll('.ovdrop-sep').length, danger: d.querySelectorAll('.is-danger').length }; })()`);
++ assert.ok(b.above && b.bottom <= b.vh && b.right <= b.vw - 4, 'no room below: it opens above, still on the screen');
++ assert.deepEqual([b.sep, b.danger], [1, 1], 'a hairline between groups; a destructive row is marked');
++ await page.evaluate(`document.querySelector('.ovdrop.is-open .ovdrop-item:last-of-type').click()`);
++ assert.ok(await until(`window.picked === 'delete' && !document.querySelector('.ovdrop.is-open')`), 'choosing runs the row and closes the list');
++ // The same button closes what it opened; one list at a time.
++ await page.evaluate(`document.getElementById('low').click()`); assert.ok(await until(`!!document.querySelector('.ovdrop.is-open')`));
++ await page.evaluate(`document.getElementById('name').click()`);
++ assert.ok(await until(`document.querySelectorAll('.ovdrop.is-open').length === 1 && document.querySelector('.ovdrop.is-open').getAttribute('aria-label') === 'Pages on Chat'`), 'opening another closes the first');
++ await page.evaluate(`document.getElementById('name').click()`);
++ assert.ok(await until(`!document.querySelector('.ovdrop.is-open')`), 'pressing the same thing again closes it');
++ console.log('ov-drop-chrome: under or above what was pressed, on the screen, pages with Home first and the present page marked, Escape, one at a time');
++ } finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/ov-upload-browser.test.js b/test/ov-upload-browser.test.js
nnew file mode 100644
iindex 0000000..1ab4a79
---- /dev/null
++++ b/test/ov-upload-browser.test.js
@@@ -0,0 +1,46 @@
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const path = require('path');
++const browserHarness = require('../browser-harness');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !browserHarness.findChrome()) {
++ console.log('ov-upload-browser: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++(async () => {
++ const script = fs.readFileSync(path.join(__dirname, '../ov-upload.js'));
++ const server = http.createServer((req, res) => {
++ if (req.url === '/ov-upload.js') { res.setHeader('Content-Type', 'text/javascript'); return res.end(script); }
++ res.setHeader('Content-Type', 'text/html');
++ res.end('<!doctype html><html><head><meta charset="utf-8"><script src="/ov-upload.js"></script></head><body><div id="row"></div></body></html>');
++ });
++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
++ let browser, context, page;
++ try {
++ browser = await browserHarness.launch();
++ context = await browser.newContext();
++ page = await browserHarness.openPage(browser, context);
++ await page.goto(`http://127.0.0.1:${server.address().port}/`);
++ const result = await page.evaluate(`(async () => {
++ let again = false;
++ const file = new File(['duplicate fixture'], 'same.txt', { type: 'text/plain' });
++ const row = document.getElementById('row');
++ await OVUpload.preflight(file, async (_file, hash) => ({ duplicate: true, object: { date: '2026-11-10', address: '/p/existing', url: '/p/existing', sha256: hash } }), row, { onAgain: () => { again = true; } });
++ const labels = Array.from(row.querySelectorAll('button,a')).map((x) => x.textContent);
++ const note = row.querySelector('.ov-upload__duplicate-note').textContent;
++ row.querySelector('button:last-child').click();
++ return { labels, note, again };
++ })()`);
++ assert.deepStrictEqual(result.labels, ['Open it', 'Use that one', 'Upload again']);
++ assert.match(result.note, /^You already uploaded this on 10 Nov 2026\.$/, 'the day as a person would say it');
++ assert.strictEqual(result.again, true);
++ } finally {
++ if (page) await page.close();
++ if (context) await context.dispose();
++ if (browser) await browser.close();
++ await new Promise((resolve) => server.close(resolve));
++ }
++})().catch((error) => { console.error(error); process.exitCode = 1; });
ddiff --git a/test/ov-upload.test.js b/test/ov-upload.test.js
iindex 1f48681..bc8001a 100644
---- a/test/ov-upload.test.js
++++ b/test/ov-upload.test.js
@@@ -16,6 +16,13 @@ assert.ok(burst > steady && burst < 20 * 1024 * 1024, 'one burst is smoothed');
assert.strictEqual(upload.rate(burst, 0, 1), burst, 'a stalled sample does not become a false zero rate');
assert.strictEqual(upload.timeLeft(Infinity), 'Estimating time left');
++(async () => {
++ const bytes = Buffer.from('fixture upload');
++ const file = { size: bytes.length, slice: (a, b) => new Blob([bytes.subarray(a, b)]) };
++ assert.strictEqual(await upload.sha256(file), require('crypto').createHash('sha256').update(bytes).digest('hex'), 'fixture bytes use their exact SHA-256');
++ assert.strictEqual(await upload.sha256({ size: 99, slice: file.slice }, 10), null, 'large files skip the expensive preflight hash');
++})().catch((error) => { console.error(error); process.exitCode = 1; });
++
assert.ok(files.isBrowserFile('ov-upload.js') && files.isBrowserFile('ov-upload.css'));
const css = fs.readFileSync(path.join(__dirname, '../ov-upload.css'), 'utf8');
assert.match(css, /html\[data-ov-motion=off\] \.ov-progress__check path\{stroke-dashoffset: 0\}/, 'with motion off the tick is simply drawn');
ddiff --git a/test/run.js b/test/run.js
iindex e7c0aa5..7aa7240 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -13,4 +13,6 @@
* a skipped file is listed with ○ and never counted as passed.
*/
'use strict';
--require('../test-runner').main({ dir: __dirname, timeoutMs: 60000, pad: 36, serial: /-chrome\.test\.js$/ });
++// A headless-Chrome file that fails is run once more (a slow runner, not a wrong page); the summary names any that
++// needed it.
++require('../test-runner').main({ dir: __dirname, timeoutMs: 60000, pad: 36, serial: /-chrome\.test\.js$/, retry: /-chrome\.test\.js$/ });
ddiff --git a/test/shell.test.js b/test/shell.test.js
iindex 25d3eb1..2ed1342 100644
---- a/test/shell.test.js
++++ b/test/shell.test.js
@@@ -124,4 +124,17 @@ assert.ok(withSummary.indexOf('</head>') < withSummary.indexOf('<noscript><secti
assert.doesNotMatch(shell.page({ name: 'X', service: 'mail', favicon: false, body: '' }), /rel="icon"/, 'favicon: false opts out');
assert.match(shell.page({ name: 'Plain', body: '' }), /data-ov-icon="network"/, 'no service: the network mark');
}
++{
++ // One bar mount per page: the server's bar goes into the page's own mount where it has one (4.190.0).
++ const count = (html) => (html.match(/id="navbar-mount"/g) || []).length;
++ const plain = shell.page({ name: 'X', service: 'mail', body: '<main>Hi</main>' });
++ assert.equal(count(plain), 1, 'a page with no mount of its own gets one');
++ const own = shell.page({ name: 'X', service: 'mail', body: '<div id="navbar-mount"></div>\n<main>Hi</main>' });
++ assert.equal(count(own), 1, 'a page with its own mount keeps one, not two (the second was an empty 52 px box)');
++ assert.match(own, /<div id="navbar-mount"><nav class="openvibe-navbar"[^>]*data-ov-server-nav="true"[\s\S]*<\/nav><\/div>\n<main>Hi<\/main>/, 'and the server\'s bar is in it, where the page put it');
++ const classed = shell.page({ name: 'X', service: 'mail', body: '<div id="navbar-mount" class="top"></div><main>Hi</main>' });
++ assert.equal(count(classed), 1, 'a mount with attributes is the same mount');
++ const price = shell.page({ name: 'X', navbar: { service: 'mail', links: [{ label: 'Costs $1 $& more', href: '/a' }] }, body: '<div id="navbar-mount"></div>' });
++ assert.ok(price.includes('Costs $1 $&amp; more') && (price.match(/id="navbar-mount"/g) || []).length === 1, 'a dollar sign in a link is written as it is');
++}
console.log('shell: all checks passed');
ddiff --git a/test/shipped.test.js b/test/shipped.test.js
iindex 152f6e0..3b58461 100644
---- a/test/shipped.test.js
++++ b/test/shipped.test.js
@@@ -167,7 +167,7 @@ const data = {
const more = logEl.querySelector('.ov-shipped-more');
assert.strictEqual(more.hidden, false);
assert.deepStrictEqual([...logEl.querySelectorAll('.ov-shipped-bar:not(.ov-shipped-kinds) .ov-shipped-chip')].map((b) => b.textContent), ['Wiki', 'All of OpenVibe']);
-- assert.deepStrictEqual([...logEl.querySelectorAll('.ov-shipped-kinds .ov-shipped-chip')].map((b) => b.textContent), ['All changes', 'New', 'Better', 'Fixed'], 'the kind filter');
++ assert.deepStrictEqual([...logEl.querySelectorAll('.ov-shipped-kinds .ov-shipped-chip')].map((b) => b.textContent), ['All changes', 'New', 'Improved', 'Fixed'], 'the kind filter (an improvement is named "Improved", not "Better")');
more.dispatchEvent(new window.Event('click'));
await new Promise((r) => setTimeout(r, 20));
assert.ok(calls.some((u) => u.includes('before=CUR') && u.includes('service=wiki')));
ddiff --git a/test/surfaces-chrome.test.js b/test/surfaces-chrome.test.js
iindex bc4aa6a..8cf8fce 100644
---- a/test/surfaces-chrome.test.js
++++ b/test/surfaces-chrome.test.js
@@@ -64,9 +64,11 @@ const server = http.createServer((req, res) => {
for (let i = 0; i < 60 && !adopted; i++) { adopted = await p.evaluate('document.querySelector("#sheet").open && document.body.style.position === "fixed"'); if (!adopted) await new Promise((ok) => setTimeout(ok, 50)); }
assert.strictEqual(adopted, true, 'the sheet opens again and locks the page');
await new Promise((ok) => setTimeout(ok, 60));
-- await p.evaluate(`(() => { const head=document.querySelector('[data-ov-sheet-head]');
-- const fire=(type,y)=>head.dispatchEvent(new PointerEvent(type,{bubbles:true,pointerId:5,pointerType:'touch',clientY:y}));
-- fire('pointerdown',200);fire('pointermove',330);fire('pointerup',330); })()`);
++ const head = await p.evaluate(`(() => { const r=document.querySelector('[data-ov-sheet-head]').getBoundingClientRect();
++ return {x:r.left+r.width/2,y:r.top+r.height/2}; })()`);
++ await p.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [head] });
++ await p.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: head.x, y: head.y + 130 }] });
++ await p.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
assert.strictEqual(await p.evaluate('document.querySelector("#sheet").open'), false, 'a downward header swipe closes the sheet');
await p.evaluate('document.querySelector("#one").click()');
await p.evaluate('document.querySelector("#two").click()');
ddiff --git a/test/test-runner.test.js b/test/test-runner.test.js
iindex afba2be..bb83587 100644
---- a/test/test-runner.test.js
++++ b/test/test-runner.test.js
@@@ -60,6 +60,21 @@ const { run, skipsIn, resolveParallel } = require('../test-runner');
assert.strictEqual(r.exitCode, 1);
assert.deepStrictEqual(skipsIn('x\n ACK test: skipped (ffmpeg not installed) \nrollback with newer writes: skipped (no git history)\nOK B: skipped — old style'),
['ACK test: skipped (ffmpeg not installed)', 'rollback with newer writes: skipped (no git history)']);
++ // retry: a file that matches and fails is run once more. One that passes the second time counts as passed and is
++ // named; one that fails twice is a failure; a file that does not match is not tried again.
++ const flag = path.join(dir, 'ran-once');
++ write('f-slow-chrome.test.js', `const fs = require('fs'); if (!fs.existsSync(${JSON.stringify(flag)})) { fs.writeFileSync(${JSON.stringify(flag)}, '1'); throw new Error('too slow the first time'); } console.log('f: all checks passed');\n`);
++ write('g-broken-chrome.test.js', "throw new Error('wrong both times');\n");
++ lines.length = 0;
++ r = await run({ dir, argv: ['f-slow', 'g-broken', 'd-fail', 'a-pass'], log, retry: /-chrome\.test\.js$/ });
++ assert.deepStrictEqual(r.results.map((x) => [x.file, x.state, Boolean(x.retried)]), [['a-pass.test.js', 'pass', false], ['d-fail.test.js', 'fail', false], ['f-slow-chrome.test.js', 'pass', true], ['g-broken-chrome.test.js', 'fail', true]]);
++ assert.deepStrictEqual([r.passed, r.failed, r.exitCode, r.secondTry], [2, 2, 1, ['f-slow-chrome.test.js']]);
++ assert.match(r.summary, /^2\/4 test files passed; passed on a second try: f-slow-chrome\.test\.js$/, 'the summary names what needed a second try');
++ assert.ok(lines.some((l) => l.startsWith('↻ f-slow-chrome.test.js')) && lines.some((l) => l.startsWith('✓ f-slow-chrome.test.js')), 'the first failure is said, then the pass');
++ assert.strictEqual(lines.filter((l) => l.startsWith('↻ d-fail')).length, 0, 'a file that does not match is not run again');
++ fs.rmSync(flag, { force: true });
++ r = await run({ dir, argv: ['f-slow'], log });
++ assert.deepStrictEqual([r.failed, r.exitCode], [1, 1], 'without retry, the first failure stands');
fs.rmSync(dir, { recursive: true, force: true });
console.log('test-runner: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });