openvibers/openvibe.shared Commits
4.190.0: the one dropdown, the bar holds one row, the wallet in colour, who is online (#417)
Changes
@@@ -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@@@ -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 deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -831,7 +831,18 @@ One menu (4.187): the OpenVibe mark is a button that opens the OpenVibe menuon its first tab, as the menu button does (there is no separate siteswitcher). The site's name stays the link home; switcher: false keeps themark 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, withone `.ovnav-chip-mini` per balance the person has (OpenCoins, Vibes, the moneybalance; only those above zero, the largest first) and one of them showing.`purse.js`, loaded beside the navbar, draws each currency's icon@@@ -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)",@@@ -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 };@@@ -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) {@@@ -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' });@@@ -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@@@ -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;opacityif (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;opacitypaintPulse();}).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;opacitylet _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;opacityreturn `<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); } };});@@@ -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,nnew file mode 100644@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);@@@ -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 forevery control at the bar's right, 36 px (34 px on a phone under 400 px): Sign In, the orb, messages, thepicture, the balances and a featured link. The orb draws its dots at 26 px (24) so it reads as large as@@@ -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;nnew file mode 100644@@@ -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);@@@ -466,6 +466,8 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durrail.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;}@@@ -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); } }@@@ -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 filesreturn { 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 };});@@@ -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}@@@ -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 itconst 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);@@@ -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",@@@ -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",@@@ -11,7 +11,10 @@OpenVibePurse.mount(button) the pill, after each draw of the barOpenVibePurse.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);@@@ -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'] },@@@ -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>',@@@ -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;@@@ -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. */@@@ -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');}nnew file mode 100644@@@ -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');nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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');@@@ -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 ──────────────────────────{nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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; });@@@ -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');@@@ -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$/ });@@@ -124,4 +124,17 @@ assert.ok(withSummary.indexOf('</head>') < withSummary.indexOf('<noscript><sectiassert.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 $& 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');@@@ -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')));@@@ -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()');@@@ -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); });
@@ -1,5 +1,61 @@
@@ -1,5 +1,61 @@
# Changelog
# 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
## 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
**The balances are one pill, the menu and the Dock lead to each other, and the menu opens where you are.** Seven notes
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.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
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -831,7 +831,18 @@ One menu (4.187): the OpenVibe mark is a button that opens the OpenVibe menu
@@ -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
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
switcher). The site's name stays the link home; switcher: false keeps the
mark as a home link.
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
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.
balance; only those above zero, the largest first) and one of them showing.
`purse.js`, loaded beside the navbar, draws each currency's icon
`purse.js`, loaded beside the navbar, draws each currency's icon
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.189.0",
+ "version": "4.190.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
'use strict';
'use strict';
let barEl = null;
let barEl = null;
let resizeBound = false;
let resizeBound = false;
+ let pillWatch = null;
function spec(ctx) {
function spec(ctx) {
const cfg = ctx.config.appBar;
const cfg = ctx.config.appBar;
@@ -80,8 +81,12 @@
@@ -80,8 +81,12 @@
const ic = bar && bar.querySelector('.ovnav-ab.is-here .ovnav-ab-ic');
const ic = bar && bar.querySelector('.ovnav-ab.is-here .ovnav-ab-ic');
if (!pill || !ic) return;
if (!pill || !ic) return;
if (instant) bar.classList.add('ab-still');
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);
const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (f) => setTimeout(f, 16);
if (instant) raf(() => raf(() => bar.classList.remove('ab-still')));
if (instant) raf(() => raf(() => bar.classList.remove('ab-still')));
}
}
@@ -137,6 +142,14 @@
@@ -137,6 +142,14 @@
if (de && de.classList) de.classList.add('ov-has-appbar');
if (de && de.classList) de.classList.add('ov-has-appbar');
placePill(bar, true);
placePill(bar, true);
if (!resizeBound && typeof window !== 'undefined' && typeof window.addEventListener === 'function') { resizeBound = true; window.addEventListener('resize', () => placePill(barEl, 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 };
const api = { render, spec };
@@ -161,6 +161,26 @@
@@ -161,6 +161,26 @@
form.append(input, send);
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 = ''; }); });
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);
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) {
async function act(path, body, btn) {
@@ -112,7 +112,7 @@
@@ -112,7 +112,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
- const DOCK_REV = { 'dock-vibe.js': '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) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -578,7 +578,7 @@
@@ -578,7 +578,7 @@
});
});
// Actor v3 (A3): your Actor first, on every site; it shows nothing for a person without one.
// 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) => {
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) => {
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' });
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' });
@@ -25,7 +25,7 @@ const BROWSER = Object.freeze([
@@ -25,7 +25,7 @@ const BROWSER = Object.freeze([
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
'dock-runs.js', // the Dock's content-aware Agents rows and overview, loaded with that tab
'dock-runs.js', // the Dock's content-aware Agents rows and overview, loaded with that tab
'ai-marks-client.js', // maker marks on a run receipt, loaded only in Agents
'ai-marks-client.js', // maker marks on a run receipt, loaded only in Agents
- 'presence.js', 'purse.js', '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-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent
'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent
@@ -218,6 +218,20 @@
@@ -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"/>',
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"/>',
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"/>',
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>`;
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 @@
@@ -290,7 +304,7 @@
const networkLinks = network.slice(0, 8);
const networkLinks = network.slice(0, 8);
return `<div class="ovf-inner${compact ? ' ovf-inner--expanded' : ''}">
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-cols">
<div class="ovf-col ovf-col--brand">
<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>
<a class="ovf-brand" href="/"><span class="ov-mark" data-size="38" data-wide></span><span>${esc(brand)}</span></a>
@@ -437,6 +451,39 @@
@@ -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);
.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}
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-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 a:hover{color:var(--accent,#60a5fa)}
.ovf-pulse .ovf-sep{color:var(--text-muted,#8b93ad)}
.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}
.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
@@ -532,10 +579,17 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
// ── The pulse: the network right now ───────────────────────
// ── 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.
// 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 PULSE_URL = `${NETWORK_URL}/api/frame/pulse`;
+ const ONLINE_URL = `${NETWORK_URL}/api/frame/online`;
const PULSE_MS = 60_000;
const PULSE_MS = 60_000;
let _pulse = null;
let _pulse = null;
let _pulseAt = 0;
let _pulseAt = 0;
+ let _online = null;
+ let _onlineAt = 0;
let _pulseObserver = null;
let _pulseObserver = null;
let _pulseTimer = 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));
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
@@ -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>`);
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>');
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() {
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; }
for (const el of _mounted.querySelectorAll('[data-ovf-pulse]')) { el.innerHTML = html; el.hidden = !html; }
}
}
function readPulse() {
function readPulse() {
@@ -567,18 +686,33 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
@@ -567,18 +686,33 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
paintPulse();
paintPulse();
}).catch(() => { /* a quiet footer is fine */ });
}).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() {
function watchPulse() {
if (!_mounted || _cfg.pulse === false || typeof window === 'undefined') return;
if (!_mounted || _cfg.pulse === false || typeof window === 'undefined') return;
- if (_pulse) paintPulse();
+ if (_online || _pulse) paintPulse();
if (_pulseObserver) return;
if (_pulseObserver) return;
- if (typeof IntersectionObserver !== 'function') { readPulse(); return; }
+ if (typeof IntersectionObserver !== 'function') { readNetwork(); return; }
_pulseObserver = new IntersectionObserver((entries) => {
_pulseObserver = new IntersectionObserver((entries) => {
const seen = entries.some(e => e.isIntersecting);
const seen = entries.some(e => e.isIntersecting);
clearInterval(_pulseTimer);
clearInterval(_pulseTimer);
_pulseTimer = null;
_pulseTimer = null;
if (!seen) return;
if (!seen) return;
- readPulse();
- _pulseTimer = setInterval(readPulse, PULSE_MS);
+ readNetwork();
+ _pulseTimer = setInterval(readNetwork, PULSE_MS);
}, { rootMargin: '200px 0px' });
}, { rootMargin: '200px 0px' });
_pulseObserver.observe(_mounted);
_pulseObserver.observe(_mounted);
}
}
@@ -587,7 +721,7 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
@@ -587,7 +721,7 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
let _shippedLoading = false;
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
// 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.
// 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;
let _shippedSeen = null;
function shippedWhenNear(el) {
function shippedWhenNear(el) {
if (typeof IntersectionObserver !== 'function' || !el || !el.getBoundingClientRect) { loadShipped(); return; }
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
@@ -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 `<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); } };
});
});
@@ -65,6 +65,9 @@
@@ -65,6 +65,9 @@
type: String(entry.type || 'page').slice(0, 32),
type: String(entry.type || 'page').slice(0, 32),
title: String(entry.title || (typeof document !== 'undefined' ? document.title : '') || url).slice(0, 200),
title: String(entry.title || (typeof document !== 'undefined' ? document.title : '') || url).slice(0, 200),
url,
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,
icon: entry.icon || ICONS[entry.type] || null,
service: entry.service || inf.service || null,
service: entry.service || inf.service || null,
sub: entry.sub || inf.sub || null,
sub: entry.sub || inf.sub || null,
new file mode 100644
new file mode 100644
@@ -0,0 +1,156 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);
@@ -73,6 +73,9 @@
@@ -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-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-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.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.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 .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; }
.openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0; opacity: .5; }
@@ -255,6 +258,9 @@
@@ -255,6 +258,9 @@
.openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; }
.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 { 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 {
.openvibe-navbar-links a {
padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500;
padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500;
color: var(--text-secondary, #b0b0b8); text-decoration: none;
color: var(--text-secondary, #b0b0b8); text-decoration: none;
@@ -312,6 +318,9 @@
@@ -312,6 +318,9 @@
/* A close mark that arrives already made (the Dock's sheet) forms from the button it came from. */
/* 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; }
.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; } }
@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-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; } }
@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); }
.openvibe-navbar .ovnav-orb[aria-expanded="true"] { color: var(--text-primary, #e6edf7); }
@@ -352,6 +361,16 @@
@@ -352,6 +361,16 @@
transition: filter .15s; text-decoration: none; display: inline-flex; align-items: center;
transition: filter .15s; text-decoration: none; display: inline-flex; align-items: center;
}
}
.openvibe-navbar-login:hover { filter: brightness(1.12); }
.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
/* 4.148.0 (the owner, 11-08: "the things need to be more consistent in the nav bar in size"): one height for
every control at the bar's right, 36 px (34 px on a phone under 400 px): Sign In, the orb, messages, the
every control at the bar's right, 36 px (34 px on a phone under 400 px): Sign In, the orb, messages, the
picture, the balances and a featured link. The orb draws its dots at 26 px (24) so it reads as large as
picture, the balances and a featured link. The orb draws its dots at 26 px (24) so it reads as large as
@@ -548,7 +567,7 @@
@@ -548,7 +567,7 @@
const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '04aa1cedb6b4', 'dock.js': '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
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -1019,16 +1038,19 @@
@@ -1019,16 +1038,19 @@
let _renderAbort = null;
let _renderAbort = null;
const onDocument = () => (_renderAbort ? { signal: _renderAbort.signal } : undefined);
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;
let _counted = false;
function countView() {
function countView() {
if (_counted || _config.countViews === false) return; _counted = true;
if (_counted || _config.countViews === false) return; _counted = true;
try {
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 { /* */ }
} catch { /* */ }
}
}
@@ -2096,14 +2118,14 @@
@@ -2096,14 +2118,14 @@
<div id="openvibe-bell-mount"></div>
<div id="openvibe-bell-mount"></div>
${links.length ? `<button type="button" class="ovnav-burger ovnav-orb" id="openvibe-burger" aria-label="Menu: this site, search and every OpenVibe site" aria-haspopup="dialog" aria-expanded="false">${orbSVG()}</button>` : ''}
${links.length ? `<button type="button" class="ovnav-burger ovnav-orb" id="openvibe-burger" aria-label="Menu: this site, search and every OpenVibe site" aria-haspopup="dialog" aria-expanded="false">${orbSVG()}</button>` : ''}
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
- `<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">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>
</div>
${links.length ? `<div class="ovnav-drawer" id="openvibe-drawer" role="menu">${drawerOrder(links).map(l => linkHTML(l, true)).join('')}<div class="ovnav-drawer-net">${networkLinksHTML(links, true)}</div>${u ? '' : '<div class="ovnav-display" hidden></div>'}</div>` : ''}
${links.length ? `<div class="ovnav-drawer" id="openvibe-drawer" role="menu">${drawerOrder(links).map(l => linkHTML(l, true)).join('')}<div class="ovnav-drawer-net">${networkLinksHTML(links, true)}</div>${u ? '' : '<div class="ovnav-display" hidden></div>'}</div>` : ''}
`;
`;
if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
bindLinks(nav, links);
bindLinks(nav, links);
hookSearch(nav);
hookSearch(nav);
- hookSwitcher(nav);
+ hookSwitcher(nav); hookPages(nav); hookSignIn(nav); hookFit(nav);
bindChips(nav);
bindChips(nav);
bindChipsAll(nav);
bindChipsAll(nav);
queueIcons();
queueIcons();
@@ -2334,6 +2356,47 @@
@@ -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 */ }
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(() => {});
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) {
function hookSwitcher(nav) {
const b = nav.querySelector('#openvibe-switch-btn');
const b = nav.querySelector('#openvibe-switch-btn');
if (!b) return;
if (!b) return;
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -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);
@@ -466,6 +466,8 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -466,6 +466,8 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
rail.addEventListener('scroll', ends, { passive: true });
rail.addEventListener('scroll', ends, { passive: true });
requestAnimationFrame(ends);
requestAnimationFrame(ends);
wrap.append(rail);
wrap.append(rail);
+ const all = h('a', 'ovh-link', 'View full history'); all.href = 'https://openvibe.my/my#history';
+ wrap.append(all);
return wrap;
return wrap;
}
}
@@ -56,6 +56,11 @@
@@ -56,6 +56,11 @@
@keyframes ov-upload-sheen { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
@keyframes ov-upload-sheen { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
@keyframes ov-upload-pulse { 50% { opacity: .5; } }
@keyframes ov-upload-pulse { 50% { opacity: .5; } }
@keyframes ov-upload-check { from { transform: scale(.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@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-draw { to { stroke-dashoffset: 0; } }
@keyframes ov-upload-in { from { opacity: 0; transform: translateY(6px); } }
@keyframes ov-upload-in { from { opacity: 0; transform: translateY(6px); } }
@keyframes ov-upload-bob { 50% { transform: translateY(-3px); } }
@keyframes ov-upload-bob { 50% { transform: translateY(-3px); } }
@@ -18,6 +18,30 @@
@@ -18,6 +18,30 @@
if (seconds < 3600) return 'About ' + Math.ceil(seconds / 60) + ' min left';
if (seconds < 3600) return 'About ' + Math.ceil(seconds / 60) + ' min left';
return 'About ' + Math.ceil(seconds / 3600) + ' hr 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.
// Seconds are smoothed by measured bytes per second, not the last network burst.
function rate(previous, deltaBytes, deltaSeconds, alpha) {
function rate(previous, deltaBytes, deltaSeconds, alpha) {
if (deltaBytes <= 0 || deltaSeconds <= 0) return previous || 0;
if (deltaBytes <= 0 || deltaSeconds <= 0) return previous || 0;
@@ -28,6 +52,12 @@
@@ -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_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_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>';
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) {
function node(tag, className, text) {
var el = document.createElement(tag);
var el = document.createElement(tag);
if (className) el.className = className;
if (className) el.className = className;
@@ -93,6 +123,25 @@
@@ -93,6 +123,25 @@
render();
render();
return { update: update, destroy: function () { if (timer) clearInterval(timer); } };
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) {
function picker(root, options) {
options = options || {};
options = options || {};
var input = node('input', 'ov-upload__input');
var input = node('input', 'ov-upload__input');
@@ -146,7 +195,7 @@
@@ -146,7 +195,7 @@
var done = items.reduce(function (n, item) { return n + item.done; }, 0);
var done = items.reduce(function (n, item) { return n + item.done; }, 0);
var state = items.some(function (item) { return item.state === 'sending'; }) ? 'sending'
var state = items.some(function (item) { return item.state === 'sending'; }) ? 'sending'
: items.some(function (item) { return item.state === 'failed'; }) ? 'failed'
: 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.' : '' });
total.update({ done: done, total: bytes, state: state, message: state === 'failed' ? 'A file needs a retry.' : '' });
totalRoot.hidden = items.length < 2;
totalRoot.hidden = items.length < 2;
}
}
@@ -154,18 +203,38 @@
@@ -154,18 +203,38 @@
while (active < (options.concurrency || 3)) {
while (active < (options.concurrency || 3)) {
var item = items.find(function (x) { return x.state === 'waiting'; });
var item = items.find(function (x) { return x.state === 'waiting'; });
if (!item) break;
if (!item) break;
- item.state = 'sending'; active++;
+ item.state = 'checking'; active++;
item.meter.update({ state: 'sending', done: item.done, total: item.file.size }); refresh();
item.meter.update({ state: 'sending', done: item.done, total: item.file.size }); refresh();
(function (entry) {
(function (entry) {
entry.controller = new AbortController();
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,
signal: entry.controller.signal,
+ keepBoth: !!entry.keepBoth,
+ sha256: entry.sha256 || null,
onProgress: function (value) {
onProgress: function (value) {
entry.done = Math.max(0, Math.min(entry.file.size, Number(value.uploadedBytes == null ? value.done : value.uploadedBytes) || 0));
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();
entry.meter.update({ state: 'sending', done: entry.done, total: entry.file.size }); refresh();
}
}
}); }).then(function (result) {
}); }).then(function (result) {
if (entry.removed) return;
if (entry.removed) return;
+ if (result && result.__duplicate) return;
entry.done = entry.file.size; entry.state = 'done';
entry.done = entry.file.size; entry.state = 'done';
entry.meter.update({ state: 'done', done: entry.done, total: entry.file.size });
entry.meter.update({ state: 'done', done: entry.done, total: entry.file.size });
if (options.onDone) options.onDone(entry.file, result);
if (options.onDone) options.onDone(entry.file, result);
@@ -177,6 +246,16 @@
@@ -177,6 +246,16 @@
})(item);
})(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, {
var pick = picker(pickRoot, {
label: options.label, accept: options.accept, multiple: options.multiple !== false,
label: options.label, accept: options.accept, multiple: options.multiple !== false,
onFile: function (file, row) {
onFile: function (file, row) {
@@ -197,5 +276,5 @@
@@ -197,5 +276,5 @@
pick.rows.before(totalRoot); // the total sits between the picker and the files
pick.rows.before(totalRoot); // the total sits between the picker and the files
return { picker: pick, total: total, items: items };
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 };
});
});
@@ -6,3 +6,31 @@
@@ -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}}
@media(max-width:600px){.ov-wallet{width:100%;max-width:100%;max-height:94dvh;margin:auto 0 0;border-radius:22px 22px 0 0;border-bottom:0}.ov-wallet-head{padding:18px 18px 14px}.ov-wallet-body{padding:16px 18px 28px;max-height:calc(94dvh - 94px);scrollbar-gutter:auto}.ov-wallet-eyebrow{font-size:12px;letter-spacing:1px}.ov-wallet-balances{grid-template-columns:1fr;gap:9px}.ov-wallet-balance{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;padding:12px 14px}.ov-wallet-value{grid-column:1;grid-row:2;font-size:26px}.ov-wallet-purpose{grid-column:1/-1;grid-row:3;font-size:12px}.ov-wallet-balance>.ov-wallet-button{grid-column:2;grid-row:1/3;align-self:center;max-width:132px}.ov-wallet-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.ov-wallet-preview{height:92px}.ov-wallet-item .ov-wallet-button{padding:9px 6px;font-size:12px}.ov-wallet-section{margin-top:24px}.ov-wallet-event{gap:8px}.ov-wallet-event>span{max-width:44%;text-align:right;overflow-wrap:anywhere}}
/* The close ring only for the keyboard (a tap that opens the sheet shows no ring), in the accent like every focus. */
/* The close ring only for the keyboard (a tap that opens the sheet shows no ring), in the accent like every focus. */
.ov-wallet-close:focus{outline:none}.ov-wallet-close:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
.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}
@@ -47,6 +47,7 @@
@@ -47,6 +47,7 @@
// make its saved state "hidden". Only a page without it needs the wallet's own lock.
// make its saved state "hidden". Only a page without it needs the wallet's own lock.
const ownLock = !root.OVSurfaces;
const ownLock = !root.OVSurfaces;
let disposed = false, data = null, allItems = null, equipped = {}, kinds = [], storeItems = [];
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 pending = new Map();
const session = { close() {
const session = { close() {
if (disposed) return; disposed = true; controller.abort(); dialog.close(); dialog.remove();
if (disposed) return; disposed = true; controller.abort(); dialog.close(); dialog.remove();
@@ -68,7 +69,14 @@
@@ -68,7 +69,14 @@
if (!r.ok) throw new Error(value.detail || 'This is temporarily unavailable. Please try again.');
if (!r.ok) throw new Error(value.detail || 'This is temporarily unavailable. Please try again.');
return value;
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) {
function button(label, action, cls) {
const b = el('button', cls || 'ov-wallet-button', label); b.type = 'button';
const b = el('button', cls || 'ov-wallet-button', label); b.type = 'button';
b.addEventListener('click', async () => {
b.addEventListener('click', async () => {
@@ -147,11 +155,26 @@
@@ -147,11 +155,26 @@
const balances = el('div', 'ov-wallet-balances'); body.append(balances);
const balances = el('div', 'ov-wallet-balances'); body.append(balances);
for (const id of ['coins', 'vibes', 'balance']) {
for (const id of ['coins', 'vibes', 'balance']) {
const b = data.balances[id], card = el('article', `ov-wallet-balance is-${id}`);
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); }
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); }
else { const action = button(b.action, () => checkout(id === 'vibes' ? 'vibes' : 'topup')); action.disabled = data.money_writes_frozen; card.append(action); }
balances.append(card);
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 store = section('Store', 'A little more you. Items travel with your OpenVibe account.');
const grid = el('div', 'ov-wallet-grid'); store.append(grid);
const grid = el('div', 'ov-wallet-grid'); store.append(grid);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.189.0",
+ "version": "4.190.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.189.0",
+ "version": "4.190.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.189.0",
+ "version": "4.190.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -50,6 +50,8 @@
@@ -50,6 +50,8 @@
"./dock": "./dock.js",
"./dock": "./dock.js",
"./presence": "./presence.js",
"./presence": "./presence.js",
"./purse": "./purse.js",
"./purse": "./purse.js",
+ "./drop": "./ov-drop.js",
+ "./machines": "./machines.js",
"./nav-slots": "./nav-slots.js",
"./nav-slots": "./nav-slots.js",
"./overlay": "./overlay.js",
"./overlay": "./overlay.js",
"./dock-vibe": "./dock-vibe.js",
"./dock-vibe": "./dock-vibe.js",
@@ -11,7 +11,10 @@
@@ -11,7 +11,10 @@
OpenVibePurse.mount(button) the pill, after each draw of the bar
OpenVibePurse.mount(button) the pill, after each draw of the bar
OpenVibePurse.set(id, value) a balance changed ('coins', '1,234')
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.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) {
(function (root) {
'use strict';
'use strict';
if (!root || !root.document || root.OpenVibePurse) return;
if (!root || !root.document || root.OpenVibePurse) return;
@@ -27,7 +30,7 @@
@@ -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>',
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 = `
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. */
/* 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="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)); }
[data-purse="vibes"] { --pc: color-mix(in oklab, #1fc78c 86%, var(--text-primary, #e6edf7)); }
@@ -44,6 +47,11 @@
@@ -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-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-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; }
.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 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 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))); } }
@keyframes ovpNote { from { transform: translateX(calc(-5px * var(--ov-move, 1))) rotate(calc(-12deg * var(--ov-move, 1))); } }
@@ -128,13 +136,14 @@
@@ -128,13 +136,14 @@
tour();
tour();
}
}
/** A few of the currency's pieces: into the pill from below on a gain, out of it and down on a spend. */
/** 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;
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) {
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]);
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 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 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 }]
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 @@
@@ -144,12 +153,13 @@
}
}
}
}
/** "+15" or "−200" under the pill: it rises into the pill on a gain and falls away on a spend. */
/** "+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;
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);
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()}`;
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 }]
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 }];
: [{ 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' });
const a = t.animate(frames, { duration: 1900, easing: 'ease-out', fill: 'both' });
@@ -166,11 +176,26 @@
@@ -166,11 +176,26 @@
later(() => { btn.classList.remove('is-gain', 'is-loss'); }, 1500);
later(() => { btn.classList.remove('is-gain', 'is-loss'); }, 1500);
later(() => { busy = false; show(home); idle(); }, 3400);
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 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);
root.requestAnimationFrame(step);
}
}
function set(id, value) {
function set(id, value) {
@@ -181,6 +206,6 @@
@@ -181,6 +206,6 @@
m.setAttribute('data-v', String(value));
m.setAttribute('data-v', String(value));
play(id, n - was); count(m.querySelector('.ovnav-chip-mv'), was, n);
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);
const now = doc.getElementById('openvibe-chips-all'); if (now) mount(now);
})(typeof window !== 'undefined' ? window : null);
})(typeof window !== 'undefined' ? window : null);
@@ -17,7 +17,9 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
@@ -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.
// 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',
'ov-wallet.js', 'ov-wallet.css',
// 4.189.0: the balances pill's icons and effects.
// 4.189.0: the balances pill's icons and effects.
- 'purse.js'];
+ 'purse.js',
+ // 4.191.0: the one dropdown (the site's name opens its pages).
+ 'ov-drop.js'];
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
@@ -153,6 +153,13 @@ function faviconLink(o) {
@@ -153,6 +153,13 @@ function faviconLink(o) {
// specificity, which any site's own rule beats.
// 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>';
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 = {}) {
function page(o = {}) {
const name = o.name || o.service || 'OpenVibe';
const name = o.name || o.service || 'OpenVibe';
const navbar = navbarOptions(o, name);
const navbar = navbarOptions(o, name);
@@ -185,8 +192,7 @@ function page(o = {}) {
@@ -185,8 +192,7 @@ function page(o = {}) {
'</head>',
'</head>',
`<body${attrs(bodyAttributes)}>`,
`<body${attrs(bodyAttributes)}>`,
summary ? summary.body : '',
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' }),
frame.footer(o.footer || { service: name, variant: 'compact' }),
'</body>',
'</body>',
'</html>',
'</html>',
@@ -104,7 +104,14 @@
@@ -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 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-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 .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-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}`,
`.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}}',
'@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 @@
@@ -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
// 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
// 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.
// 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) {
function areaChips(e, n) {
const areas = Array.isArray(e && e.areas) ? e.areas.filter((a) => typeof a === 'string' && a).slice(0, n) : [];
const areas = Array.isArray(e && e.areas) ? e.areas.filter((a) => typeof a === 'string' && a).slice(0, n) : [];
if (!areas.length) return null;
if (!areas.length) return null;
@@ -471,7 +478,7 @@
@@ -471,7 +478,7 @@
const loading = el('p', { class: 'ov-shipped-empty' }, 'Loading…');
const loading = el('p', { class: 'ov-shipped-empty' }, 'Loading…');
function drawKinds() {
function drawKinds() {
kinds.textContent = '';
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);
const b = el('button', { type: 'button', class: 'ov-shipped-chip', 'aria-pressed': String(kindNow === k) }, label);
b.addEventListener('click', () => {
b.addEventListener('click', () => {
kindNow = k;
kindNow = k;
@@ -72,6 +72,7 @@ function runOne(file, { dir, cwd, env, timeoutMs, nodeArgs }) {
@@ -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),
* 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),
* 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),
* 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).
* nodeArgs ([]), log (console.log).
*/
*/
async function run(opts = {}) {
async function run(opts = {}) {
@@ -97,16 +98,26 @@ async function run(opts = {}) {
@@ -97,16 +98,26 @@ async function run(opts = {}) {
const queue = [], serial = [];
const queue = [], serial = [];
for (const file of files) (opts.serial && opts.serial.test(file) ? serial : queue).push(file);
for (const file of files) (opts.serial && opts.serial.test(file) ? serial : queue).push(file);
const results = [];
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 () => {
await Promise.all(Array.from({ length: parallel }, async () => {
while (queue.length) {
while (queue.length) {
- const r = await runOne(queue.shift(), cfg);
+ const r = await runFile(queue.shift());
results.push(r);
results.push(r);
const mark = r.state === 'pass' ? '✓' : r.state === 'skip' ? '○' : '✗';
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('; ')}` : ''}`);
log(`${mark} ${r.file.padEnd(pad)} ${String(r.ms).padStart(6)}ms${r.state === 'skip' ? ` ${r.skips.join('; ')}` : ''}`);
}
}
}));
}));
for (const file of serial) {
for (const file of serial) {
- const r = await runOne(file, cfg);
+ const r = await runFile(file);
results.push(r);
results.push(r);
const mark = r.state === 'pass' ? '✓' : r.state === 'skip' ? '○' : '✗';
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('; ')}` : ''}`);
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 = {}) {
@@ -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'));
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 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, ${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}`);
log(`\n${summary}`);
const exitCode = failed.length || (strict && skipped.length) ? 1 : 0;
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. */
/** run(), then exit with its status: what a test/run.js calls. */
@@ -79,7 +79,7 @@ function load(host = 'openvibe.live') {
@@ -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, 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.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.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');
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');
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -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');
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
const ICONS = require('../nav-icons');
-const NAVBAR_BROTLI_BUDGET = 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 ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -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; });
@@ -16,6 +16,13 @@ assert.ok(burst > steady && burst < 20 * 1024 * 1024, 'one burst is smoothed');
@@ -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.rate(burst, 0, 1), burst, 'a stalled sample does not become a false zero rate');
assert.strictEqual(upload.timeLeft(Infinity), 'Estimating time left');
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'));
assert.ok(files.isBrowserFile('ov-upload.js') && files.isBrowserFile('ov-upload.css'));
const css = fs.readFileSync(path.join(__dirname, '../ov-upload.css'), 'utf8');
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');
assert.match(css, /html\[data-ov-motion=off\] \.ov-progress__check path\{stroke-dashoffset: 0\}/, 'with motion off the tick is simply drawn');
@@ -13,4 +13,6 @@
@@ -13,4 +13,6 @@
* a skipped file is listed with ○ and never counted as passed.
* a skipped file is listed with ○ and never counted as passed.
*/
*/
'use strict';
'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$/ });
@@ -124,4 +124,17 @@ assert.ok(withSummary.indexOf('</head>') < withSummary.indexOf('<noscript><secti
@@ -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.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');
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 $& 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');
console.log('shell: all checks passed');
@@ -167,7 +167,7 @@ const data = {
@@ -167,7 +167,7 @@ const data = {
const more = logEl.querySelector('.ov-shipped-more');
const more = logEl.querySelector('.ov-shipped-more');
assert.strictEqual(more.hidden, false);
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-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'));
more.dispatchEvent(new window.Event('click'));
await new Promise((r) => setTimeout(r, 20));
await new Promise((r) => setTimeout(r, 20));
assert.ok(calls.some((u) => u.includes('before=CUR') && u.includes('service=wiki')));
assert.ok(calls.some((u) => u.includes('before=CUR') && u.includes('service=wiki')));
@@ -64,9 +64,11 @@ const server = http.createServer((req, res) => {
@@ -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)); }
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');
assert.strictEqual(adopted, true, 'the sheet opens again and locks the page');
await new Promise((ok) => setTimeout(ok, 60));
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');
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("#one").click()');
await p.evaluate('document.querySelector("#two").click()');
await p.evaluate('document.querySelector("#two").click()');
@@ -60,6 +60,21 @@ const { run, skipsIn, resolveParallel } = require('../test-runner');
@@ -60,6 +60,21 @@ const { run, skipsIn, resolveParallel } = require('../test-runner');
assert.strictEqual(r.exitCode, 1);
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'),
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)']);
['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 });
fs.rmSync(dir, { recursive: true, force: true });
console.log('test-runner: all checks passed');
console.log('test-runner: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });