openvibers/openvibe.shared Commits

4.202.0: one dropdown, one window, user cards, eight groups, reveal (#456)

OP OpenVibers committed 2 days ago · fd1322825f · 68 files, +2358 −219

* The bottom of a phone measured once, and the page's end at 32/24 (plan 279, 280)

* bottom.js is exported, room.css carries the same tokens, navbar stays inside its budget

* One site list: the owner's eight groups in the footer, rooms, You folders and home pages (ask 274)

The footer's Network column reads the Frame's groups and its fallback links the same eight /sites sections; rooms.js HOODS and every room's hood use the same ids; the You tab grows one folder per group in the menu's order (Build keeps the saved build id, old folder ids are mapped); and More on OpenVibe leads to the next places in the site's own group, in group order, instead of a hand-picked cross-group list. Pins openvibe-contracts v0.400.0.

* Link Jump back in to private history

* One dropdown for a field: async sources, sections, Create on OpenVibe Git (348)

* Build it in Code: a Vibe chat hands its words to the Code part, and the switch follows (350)

* The phone menu shows each site's whole name, under its mark (Download, Community)

* The dropdown is exported (and the composer and the Code part beneath it)

* One window for every little interface: dialog, sheet, panel; the composer's settings open in it (304, 318)

* Add the shared public user card and opt-in mention hooks

* Use the kit section for user card content

* One user card, designed: banner from the picture, the live ring, chips, this week; it opens in the one window (287)

* Windows: Glass, Solid or Outline, a display setting saved with the look (304, 318)

* The wallet opens in the one window: one motion, one sheet on a phone, the window style (304)

* A pictures-only box keeps a long paste as text (it was turned into a file the box refused, and the words were lost)

* The reveal: a heading out of a blur, its rule drawn, six card steps (278)

* Test the reveal's data-fx-self and its Calm path on the scroll timeline

* 4.202.0: one field, one window, one user card, eight groups, reveal

* Add 4.202.0 screenshots and note Dock budget

* Update home screen test for the new group folder

* 4.202.0: an empty footer column is never drawn

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 333fe11..6623bfc 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,19 @@
# Changelog
++## 4.202.0 — 2026-11-11
++
++**One field dropdown, one window for small interfaces, one user card, a measured page end, eight site groups, and the reveal remade.**
++
++- **An empty footer column is never drawn (`footer.js`).** A group with no heading or no links is left out instead of
++ showing as an untitled "0 links" fold.
++- **One field dropdown (#447).** `ov-combo.js` adds async, grouped suggestions and optional creation; the composer handoff to Code and whole site names in the phone menu are included.
++- **One window (#451).** `ov-window.js` supplies dialog, sheet and panel modes, and the composer's settings use it.
++- **One user card (#452).** Shared mention hooks, the profile card, wallet window and display style settings.
++- **Page end (#442).** A shared bottom measurement keeps the end of a page at 32 px on computer and 24 px on phone.
++- **Eight site groups (#445).** The footer, rooms, You folders and home pages use the same groups.
++- **Reveal (#448).** Headings emerge from blur, rules draw, and up to six cards stagger as they enter view.
++- **Jump back in (#412).** The full history link and private history support are present from the 4.201.0 base.
++
## 4.201.0 — 2026-11-10
**Notifications move into the one menu; the mark and the name are one button; one composer and Code on every site.**
@@@ -93,7 +107,6 @@ From the owner's notes of 2026-11-10 (asks 334 to 350).
event arrives later and used to close a menu opened again in between. The menu now ignores the one step it took itself.
- Budget: `dock.js` 20.43 KB brotli (budget 20.45 KB, was 20.30 KB) for the "Open in Chat" icon in the always-loaded
Dock; the Vibe-only button rules stay in `dock-vibe.js`.
--
## 4.199.0 — 2026-11-10
**Every page downloads about 56 KB less: the shared files are served minified.** From the owner's notes of 2026-11-10:
ddiff --git a/README.md b/README.md
iindex 0ae388b..11f7823 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.201.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.202.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
@@@ -838,7 +838,7 @@ Menus are modular: every site keeps the shared account menu and adds its own
({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()
/ setLinks() do the same at runtime. Signed-in users also get a
"Recently used" row fed by the shared history module when it is loaded.
--Sign-in and history: history: { type: 'tool'|'stream'|'paste'|'page'|…,
++Sign-in and history: history: { type: 'tool'|'stream'|'video'|'clip'|'paste'|'thread'|'conversation'|'document'|'page'|…,
title, url, icon } is recorded for the signed-in user once auth resolves
(cross-site "Recently used" / History on the Network). silentLogin:
'https://site/auth/login?silent=1&next={url}' — when nobody is signed in
ddiff --git a/STATUS.json b/STATUS.json
iindex 6552b8d..bf4b100 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,13 +4,14 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.201.0",
++ "version": "4.202.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)",
"contracts": "openvibe-contracts v0.399.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.146.0",
"features": [
++ "the shared field dropdown, one dialog/sheet/panel window, user cards, eight site groups, measured page ends and remade reveal (4.202.0)",
"the page-view beacon sends ?in=1 only for a signed-in browser and counts a guest at once, so a signed-in person is counted from presence and never also as a guest (4.191.0)",
"the mark at work is the O as eight dots with three bright ones going round, and it has a state of its own for an agent that is thinking or writing (data-state=think); a phone's Dock opens and closes exactly as the menu does and wears its close button in its place; a button, field, select or text box that nobody styled wears the theme instead of the browser's grey (4.186.0)",
"the menu's You tab: your home right under your name (a 52px bar, was 68) and the AI card; Most used is the apps themselves under their bar, each a link and a full touch target; Jump back in last, saying what it is. A phone's Dock opens out of the navbar's menu button: its close orb starts on that orb and settles into its x while the navbar's orb makes the same move underneath, and closing runs it back (4.185.0)",
ddiff --git a/appbar.js b/appbar.js
iindex f8d912f..1781d16 100644
---- a/appbar.js
++++ b/appbar.js
@@@ -13,6 +13,10 @@
* dock and save bar sit on it), and it steps aside while the keyboard is up or a panel is open. A link's icon is a
* navbar icon name (fa-…), its dot a small red mark, the open page (its path, a path under it, or setActive's page)
* a filled pill. Clicks go through the link's onClick and the site's onNavigate, as the navbar's own links do.
++ *
++ * Plan 279: the bar does not sit on the layout viewport's bottom edge. bottom.js measures the visible viewport
++ * (--ovb-lift) and the bar moves up by it with `translate`, so the browser's toolbar sliding away or a keyboard
++ * never leaves the bar behind; its height is measured back for whatever docks above it (--ovb-bar, --ovb-dock).
*/
(function (root) {
'use strict';
@@@ -43,7 +47,7 @@
@media (max-width: 760px) {
html.ov-has-appbar { --ovnav-appbar-h: calc(58px + env(safe-area-inset-bottom, 0px)); }
html.ov-has-appbar body { padding-bottom: var(--ovnav-appbar-h); }
-- .ovnav-appbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 9990; display: flex; align-items: stretch; height: var(--ovnav-appbar-h); box-sizing: border-box; padding: 0 4px env(safe-area-inset-bottom, 0px);
++ .ovnav-appbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--ovb-z-bar, 9990); translate: 0 calc(-1 * var(--ovb-lift, 0px)); display: flex; align-items: stretch; height: var(--ovnav-appbar-h); box-sizing: border-box; padding: 0 4px env(safe-area-inset-bottom, 0px);
background: color-mix(in srgb, var(--bg-secondary, #121826) 90%, transparent); -webkit-backdrop-filter: saturate(1.5) blur(16px); backdrop-filter: saturate(1.5) blur(16px);
border-top: 1px solid var(--border, rgba(255,255,255,.1)); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; transition: transform .2s ease; }
.ovnav-appbar .ovnav-ab { all: unset; box-sizing: border-box; flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; padding: 6px 2px; cursor: pointer;
@@@ -140,6 +144,7 @@
document.body.appendChild(bar);
barEl = bar;
if (de && de.classList) de.classList.add('ov-has-appbar');
++ if (root.OpenVibeBottom) root.OpenVibeBottom.refresh();
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
ddiff --git a/bottom.js b/bottom.js
nnew file mode 100644
iindex 0000000..8080852
---- /dev/null
++++ b/bottom.js
@@@ -0,0 +1,142 @@
++/**
++ * openvibe-shared/bottom.js — the bottom of the screen, measured once for everything docked there.
++ *
++ * The owner, 2026-11-10 (plan 279): "That fixed bottom position is kind of buggy … as you scroll up and down,
++ * so let's make that nice and smooth and perfect across all sites." A phone's visible viewport is not its layout
++ * viewport: the browser's toolbar slides away, a keyboard opens, an orientation turns. A bar placed at bottom: 0
++ * of the layout viewport is then somewhere behind the thing the person is looking at. This file measures the one
++ * number every bottom-docked part needs and writes it where CSS can use it:
++ *
++ * --ovb-lift px between the bottom of the layout viewport and the bottom of the visible (visual) viewport;
++ * the bar, the small player, the Dock's pill and the composer move up by it with `translate`
++ * (a paint, never layout).
++ * --ovb-vh the visible viewport's height in px (for a surface that must fit it), measured, never 100dvh.
++ * --ovb-bar the app bar's height while it is on screen, measured from the bar itself (0 when it is away).
++ * --ovb-mini the small player's height plus its 8 px gap, while it is the full-width bar on a phone.
++ * --ovb-base max(--ovb-bar, safe-area-inset-bottom): where the first docked part starts.
++ * --ovb-dock --ovb-base + --ovb-mini: how far anything above all of them sits from the bottom edge.
++ *
++ * One stacking order, written here only (the bottom of the screen, low to high):
++ * 1. the app bar --ovb-z-bar 9990
++ * 2. the small player --ovb-z-mini 9991
++ * 3. the Dock's pill --ovb-z-pill 9995
++ * 4. the Dock's card --ovb-z-dock 9996 (a phone's Dock sheet is 10010: it covers the navbar)
++ * Above them belong sheets (10060), the notification panel (99999), toasts and notices (2147483000+).
++ *
++ * navbar.js loads this before the bar, the Dock and the player draw. It is safe without it: every rule that uses
++ * these variables has its old value as the fallback, so a page whose bottom.js did not arrive keeps the bar it had.
++ *
++ * OpenVibeBottom.measure() measure now (a page that changed its own bottom calls it)
++ * OpenVibeBottom.lift() the current lift in px
++ * OpenVibeBottom.height() the visible viewport's height in px
++ */
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || !document.documentElement || root.OpenVibeBottom) return;
++ const html = document.documentElement;
++ let raf = 0;
++ let observer = null;
++ let resize = null;
++ let watched = [];
++
++ function styles() {
++ if (document.getElementById('ovb-styles')) return;
++ const st = document.createElement('style');
++ st.id = 'ovb-styles';
++ st.textContent = `:root {
++ --ovb-lift: 0px;
++ --ovb-bar: 0px;
++ --ovb-mini: 0px;
++ --ovb-base: max(var(--ovb-bar, 0px), env(safe-area-inset-bottom, 0px));
++ --ovb-dock: calc(var(--ovb-base) + var(--ovb-mini, 0px));
++ --ovb-z-bar: 9990;
++ --ovb-z-mini: 9991;
++ --ovb-z-pill: 9995;
++ --ovb-z-dock: 9996;
++}
++.ovnav-appbar, .ovmini, .ovdk-pill { will-change: translate; }`;
++ (document.head || html).appendChild(st);
++ }
++
++ const px = (n) => `${Math.round(n * 100) / 100}px`;
++
++ /** The height of an element that is actually on screen (a bar hidden under the page is not). */
++ function onScreen(el) {
++ if (!el) return 0;
++ const cs = getComputedStyle(el);
++ if (cs.display === 'none' || cs.visibility === 'hidden') return 0;
++ const r = el.getBoundingClientRect();
++ if (!r.height || r.bottom <= 0 || r.top >= root.innerHeight) return 0;
++ return r.height;
++ }
++
++ function measure() {
++ raf = 0;
++ const vv = root.visualViewport;
++ const vh = vv ? vv.height : root.innerHeight;
++ const top = vv ? vv.offsetTop : 0;
++ const lift = Math.max(0, root.innerHeight - (top + vh));
++ html.style.setProperty('--ovb-lift', px(lift));
++ // A visible height of 0 happens between two documents; keep the last real one.
++ if (vh > 0) html.style.setProperty('--ovb-vh', px(vh));
++ const bar = onScreen(document.querySelector('.ovnav-appbar'));
++ html.style.setProperty('--ovb-bar', px(bar));
++ let mini = 0;
++ try {
++ if (root.matchMedia && root.matchMedia('(max-width: 640px)').matches) {
++ const m = onScreen(document.querySelector('.ovmini:not(.is-leaving)'));
++ if (m) mini = m + 8; // its 8 px above the bar
++ }
++ } catch { /* no matchMedia: no full-width small player to stack */ }
++ html.style.setProperty('--ovb-mini', px(mini));
++ }
++
++ const later = () => { if (!raf) raf = requestAnimationFrame(measure); };
++
++ /** Watch the parts that are added to the page after this file, and their size changes. */
++ function watch() {
++ const parts = [...document.querySelectorAll('.ovnav-appbar, .ovmini')];
++ if (resize) { resize.disconnect(); watched = []; }
++ else if (typeof ResizeObserver === 'function') resize = new ResizeObserver(later);
++ if (!resize) return;
++ for (const el of parts) { try { resize.observe(el); watched.push(el); } catch { /* */ } }
++ measure();
++ }
++
++ function start() {
++ styles();
++ const vv = root.visualViewport;
++ if (vv && vv.addEventListener) { vv.addEventListener('resize', later); vv.addEventListener('scroll', later); }
++ root.addEventListener('resize', later);
++ root.addEventListener('orientationchange', later);
++ root.addEventListener('load', later);
++ document.addEventListener('visibilitychange', later);
++ if (typeof MutationObserver === 'function' && document.body) {
++ observer = new MutationObserver((records) => {
++ let parts = false;
++ for (const r of records) {
++ if (r.target && r.target.closest && r.target.closest('.ovnav-appbar, .ovmini')) { parts = true; break; }
++ for (const n of [...r.addedNodes, ...r.removedNodes]) {
++ if (n.nodeType === 1 && (n.matches('.ovnav-appbar, .ovmini') || n.querySelector('.ovnav-appbar, .ovmini'))) { parts = true; break; }
++ }
++ if (parts) break;
++ }
++ if (parts) watch();
++ });
++ observer.observe(document.body, { childList: true, subtree: true });
++ }
++ watch();
++ setTimeout(measure, 300); // fonts, a late app bar, a small player opened by the page
++ }
++
++ root.OpenVibeBottom = {
++ measure,
++ refresh: later,
++ lift: () => { const n = parseFloat(html.style.getPropertyValue('--ovb-lift')); return Number.isFinite(n) ? n : 0; },
++ height: () => { const vv = root.visualViewport; return vv ? vv.height : root.innerHeight; },
++ _stop: () => { if (observer) observer.disconnect(); if (resize) resize.disconnect(); },
++ };
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
++ else start();
++ if (typeof module === 'object' && module.exports) module.exports = root.OpenVibeBottom;
++})(typeof globalThis !== 'undefined' ? globalThis : this);
ddiff --git a/dock-code.js b/dock-code.js
iindex ab13b35..b9c7ba3 100644
---- a/dock-code.js
++++ b/dock-code.js
@@@ -2,7 +2,8 @@
// OpenVibe — the Code part (openvibe-shared 4.201.0). Ask 350, the owner: openvibe.codes "should be better interconnected
// and shared … so that you can do chat, codes, and agents all in the same place and in one area". The menu's Vibe place
// is Chat · Code · Agents, and the Dock has the same Code tab. dock.js loads this file the first time it opens:
--// OpenVibeDockCode.mount(container, { token, frameBase, base, codes, active }) → { refresh(), focus(), destroy() }
++// OpenVibeDockCode.mount(container, { token, frameBase, base, codes, active, take }) → { refresh(), focus(), destroy() }
++// take() → the words another part handed over (Vibe's "Build it in Code"), which go in the box.
//
// One box to start a coding task, the way openvibe.codes starts one (Create: a new website, game or app; OpenVibe:
// a change to OpenVibe itself; My code: a repository of yours), and your latest tasks under it, running ones first,
@@@ -215,10 +216,13 @@
paint();
if (reachable && tasks && tasks.some((t) => /running|queued/.test(t.status)) && (!o.active || o.active())) timer = setTimeout(read, POLL_MS);
}
++ // What another part handed over (Vibe's Build it in Code) goes in the box, ready to start.
++ const take = () => { const t = typeof o.take === 'function' ? o.take() : ''; if (!t) return; text.value = t; text.dispatchEvent(new Event('input')); text.focus(); note.textContent = 'From your chat with Vibe. Start it, or change it first.'; };
paint();
read();
++ take();
return {
-- refresh: read,
++ refresh: () => { take(); read(); },
focus: () => text.focus(),
destroy() { gone = true; clearTimeout(timer); container.replaceChildren(); container.classList.remove('ovdc'); },
};
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex 980ad33..caabc43 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -206,7 +206,15 @@
} catch { err.textContent = 'That picture could not be attached. Try again.'; err.hidden = false; }
finally { attach.disabled = false; file.value = ''; }
});
-- form.append(attach, file);
++ // 350: Build it in Code. What you wrote, else Vibe's last answer, goes to the Code part as a new task's words.
++ const build = iconButton('button', 'ovdk-attach ovdk-build', 'M8 7l-5 5 5 5M16 7l5 5-5 5', 'Build it in Code');
++ build.addEventListener('click', () => {
++ const last = [...vibe.msgs].reverse().find((m) => m.from !== 'you');
++ const text = box.value.trim() || (last && last.text) || '';
++ if (!text) { box.focus(); return; }
++ build.dispatchEvent(new CustomEvent('ov-dock:part', { bubbles: true, detail: { part: 'code', text: text.slice(0, 16000) } }));
++ });
++ form.append(attach, file, build);
let mic = null;
if (TALK) {
mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));
ddiff --git a/dock.js b/dock.js
iindex 3699f8f..a2938f0 100644
---- a/dock.js
++++ b/dock.js
@@@ -89,7 +89,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': '4eb6d94db445', 'dock-code.js': '7cc3b2f4b7ea', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
++ const DOCK_REV = { 'dock-vibe.js': '1bfa19b01e8e', 'dock-code.js': '3dcbaca10bfb', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', '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);
@@@ -106,7 +106,7 @@
const st = document.createElement('style');
st.id = 'ovdk-styles';
st.textContent = `
-- .ovdk-pill { --t: var(--accent, #3b82f6); --bg: var(--bg-elevated, var(--bg-secondary, #252530)); position: fixed; z-index: 9995; right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 0px));
++ .ovdk-pill { --t: var(--accent, #3b82f6); --bg: var(--bg-elevated, var(--bg-secondary, #252530)); position: fixed; z-index: var(--ovb-z-pill, 9995); right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(16px + var(--ovb-dock, calc(max(var(--ovnav-appbar-h, 0px), env(safe-area-inset-bottom, 0px)) + var(--ovb-mini, 0px)))); translate: 0 calc(-1 * var(--ovb-lift, 0px));
display: inline-flex; align-items: center; gap: 9px; height: 46px; padding: 0 18px 0 9px; border-radius: 99px; border: 1px solid var(--border, #333340); background: var(--bg); color: var(--text-primary, #e0e0e0);
font: inherit; font-size: 13.5px; font-weight: 700; letter-spacing: -.005em; box-shadow: 0 14px 34px -14px rgba(0,0,0,.6); cursor: pointer; -webkit-tap-highlight-color: transparent; animation: ovdk-in .3s cubic-bezier(.2,.9,.25,1.2); }
.ovdk-pill[hidden] { display: none; }
@@@ -124,7 +124,7 @@
.ovdk-pill:has(.ovdk-t:empty) { padding-right: 8px; }
@keyframes ovdk-in { from { opacity: 0; transform: translateY(10px) scale(.9); } }
@keyframes ovdk-ping { to { transform: scale(2.6); opacity: 0; } }
-- .ovdk { --ovdk-gap: 16px; position: fixed; z-index: 9996; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;
++ .ovdk { --ovdk-gap: 16px; position: fixed; z-index: var(--ovb-z-dock, 9996); display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;
right: max(var(--ovdk-gap), env(safe-area-inset-right, 0px)); bottom: calc(max(var(--ovdk-gap), env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 0px));
width: ${DEF.width}px; height: ${DEF.height}px; max-width: calc(100vw - 32px); max-height: calc(100vh - 96px); max-height: calc(100dvh - 96px);
background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));
ddiff --git a/docs/bottom.md b/docs/bottom.md
nnew file mode 100644
iindex 0000000..3b0fc01
---- /dev/null
++++ b/docs/bottom.md
@@@ -0,0 +1,75 @@
++# The bottom of the screen (plan 279) and the page's end (plan 280)
++
++The owner, 2026-11-10:
++
++> "That fixed bottom position is kind of buggy … as you scroll up and down, so let's make that nice and smooth and
++> perfect across all sites."
++
++> "That huge wasted space … in between that and the footer … It's just waste."
++
++## One measured number
++
++A phone's visible viewport is not its layout viewport. The browser's toolbar slides away while the page scrolls, a
++keyboard opens, an orientation turns; a bar placed at `bottom: 0` of the layout viewport is then behind the very
++thing the person is looking at. `bottom.js` measures the difference and writes it where CSS can use it:
++
++| variable | meaning |
++|---|---|
++| `--ovb-lift` | px between the layout viewport's bottom and the visible (visual) viewport's bottom; every bottom-docked part moves up by it with `translate` (a paint, never layout) |
++| `--ovb-vh` | the visible viewport's height in px, measured — never a `100vh`/`100dvh` guess |
++| `--ovb-bar` | the app bar's height while it is on screen (0 when it is away) |
++| `--ovb-mini` | the small player's height plus its 8 px gap while it is the full-width bar on a phone |
++| `--ovb-base` | `max(--ovb-bar, env(safe-area-inset-bottom))`: where the first docked part starts |
++| `--ovb-dock` | `--ovb-base + --ovb-mini`: how far anything above all of them sits from the bottom edge |
++
++`navbar.js` loads `bottom.js` on every page before the bar, the Dock and the small player draw. Every rule that uses
++these variables keeps its old value as the fallback, so a page that did not get `bottom.js` keeps the bar it had.
++
++The parts move with `translate: 0 calc(-1 * var(--ovb-lift, 0px))`, never by changing `bottom` or `height` while the
++person scrolls; `--ovb-bar`/`--ovb-mini` are measured from the elements themselves by a `ResizeObserver`, so a bar
++that steps aside for the keyboard or a player that changes shape is followed without a guess.
++
++## One stacking order
++
++The bottom of the screen, low to high (these are the only z-indexes that belong to it; all of them live in
++`bottom.js`):
++
++| layer | class | z-index |
++|---|---|---|
++| the app bar | `.ovnav-appbar` | `--ovb-z-bar` (9990) |
++| the small player | `.ovmini` | `--ovb-z-mini` (9991) |
++| the Dock's pill | `.ovdk-pill` | `--ovb-z-pill` (9995) |
++| the Dock's card | `.ovdk` | `--ovb-z-dock` (9996) |
++
++A phone's Dock sheet (10010) covers the navbar; sheets (10060), the notification panel (99999) and toasts/notices
++(2147483000+) sit above all of these and do not belong to the bottom stack.
++
++## The page's end
++
++Content ends, then **32 px on a computer (24 on a phone)**, then the footer. That gap is the footer's own
++`margin-top` (`footer.js`); a site may not add its own bottom padding above it. The last block's bottom margin
++collapses with the footer's, so it does not add — but a container's `padding-bottom` does, and is removed in the
++site that has it.
++
++`scripts/audit-page-end.js` measures the gap on every site's home at 390 and 1280 and lists every page over the rule:
++
++```
++node scripts/audit-page-end.js
++node scripts/audit-page-end.js --footer-rule # with the Shared rule applied (what is left for the sites)
++node scripts/audit-page-end.js --hosts openvibe.wiki --width 390
++```
++
++## The check
++
++`test/bottom-chrome.test.js` drives a real headless Chrome at 360×740, 390×844 and 430×932. It scrolls up and down
++while a shimmed `visualViewport` opens and closes the toolbar (the layout viewport stays put, as a real toolbar's
++update reaches the visible viewport first), and fails when:
++
++- the app bar's bottom edge is more than 1 px from the visible viewport's bottom;
++- the small player is not 8 px above the bar, or the Dock's pill not 16 px above the small player;
++- any of the three overlaps another;
++- a gap jumps by more than 1 px between samples.
++
++```
++ov test test/bottom-chrome.test.js
++```
ddiff --git a/docs/ov-user.md b/docs/ov-user.md
nnew file mode 100644
iindex 0000000..03c2b87
---- /dev/null
++++ b/docs/ov-user.md
@@@ -0,0 +1,38 @@
++# One user card
++
++Load `/shared/ov-user.js` with the site's existing kit stylesheet. Loading the script has no side effects. The owner must enable Network's `user_card_enabled` setting before sites opt in.
++
++```js
++const mounted = OVUser.card(document.getElementById('person'), { username: 'Example' });
++await mounted.ready; // public summary, or null when unavailable
++await mounted.refresh();
++mounted.destroy();
++
++// Opt-in delegation, including dynamically added mentions and avatars:
++const unbind = OVUser.bind(document);
++// unbind() removes these hooks and closes its card.
++```
++
++```html
++<a href="https://openvibe.network/@Example">@Example</a>
++<button type="button" data-ov-user="Example" aria-label="Open Example's user card"><img src="/picture.png" alt=""></button>
++<a href="https://openvibe.network/@Example" data-ov-user="Example">Example</a>
++```
++
++The hook handles clicks, context menus and keyboard activation on marked elements (`data-ov-user`, or the existing `data-username`) and canonical `/@username` links on the current site, Network or Live. Use links/buttons for keyboard access; custom elements need `role="button"` and `tabindex="0"`. Modified clicks keep their native behavior. Links within the card keep navigating normally. Rebinding the same root replaces its old listeners. The default opening is a plain native dialog with Close/Escape and focus restoration; browsers without modal dialogs retain normal link navigation.
++
++The main session can supply its own opening surface without changing the read:
++
++```js
++OVUser.bind(document, { onOpen({ username, trigger }) {
++ // Open the designed surface, then mount OVUser.card(surfaceHost, { username }).
++} });
++```
++
++`card` fetches only `GET https://openvibe.network/api/v1/people/:username/card`, without credentials or browser caching, with an eight-second deadline. Optional `networkUrl` and `fetchImpl` configure a deployment/test read. Network coalesces and caches the aggregate for 30 seconds while rechecking privacy on each call. The component adds no cache. Remount/refresh/destroy abort prior reads and ignore late results. Disabled/unavailable reads show a profile link; missing fields never become zero, offline, or placeholders. Text is text, links/pictures must be HTTPS, and only kit classes/plain markup are used.
++
++The card shows the available name, handle, picture, live state, level, streak, badges count, followers, weekly sites and profile/channel links. Weekly sites are opt-in at Network. This hook does not automatically replace existing site menus or invoke relationship/moderation actions.
++
++## Next for the design (For Fable)
++
++Design the shared card surface and its placement, avatar/live treatment, hierarchy and responsive behavior; add the person's field-visibility controls and explicit weekly-sites opt-in. Adopt the hook in site mentions/avatars and compose site-specific actions using the existing menu registration API. This PR intentionally has no version bump or new CSS.
ddiff --git a/docs/screenshots/4.202.0/README.md b/docs/screenshots/4.202.0/README.md
nnew file mode 100644
iindex 0000000..13fdc68
---- /dev/null
++++ b/docs/screenshots/4.202.0/README.md
@@@ -0,0 +1,12 @@
++# OpenVibe.Shared 4.202.0 screenshots
++
++Captured from the branch's browser components in headless Chrome at 390 × 844 and 1280 × 900. The previews keep the component in context; the repository dropdown, eight-group footer, page end, Jump back in, composer settings, floating window and mention card use the branch's actual shared code.
++
++- `repository-field-{390,1280}.png` — typed repository search with grouped suggestions open.
++- `footer-eight-groups-{390,1280}.png` — the eight site groups in the footer.
++- `page-end-{390,1280}.png` — the bottom of a page on phone and computer.
++- `jump-back-in-{390,1280}.png` — recent destinations and the full-history link.
++- `composer-settings-{390,1280}.png` — settings as a phone sheet and a computer dialog.
++- `floating-panel-{390,1280}.png` — phone sheet and computer floating panel.
++- `mention-user-card-{390,1280}.png` — the card opened from a mention as a phone sheet and computer panel.
++- `reveal-entering-{390,1280}.png`, `reveal-settled-{390,1280}.png` — two frames of the remade reveal.
ddiff --git a/docs/screenshots/4.202.0/composer-settings-1280.png b/docs/screenshots/4.202.0/composer-settings-1280.png
nnew file mode 100644
iindex 0000000..ceccd1e
BBinary files /dev/null and b/docs/screenshots/4.202.0/composer-settings-1280.png differ
ddiff --git a/docs/screenshots/4.202.0/composer-settings-390.png b/docs/screenshots/4.202.0/composer-settings-390.png
nnew file mode 100644
iindex 0000000..6c0c899
BBinary files /dev/null and b/docs/screenshots/4.202.0/composer-settings-390.png differ
ddiff --git a/docs/screenshots/4.202.0/floating-panel-1280.png b/docs/screenshots/4.202.0/floating-panel-1280.png
nnew file mode 100644
iindex 0000000..b9246b0
BBinary files /dev/null and b/docs/screenshots/4.202.0/floating-panel-1280.png differ
ddiff --git a/docs/screenshots/4.202.0/floating-panel-390.png b/docs/screenshots/4.202.0/floating-panel-390.png
nnew file mode 100644
iindex 0000000..b0a6d58
BBinary files /dev/null and b/docs/screenshots/4.202.0/floating-panel-390.png differ
ddiff --git a/docs/screenshots/4.202.0/footer-eight-groups-1280.png b/docs/screenshots/4.202.0/footer-eight-groups-1280.png
nnew file mode 100644
iindex 0000000..a51778d
BBinary files /dev/null and b/docs/screenshots/4.202.0/footer-eight-groups-1280.png differ
ddiff --git a/docs/screenshots/4.202.0/footer-eight-groups-390.png b/docs/screenshots/4.202.0/footer-eight-groups-390.png
nnew file mode 100644
iindex 0000000..6b2f3c9
BBinary files /dev/null and b/docs/screenshots/4.202.0/footer-eight-groups-390.png differ
ddiff --git a/docs/screenshots/4.202.0/jump-back-in-1280.png b/docs/screenshots/4.202.0/jump-back-in-1280.png
nnew file mode 100644
iindex 0000000..8bca5ec
BBinary files /dev/null and b/docs/screenshots/4.202.0/jump-back-in-1280.png differ
ddiff --git a/docs/screenshots/4.202.0/jump-back-in-390.png b/docs/screenshots/4.202.0/jump-back-in-390.png
nnew file mode 100644
iindex 0000000..1044e63
BBinary files /dev/null and b/docs/screenshots/4.202.0/jump-back-in-390.png differ
ddiff --git a/docs/screenshots/4.202.0/mention-user-card-1280.png b/docs/screenshots/4.202.0/mention-user-card-1280.png
nnew file mode 100644
iindex 0000000..943d250
BBinary files /dev/null and b/docs/screenshots/4.202.0/mention-user-card-1280.png differ
ddiff --git a/docs/screenshots/4.202.0/mention-user-card-390.png b/docs/screenshots/4.202.0/mention-user-card-390.png
nnew file mode 100644
iindex 0000000..9634683
BBinary files /dev/null and b/docs/screenshots/4.202.0/mention-user-card-390.png differ
ddiff --git a/docs/screenshots/4.202.0/page-end-1280.png b/docs/screenshots/4.202.0/page-end-1280.png
nnew file mode 100644
iindex 0000000..5f64654
BBinary files /dev/null and b/docs/screenshots/4.202.0/page-end-1280.png differ
ddiff --git a/docs/screenshots/4.202.0/page-end-390.png b/docs/screenshots/4.202.0/page-end-390.png
nnew file mode 100644
iindex 0000000..c54d413
BBinary files /dev/null and b/docs/screenshots/4.202.0/page-end-390.png differ
ddiff --git a/docs/screenshots/4.202.0/repository-field-1280.png b/docs/screenshots/4.202.0/repository-field-1280.png
nnew file mode 100644
iindex 0000000..bd81228
BBinary files /dev/null and b/docs/screenshots/4.202.0/repository-field-1280.png differ
ddiff --git a/docs/screenshots/4.202.0/repository-field-390.png b/docs/screenshots/4.202.0/repository-field-390.png
nnew file mode 100644
iindex 0000000..e07e9b8
BBinary files /dev/null and b/docs/screenshots/4.202.0/repository-field-390.png differ
ddiff --git a/docs/screenshots/4.202.0/reveal-entering-1280.png b/docs/screenshots/4.202.0/reveal-entering-1280.png
nnew file mode 100644
iindex 0000000..a130708
BBinary files /dev/null and b/docs/screenshots/4.202.0/reveal-entering-1280.png differ
ddiff --git a/docs/screenshots/4.202.0/reveal-entering-390.png b/docs/screenshots/4.202.0/reveal-entering-390.png
nnew file mode 100644
iindex 0000000..8b6bfa2
BBinary files /dev/null and b/docs/screenshots/4.202.0/reveal-entering-390.png differ
ddiff --git a/docs/screenshots/4.202.0/reveal-settled-1280.png b/docs/screenshots/4.202.0/reveal-settled-1280.png
nnew file mode 100644
iindex 0000000..a130708
BBinary files /dev/null and b/docs/screenshots/4.202.0/reveal-settled-1280.png differ
ddiff --git a/docs/screenshots/4.202.0/reveal-settled-390.png b/docs/screenshots/4.202.0/reveal-settled-390.png
nnew file mode 100644
iindex 0000000..8b6bfa2
BBinary files /dev/null and b/docs/screenshots/4.202.0/reveal-settled-390.png differ
ddiff --git a/files.js b/files.js
iindex 34f0d72..abc928d 100644
---- a/files.js
++++ b/files.js
@@@ -38,6 +38,7 @@ const BROWSER = Object.freeze([
'dock-timeline.js', // the Dock's one timeline (A6); dock.js loads it with the Agent panel
'account-switcher.js',
'user-card.js',
++ 'ov-user.js', // public user-card read and opt-in mention/avatar hook; plain kit markup
'ov-mark.js', // the OV brand mark drop-in
'ov-glyphs.js', // the network's own icons in place of Font Awesome's (built from glyphs.js; navbar.js loads it)
'brands.js', // brand logos (Simple Icons, CC0), fetched by ov-glyphs.js the first time a page shows one (4.72.0)
@@@ -53,7 +54,9 @@ const BROWSER = Object.freeze([
'ov-upload.js', // shared file picker, upload queue and ov-progress
'messages.js', // Chat's two views: room lines and texting bubbles (openvibe-shared/messages)
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
-- 'ov-composer.js', // the one composer (4.201.0): files, / and @, voice, drafts, your settings; ov-thread.js loads it
++ 'ov-composer.js', // shared composer
++ 'ov-combo.js', // shared field dropdown
++ 'ov-window.js', // shared dialog, sheet, and panel
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'ov-home.js', // the menu's home screen and wall (search.js loads it for You and Feed)
'history.js', // cross-site history
@@@ -75,6 +78,7 @@ const BROWSER = Object.freeze([
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand
'web-runtime.js', // feature loader and route lifecycle (OVWebRuntime), from Live's ov-loader
++ 'bottom.js', // the bottom of the screen measured once (plan 279): --ovb-lift for the app bar, the small player and the Dock
'appbar.js', // the phone app bar; navbar.js loads it only for a site that sets appBar
'kit.js', // the OpenVibe Kit's builders in the page (for pages that draw themselves; with kit-web.js)
'kit-web.js', // the OpenVibe Kit's behaviour (copy, menus, Enter to send, confirms, sheets): all a server-rendered site loads
ddiff --git a/footer.js b/footer.js
iindex 68076b4..afedd71 100644
---- a/footer.js
++++ b/footer.js
@@@ -33,6 +33,11 @@
* Signed-in visitors get a cross-site row: their account, their channel, themes and the tools
* hub, so the network behaves like one product instead of a ring of separate sites. It reads
* the shared ov_token the same way navbar.js does and never blocks rendering on it.
++ *
++ * The page's end (plan 280, the owner: "That huge wasted space … in between that and the footer … It's just waste"):
++ * content ends, then 32 px on a computer (24 on a phone), then the footer — the footer's own margin-top is that
++ * number and nothing else may add to it. A site's bottom padding above the footer is its own dead space and is
++ * removed in that site (scripts/audit-page-end.js measures it on every home).
*/
(function (root, factory) {
const api = factory();
@@@ -85,24 +90,23 @@
const REPORT_URL = 'https://openvibe.help/tickets/new';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
-- // One place that knows what the network contains. Adding a property here gives every site
-- // the link, with no edit anywhere else.
-- const NETWORK = [
-- { id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },
-- { id: 'tools', name: 'Online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },
-- { id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },
-- { id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },
-- { id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },
-- { id: 'audio', name: 'Audio converter', url: 'https://audio.openvibe.tools', desc: 'Convert, trim, pitch-shift' },
-- { id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },
-- { id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },
-- { id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },
-- { id: 'community', name: 'Community & pastes', url: 'https://openvibe.community', desc: 'Pastes, posts and the people of OpenVibe' },
-- { id: 'games', name: 'Games', url: 'https://openvibe.games', desc: 'Scraplandia and browser games' },
-- { id: 'media', name: 'Media', url: 'https://openvibe.media', desc: 'VODs, clips and files for every site' },
-- { id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },
-- { id: 'more', name: 'The whole network →', url: NETWORK_URL + '/#network', desc: 'Every OpenVibe site, live and upcoming' },
++ // The eight groups, in the menu's order (OpenVibe.Network frame/sites.js GROUPS, the owner's regrouping of
++ // 2026-11-10). The Network column links each group's section on /sites; the Frame's `groups` replaces these names
++ // and order when it answers, so the footer, the launcher and the home pages read the same list.
++ const GROUP_LINKS = [
++ { id: 'watch', name: 'Watch & stream' },
++ { id: 'talk', name: 'Talk & belong' },
++ { id: 'play', name: 'Play' },
++ { id: 'read', name: 'Read & ask' },
++ { id: 'shop', name: 'Shop & money' },
++ { id: 'tools', name: 'Free tools' },
++ { id: 'make', name: 'Make with AI' },
++ { id: 'build', name: 'For developers' },
];
++ const groupHref = (id) => `${NETWORK_URL}/sites#group-${encodeURIComponent(id)}`;
++ const NETWORK = GROUP_LINKS.map(({ id, name }) => ({ id, name, url: groupHref(id), desc: '' }));
++ // Tool subdomains have no site of their own; brandFor names them "<Sub>.OpenVibe.Tools".
++ const TOOL_FAMILIES = new Set(['dev', 'net', 'img', 'audio', 'docs', 'text', 'yt']);
const LEGAL = [
{ key: 'terms', name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },
@@@ -189,9 +193,8 @@
if (m) { const sub = m[1] && m[1] !== 'www' ? m[1] : null; return [sub ? (sub.length <= 4 ? sub.toUpperCase() : cap(sub)) : null, 'OpenVibe', TLD_LABELS[m[2]] || cap(m[2])].filter(Boolean).join('.'); }
if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRestream';
if ((m = h.match(/^(?:(.+)\.)?open\.([a-z]+)$/))) return `Open.${TLD_LABELS[m[2]] || cap(m[2])}`;
-- const found = NETWORK.find(n => n.id === service);
if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`;
-- if (found && found.name !== 'Account & themes' && found.name !== 'The whole network →') return `${cap(service)}.OpenVibe.Tools`;
++ if (TOOL_FAMILIES.has(service)) return `${cap(service)}.OpenVibe.Tools`;
return 'OpenVibe';
}
@@@ -265,13 +268,18 @@
const service = c.service || detectService();
const brand = c.brandName || brandFor(service);
const frame = c._frame = OVFrame.get(() => { try { render(); } catch { /* */ } });
-- // Most used sites first when the Frame service has spoken; the built-in list otherwise.
-- const network = frame && frame.nav.length
-- ? frame.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname)).map(n => ({ name: 'OpenVibe.' + n.name, url: n.url, desc: n.tagline }))
-- : NETWORK.filter(n => n.id !== service);
++ // The Frame's own groups, in its order, when it answers; the built-in eight otherwise. One column of groups,
++ // not a head-list of sites, so the footer shows the same neighbourhoods as the launcher and the home pages.
++ const network = frame && Array.isArray(frame.groups) && frame.groups.length
++ ? frame.groups.map(g => ({ name: g.name, url: groupHref(g.id), desc: '' }))
++ : NETWORK;
++ // The ranked sites are still what the discover picks must not repeat.
++ const nav = frame && frame.nav.length
++ ? frame.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname))
++ : [];
const tagline = (cfg && cfg.tagline) || (frame && frame.footer.blurb) || c.tagline;
const popular = frame ? frame.footer.popular : [];
-- const discover = frame ? frame.footer.discover.filter(d => !network.slice(0, 8).some(n => n.url === d.url)) : [];
++ const discover = frame ? frame.footer.discover.filter(d => !nav.slice(0, 8).some(n => n.url === d.url)) : [];
const year = new Date().getFullYear();
if (c.variant === 'compact') {
@@@ -300,7 +308,10 @@
const fold = (label, heading, body, count) => `
<nav class="ovf-col" aria-label="${esc(label)}"><details class="ovf-fold" open><summary><h3>${esc(heading)}</h3><span class="ovf-fold-n">${esc(String(count))} ${count === 1 ? 'link' : 'links'}</span></summary>
<div class="ovf-fold-body">${body}</div></details></nav>`;
-- const cols = (c.links || []).map(group => fold(group.heading, group.heading, group.items.map(linkTag).join(''), group.items.length)).join('');
++ // A column needs a name and at least one link: an untitled "0 links" fold is never drawn (4.202.0's screenshots
++ // showed two when a site passed empty groups).
++ const cols = (c.links || []).filter(group => group && String(group.heading || '').trim() && Array.isArray(group.items) && group.items.length)
++ .map(group => fold(group.heading, group.heading, group.items.map(linkTag).join(''), group.items.length)).join('');
const networkLinks = network.slice(0, 8);
return `<div class="ovf-inner${compact ? ' ovf-inner--expanded' : ''}">
@@@ -316,7 +327,7 @@
</div>
${cols}
${c.showNetwork ? fold('The OpenVibe network', 'Network', `${networkLinks.map(linkTag).join('')}
-- <a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else ${icon('arrow')}</a>`, networkLinks.length + 1) : ''}
++ <a class="ovf-more" href="${NETWORK_URL}/sites" target="_blank" rel="noopener">Everything else ${icon('arrow')}</a>`, networkLinks.length + 1) : ''}
${popular.length ? fold('Popular tools', 'Popular tools', `${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}
<a class="ovf-more" href="https://openvibe.tools/all-tools">All tools ${icon('arrow')}</a>`, Math.min(popular.length, 6) + Math.min(discover.length, 2) + 1) : ''}
${fold('Legal', 'Legal', `${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
@@@ -382,9 +393,9 @@
// ── Styles (injected once) ─────────────────────────────────
const CSS = `
--.ovf{background:var(--bg-secondary,#12131c);border-top:1px solid var(--border,rgba(255,255,255,.08));margin-top:40px;font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif);
++.ovf{background:var(--bg-secondary,#12131c);border-top:1px solid var(--border,rgba(255,255,255,.08));margin-top:32px;font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif);
padding:clamp(24px,4vw,40px) clamp(14px,4vw,32px) calc(clamp(20px,3vw,28px) + env(safe-area-inset-bottom,0px));}
--.ovf[data-variant="compact"]{margin-top:24px;padding:14px clamp(12px,3vw,24px) calc(14px + env(safe-area-inset-bottom,0px));}
++.ovf[data-variant="compact"]{margin-top:32px;padding:14px clamp(12px,3vw,24px) calc(14px + env(safe-area-inset-bottom,0px));}
.ovf-inner{max-width:1240px;margin:0 auto}
.ovf-inner--compact{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 18px;text-align:center}
.ovf-cols{display:grid;gap:clamp(20px,3vw,36px);grid-template-columns:minmax(230px,1.6fr) repeat(auto-fit,minmax(120px,1fr));align-items:start}
@@@ -491,6 +502,7 @@ a.ovf-tile:active{transform:scale(.96)}
@keyframes ovfLive{0%{box-shadow:0 0 0 0 rgba(239,68,68,.55)}70%{box-shadow:0 0 0 7px rgba(239,68,68,0)}100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}}
.ovf-pulse-quiet{font-weight:500!important;color:var(--text-secondary,#b6bdd2)!important}
@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}
++@media (max-width:760px){.ovf,.ovf[data-variant="compact"]{margin-top:24px}}
@media (max-width:560px){
.ovf{padding-bottom:calc(20px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}
.ovf[data-variant="compact"]{padding-bottom:calc(14px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}
ddiff --git a/kit.css b/kit.css
iindex b47de36..0004d55 100644
---- a/kit.css
++++ b/kit.css
@@@ -51,7 +51,7 @@
--ovk-radius-sm: var(--ov-radius-1);
--ovk-tap: 44px;
--ovk-top: var(--ovnav-real-h, 52px); /* the navbar's height: sticky things stop under it */
-- --ovk-bottom: var(--ovnav-appbar-h, 0px); /* the app bar's height on phones (navbar.js sets it) */
++ --ovk-bottom: var(--ovb-dock, var(--ovnav-appbar-h, 0px)); /* the app bar (and a small player above it) on phones, measured by bottom.js */
--ovk-font: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
}
@@@ -248,7 +248,7 @@ div.ovk-set-row { cursor: default; }
.ovk-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.ovk-chip span { display: inline-flex; align-items: center; min-height: 32px; box-sizing: border-box; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--ovk-line); color: var(--ovk-muted); font-size: .86rem; font-weight: 650; cursor: pointer; }
.ovk-chip input:checked + span { border-color: color-mix(in srgb, var(--ovk-accent) 55%, transparent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); color: var(--ovk-text); }
--.ovk-save { position: sticky; bottom: var(--ovk-bottom); z-index: 30; margin: 0; padding: 10px 0 calc(10px + env(safe-area-inset-bottom, 0px)); background: linear-gradient(transparent, var(--ovk-bg) 35%); }
++.ovk-save { position: sticky; bottom: var(--ovk-bottom); translate: 0 calc(-1 * var(--ovb-lift, 0px)); z-index: 30; margin: 0; padding: 10px 0 calc(10px + env(safe-area-inset-bottom, 0px)); background: linear-gradient(transparent, var(--ovk-bg) 35%); }
.ovk-save .ovk-btn { width: 100%; }
.ovk-form { display: grid; gap: 12px; margin: 0; }
.ovk-form label { display: grid; gap: 5px; color: var(--ovk-muted); font-size: .9rem; }
@@@ -272,7 +272,7 @@ div.ovk-set-row { cursor: default; }
.ovk-go { display: inline-grid; place-items: center; flex: none; width: 38px; height: 38px; padding: 0; border: 0; border-radius: 50%; background: var(--ovk-fill); color: var(--ovk-on-fill); cursor: pointer; }
.ovk-go .ovk-i { width: 18px; height: 18px; stroke-width: 2.4; }
.ovk-go--send { width: var(--ovk-tap); height: var(--ovk-tap); }
--.ovk-dock { position: sticky; bottom: var(--ovk-bottom); z-index: 30; display: flex; align-items: flex-end; gap: 6px; margin: 8px -16px 0; padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px)); background: color-mix(in srgb, var(--ovk-bg) 92%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }
++.ovk-dock { position: sticky; bottom: var(--ovk-bottom); translate: 0 calc(-1 * var(--ovb-lift, 0px)); z-index: 30; display: flex; align-items: flex-end; gap: 6px; margin: 8px -16px 0; padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px)); background: color-mix(in srgb, var(--ovk-bg) 92%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }
.ovk-compose { flex: 1; min-width: 0; display: flex; align-items: flex-end; gap: 8px; margin: 0; }
.ovk-compose textarea { flex: 1; min-width: 0; min-height: var(--ovk-tap); max-height: 40vh; box-sizing: border-box; margin: 0; padding: 10px 16px; border-radius: 22px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 1rem; line-height: 1.4; resize: none; field-sizing: content; }
.ovk-compose textarea:focus-visible { outline: none; border-color: var(--ovk-accent); }
ddiff --git a/mini.js b/mini.js
iindex 9a4d58c..031daf0 100644
---- a/mini.js
++++ b/mini.js
@@@ -58,7 +58,7 @@
const st = document.createElement('style');
st.id = 'ov-mini-style';
st.textContent = `
--.ovmini { --mc: var(--accent, #3b82f6); box-sizing: border-box; position: fixed; z-index: 9990; left: max(16px, env(safe-area-inset-left, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 0px)); width: 340px; max-width: calc(100vw - 32px); display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; column-gap: 2px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-elevated, var(--bg-card, #101828)); color: var(--text-primary, #e6edf7); box-shadow: 0 18px 44px -18px rgba(0, 0, 0, .7), 0 2px 8px rgba(0, 0, 0, .25); overflow: hidden; font: 500 .88rem/1.3 var(--font-sans, system-ui, sans-serif); animation: ovmini-in .28s cubic-bezier(.2, .7, .2, 1) both; }
++.ovmini { --mc: var(--accent, #3b82f6); box-sizing: border-box; position: fixed; z-index: var(--ovb-z-mini, 9991); left: max(16px, env(safe-area-inset-left, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ovb-bar, var(--ovnav-appbar-h, 0px))); translate: 0 calc(-1 * var(--ovb-lift, 0px)); width: 340px; max-width: calc(100vw - 32px); display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; column-gap: 2px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-elevated, var(--bg-card, #101828)); color: var(--text-primary, #e6edf7); box-shadow: 0 18px 44px -18px rgba(0, 0, 0, .7), 0 2px 8px rgba(0, 0, 0, .25); overflow: hidden; font: 500 .88rem/1.3 var(--font-sans, system-ui, sans-serif); animation: ovmini-in .28s cubic-bezier(.2, .7, .2, 1) both; }
.ovmini-pic { position: relative; grid-column: 1 / -1; display: block; aspect-ratio: 16 / 9; background: #000; cursor: pointer; }
.ovmini-pic video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }
.ovmini-back { position: absolute; left: 8px; top: 8px; display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 10px 0 8px; border: 0; border-radius: 999px; background: rgba(5, 8, 15, .72); color: #fff; font: 600 .76rem/1 var(--font-sans, system-ui, sans-serif); text-decoration: none; opacity: 0; transition: opacity .16s ease; }
@@@ -82,7 +82,7 @@
@keyframes ovmini-out { to { opacity: 0; transform: translateY(10px); } }
@media (max-width: 640px) {
/* A phone: one bar above the app bar. The picture at its left, then what is playing, then play and close. */
-- .ovmini { left: 8px; right: 8px; width: auto; max-width: none; bottom: calc(8px + var(--ovnav-appbar-h, env(safe-area-inset-bottom, 0px))); grid-template-columns: auto minmax(0, 1fr) auto auto; border-radius: 14px; }
++ .ovmini { left: 8px; right: 8px; width: auto; max-width: none; bottom: calc(8px + var(--ovb-base, var(--ovnav-appbar-h, env(safe-area-inset-bottom, 0px)))); grid-template-columns: auto minmax(0, 1fr) auto auto; border-radius: 14px; }
.ovmini-pic { grid-column: 1; width: 112px; aspect-ratio: 16 / 9; }
.ovmini-back { display: none; }
.ovmini-sound { left: 4px; right: auto; top: auto; bottom: 7px; min-height: 26px; padding: 0 8px 0 5px; font-size: .68rem; }
@@@ -181,6 +181,7 @@ html[data-ov-motion=off] .ovmini-bar i { transition: none; }
video.muted = !!r.muted;
arm(); source();
document.body.appendChild(el);
++ if (root.OpenVibeBottom) root.OpenVibeBottom.refresh();
root.addEventListener('pagehide', leave);
document.addEventListener('play', other, true);
paint();
@@@ -196,7 +197,7 @@ html[data-ov-motion=off] .ovmini-bar i { transition: none; }
el = null; video = null; live = false;
if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch { /* already gone */ } }
if (!quiet) forget();
-- const off = () => node.remove();
++ const off = () => { node.remove(); if (root.OpenVibeBottom) root.OpenVibeBottom.refresh(); };
if (quiet || document.documentElement.getAttribute('data-ov-motion') === 'off') off();
else { node.classList.add('is-leaving'); node.addEventListener('animationend', off, { once: true }); root.setTimeout(off, 260); }
}
ddiff --git a/navbar.js b/navbar.js
iindex 65cdb08..2b79aac 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -607,7 +607,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', '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': '01b6fff110cd', 'dock.js': '0ebe750a2e4d', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '7477f93fd775', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'b750eacee14b', 'ov-wallet.css': 'da51db3269db', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '8eec18660ff4', 'dock.js': '4031e67d411d', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'ea69b8a08868', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'cca1c50e1cce', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
@@@ -2299,6 +2299,7 @@
_navEl = nav;
paintSlots();
watchKeyboard();
++ loadBottom();
attachAvatarFallbacks(nav);
hookTop();
loadWalletChips();
@@@ -2307,6 +2308,14 @@
return nav;
}
++ // bottom.js: the visible viewport for the bar, the small player, the Dock's pill and the composer.
++ let _bottomLoading = false;
++ function loadBottom() {
++ if (_bottomLoading || root.OpenVibeBottom) return;
++ _bottomLoading = true;
++ try { const sc = document.createElement('script'); sc.id = 'ovb-loader'; sc.src = siblingV('bottom.js'); sc.async = true; document.head.appendChild(sc); } catch { _bottomLoading = false; }
++ }
++
// ── The phone app bar (appbar.js, loaded only when the site sets appBar): its main places as a bottom tab bar ──
let _appBarLoading = false;
const rawLinks = () => _runtimeLinks || _config.links || linksFor(_config.service);
ddiff --git a/ov-combo.js b/ov-combo.js
nnew file mode 100644
iindex 0000000..814c8bc
---- /dev/null
++++ b/ov-combo.js
@@@ -0,0 +1,263 @@
++/**
++ * ov-combo.js (openvibe-shared 4.202.0): one search-as-you-type dropdown for a field. Ask 348, the owner, on Codes'
++ * repository field (a browser datalist): "a completely custom drop-down system … that works a little more intelligently
++ * … async or dynamic fetching or search … configurable … You should be able to just create the new repository … through
++ * this UI". It looks like the one dropdown (ov-drop.js) and works with the keyboard and a screen reader (a combobox
++ * with a listbox).
++ *
++ * Without script the field is a plain input. With it:
++ * <input name="repo" data-ov-combo="/api/v1/me/repos" data-ov-combo-create="/api/v1/repos">
++ * reads GET <source>?q=&limit=12 (same origin, the page's cookies) → { sections: [{ id, label, items: [{ label, url,
++ * host, private, sub }] }], create: { allowed, host, owner } }, as you type (160 ms after the last key, the one before
++ * aborted, answers kept a minute). Picking a row puts its url in the field. With create allowed, a last row offers
++ * "Create “name” on OpenVibe Git": POST <create> { name, private: true } → { url, label }, and the new one goes in.
++ * A site with its own data: OVCombo.enhance(input, { source: async (q, signal) => ({ sections }), create: async (name) => ({ url }) }).
++ * Events on the input: 'ov-combo:pick' { detail: item } after a pick or a create.
++ */
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OVCombo) return;
++
++ const svg = (inner) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
++ const HOST_IC = {
++ 'git.openvibe.codes': svg('<circle cx="7" cy="6" r="2.2"/><circle cx="7" cy="18" r="2.2"/><circle cx="17" cy="8" r="2.2"/><path d="M7 8.2v7.6M17 10.2c0 4-6 3-9.2 6"/>'),
++ 'github.com': svg('<path d="M9 19c-4 1.3-4-2-5.6-2.5M14.6 21v-3.3a2.9 2.9 0 0 0-.8-2.2c2.7-.3 5.6-1.3 5.6-6a4.6 4.6 0 0 0-1.3-3.2 4.3 4.3 0 0 0-.1-3.2s-1-.3-3.4 1.3a11.5 11.5 0 0 0-6 0C6.2 2.8 5.2 3.1 5.2 3.1a4.3 4.3 0 0 0-.1 3.2A4.6 4.6 0 0 0 3.8 9.5c0 4.6 2.8 5.7 5.5 6a2.9 2.9 0 0 0-.8 2.2V21"/>'),
++ recent: svg('<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>'),
++ repo: svg('<path d="M5 4.5h11.5a1.5 1.5 0 0 1 1.5 1.5v13H6.5A1.5 1.5 0 0 1 5 17.5z"/><path d="M5 17.5A1.5 1.5 0 0 1 6.5 16H18"/>'),
++ plus: svg('<path d="M12 5v14M5 12h14"/>'),
++ lock: '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><rect x="5.5" y="11" width="13" height="9" rx="2"/><path d="M8.5 11V8a3.5 3.5 0 0 1 7 0v3"/></svg>',
++ };
++ const CSS = `.ovcombo { position: fixed; z-index: 10030; box-sizing: border-box; max-height: min(60vh, 420px); overflow: auto; overscroll-behavior: contain; padding: 6px; border-radius: 16px;
++ background: color-mix(in srgb, var(--bg-elevated, var(--bg-secondary, #111826)) 96%, 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: 24px 0; animation: ovcombo-in .2s cubic-bezier(.2, 1.2, .3, 1) both; }
++.ovcombo.is-up { transform-origin: 24px 100%; }
++.ovcombo[hidden] { display: none; }
++.ovcombo-sec { margin: 0; padding: 8px 10px 5px; font-size: 11.5px; font-weight: 700; letter-spacing: .02em; color: var(--text-muted, #93a0b4); }
++.ovcombo-sec:not(:first-child) { margin-top: 4px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); padding-top: 10px; }
++.ovcombo-i { display: flex; align-items: center; gap: 11px; min-height: 46px; padding: 4px 12px 4px 10px; border-radius: 11px; cursor: pointer; }
++.ovcombo-i[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
++.ovcombo-i:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }
++.ovcombo-i[aria-selected="true"]:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 20%, transparent); }
++.ovcombo-ic { flex: none; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
++.ovcombo-ic svg { width: 18px; height: 18px; }
++.ovcombo-i.is-create .ovcombo-ic { color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
++.ovcombo-t { flex: 1; min-width: 0; display: grid; gap: 2px; }
++.ovcombo-t b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovcombo-t b mark { background: none; color: var(--accent-light, var(--accent, #60a5fa)); }
++.ovcombo-t small { display: flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 500; color: var(--text-muted, #93a0b4); overflow: hidden; white-space: nowrap; }
++.ovcombo-none { padding: 14px 12px; font-weight: 500; font-size: 13.5px; color: var(--text-muted, #93a0b4); }
++.ovcombo-skel { display: flex; align-items: center; gap: 11px; height: 46px; padding: 0 10px; }
++.ovcombo-skel i { flex: none; width: 30px; height: 30px; border-radius: 9px; }
++.ovcombo-skel b { height: 11px; border-radius: 6px; width: var(--w, 60%); }
++.ovcombo-skel i, .ovcombo-skel b { background: linear-gradient(90deg, color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 12%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent)); background-size: 200% 100%; animation: ovcombo-shine 1.1s linear infinite; }
++.ovcombo-err { padding: 10px 12px; font-size: 13px; font-weight: 500; color: var(--live-red, #f87171); }
++@keyframes ovcombo-in { from { opacity: 0; transform: translateY(-4px) scale(.97); } }
++@keyframes ovcombo-shine { to { background-position: -200% 0; } }
++html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, html[data-ov-motion=off] .ovcombo-skel b { animation: none; }`;
++
++ let pop = null, cur = null, uid = 0;
++ function styles() { if (document.getElementById('ovcombo-styles')) return; const st = document.createElement('style'); st.id = 'ovcombo-styles'; st.textContent = CSS; document.head.appendChild(st); }
++ function el(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }
++ const nameOf = (q) => { const s = String(q || '').trim().replace(/^https?:\/\/[^/]+\//, '').replace(/\.git$/, ''); const last = s.split('/').pop(); return /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(last || '') ? last : ''; };
++ const ago = (t) => { const s = (Date.now() - new Date(t).getTime()) / 1000; if (!isFinite(s) || s < 0) return ''; return s < 3600 ? `${Math.max(1, Math.floor(s / 60))} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago`; };
++
++ function enhance(input, opts) {
++ if (!input || input.__ovcombo) return input && input.__ovcombo;
++ styles();
++ const o = Object.assign({}, opts);
++ const src = o.source || input.getAttribute('data-ov-combo');
++ const createAt = o.create || input.getAttribute('data-ov-combo-create');
++ const listId = `ovcombo-${++uid}`;
++ input.setAttribute('role', 'combobox');
++ input.setAttribute('aria-autocomplete', 'list');
++ input.setAttribute('aria-expanded', 'false');
++ input.setAttribute('aria-controls', listId);
++ input.setAttribute('autocomplete', 'off');
++ input.removeAttribute('list');
++ const cache = new Map();
++ let rows = [], at = -1, ctl = null, timer = 0, lastQ = null, creating = false;
++
++ async function fetchQ(q) {
++ const hit = cache.get(q);
++ if (hit && Date.now() - hit.at < 60000) return hit.v;
++ if (ctl) ctl.abort();
++ ctl = typeof AbortController !== 'undefined' ? new AbortController() : null;
++ let v;
++ if (typeof src === 'function') v = await src(q, ctl && ctl.signal);
++ else {
++ const u = new URL(src, location.href); u.searchParams.set('q', q); u.searchParams.set('limit', '12');
++ const r = await fetch(u.href, { credentials: 'same-origin', headers: { accept: 'application/json' }, signal: ctl && ctl.signal });
++ if (!r.ok) throw new Error(`HTTP ${r.status}`);
++ v = await r.json();
++ }
++ v = { sections: Array.isArray(v && v.sections) ? v.sections : [], create: (v && v.create) || null };
++ cache.set(q, { at: Date.now(), v });
++ return v;
++ }
++ function place() {
++ if (!pop || pop.hidden) return;
++ const r = input.getBoundingClientRect();
++ const vh = root.innerHeight, vw = root.innerWidth;
++ const below = vh - r.bottom - 12, above = r.top - 12;
++ const up = below < 220 && above > below;
++ pop.classList.toggle('is-up', up);
++ const w = Math.min(Math.max(r.width, 280), vw - 16);
++ pop.style.width = `${w}px`;
++ pop.style.left = `${Math.max(8, Math.min(r.left, vw - w - 8))}px`;
++ pop.style.maxHeight = `${Math.min(420, Math.max(160, up ? above : below))}px`;
++ if (up) { pop.style.top = ''; pop.style.bottom = `${vh - r.top + 6}px`; } else { pop.style.bottom = ''; pop.style.top = `${r.bottom + 6}px`; }
++ }
++ function open() {
++ if (!pop) { pop = el('div', 'ovcombo'); pop.setAttribute('role', 'listbox'); pop.hidden = true; document.body.appendChild(pop); pop.addEventListener('mousedown', (e) => e.preventDefault()); }
++ pop.id = listId;
++ pop.setAttribute('aria-label', input.getAttribute('aria-label') || input.placeholder || 'Choices');
++ cur = api;
++ pop.hidden = false;
++ input.setAttribute('aria-expanded', 'true');
++ place();
++ }
++ function close() {
++ // A closed list stays closed: the answer still on its way is dropped.
++ lastQ = null; clearTimeout(timer);
++ if (ctl) { ctl.abort(); ctl = null; }
++ if (cur === api && pop) { pop.hidden = true; pop.replaceChildren(); }
++ input.setAttribute('aria-expanded', 'false');
++ input.removeAttribute('aria-activedescendant');
++ rows = []; at = -1;
++ }
++ function mark(text, q) {
++ const b = el('b');
++ const i = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1;
++ if (i < 0) { b.textContent = text; return b; }
++ b.append(text.slice(0, i), el('mark', null, text.slice(i, i + q.length)), text.slice(i + q.length));
++ return b;
++ }
++ function select(i) {
++ at = i;
++ rows.forEach((r, k) => r.el.setAttribute('aria-selected', String(k === at)));
++ if (rows[at]) { input.setAttribute('aria-activedescendant', rows[at].el.id); rows[at].el.scrollIntoView({ block: 'nearest' }); } else input.removeAttribute('aria-activedescendant');
++ }
++ function row(item, q, kind) {
++ const d = el('div', `ovcombo-i${kind === 'create' ? ' is-create' : ''}`);
++ d.id = `${listId}-${rows.length}`;
++ d.setAttribute('role', 'option');
++ d.setAttribute('aria-selected', 'false');
++ const ic = el('span', 'ovcombo-ic');
++ ic.innerHTML = kind === 'create' ? HOST_IC.plus : HOST_IC[item.host] || (kind === 'recent' ? HOST_IC.recent : HOST_IC.repo);
++ const t = el('span', 'ovcombo-t');
++ t.appendChild(kind === 'create' ? el('b', null, item.label) : mark(String(item.label || item.url || ''), q));
++ const subBits = kind === 'create' ? [item.sub] : [item.host && item.host !== 'git.openvibe.codes' ? item.host : item.host ? 'OpenVibe Git' : null, item.sub || (item.updated_at ? ago(item.updated_at) : null)].filter(Boolean);
++ if (subBits.length || item.private) {
++ const s = el('small');
++ if (item.private && kind !== 'create') { const l = el('span'); l.innerHTML = HOST_IC.lock; l.setAttribute('title', 'Private'); s.appendChild(l); }
++ s.append(subBits.join(' · '));
++ t.appendChild(s);
++ }
++ d.append(ic, t);
++ const k = rows.length;
++ d.addEventListener('click', () => choose(k));
++ d.addEventListener('mousemove', () => { if (at !== k) select(k); });
++ rows.push({ el: d, item, kind });
++ return d;
++ }
++ function skeleton() {
++ open();
++ pop.replaceChildren();
++ ['62%', '48%', '70%'].forEach((w) => { const s = el('div', 'ovcombo-skel'); s.style.setProperty('--w', w); s.append(el('i'), el('b')); pop.appendChild(s); });
++ pop.setAttribute('aria-busy', 'true');
++ }
++ function paint(v, q) {
++ open();
++ pop.removeAttribute('aria-busy');
++ pop.replaceChildren(); rows = []; at = -1;
++ let any = false;
++ for (const sec of v.sections) {
++ const items = (sec.items || []).filter((x) => x && (x.url || x.label));
++ if (!items.length) continue;
++ any = true;
++ const h = el('p', 'ovcombo-sec', sec.label || ''); h.setAttribute('role', 'presentation');
++ pop.appendChild(h);
++ for (const it of items) pop.appendChild(row(it, q, sec.id === 'recent' ? 'recent' : 'item'));
++ }
++ const name = nameOf(q);
++ const c = v.create;
++ const exists = rows.some((r) => String(r.item.label || '').split('/').pop().toLowerCase() === name.toLowerCase());
++ if (createAt && c && c.allowed && name && !exists) {
++ if (any) { const h = el('p', 'ovcombo-sec', 'New'); h.setAttribute('role', 'presentation'); pop.appendChild(h); }
++ pop.appendChild(row({ label: `Create “${name}” on ${c.host === 'git.openvibe.codes' || !c.host ? 'OpenVibe Git' : c.host}`, sub: `Private, under ${c.owner ? '@' + c.owner : 'your account'}. You can share it later.`, name }, q, 'create'));
++ }
++ if (!rows.length) pop.appendChild(el('div', 'ovcombo-none', q ? `Nothing matches “${q}”. A full address works too.` : 'Nothing here yet. Paste an address, or type a name.'));
++ place();
++ if (rows.length && q) select(0);
++ }
++ async function look(q) {
++ lastQ = q;
++ if (!cache.has(q)) skeleton();
++ try {
++ const v = await fetchQ(q);
++ if (lastQ !== q || document.activeElement !== input) return;
++ paint(v, q);
++ } catch (e) {
++ if (e && e.name === 'AbortError') return;
++ if (lastQ !== q) return;
++ open(); pop.removeAttribute('aria-busy'); pop.replaceChildren(el('div', 'ovcombo-err', 'The list did not load. You can still paste an address.')); place();
++ }
++ }
++ async function choose(i) {
++ const r = rows[i]; if (!r || creating) return;
++ if (r.kind !== 'create') { put(r.item); return; }
++ creating = true;
++ r.el.querySelector('b').textContent = `Creating “${r.item.name}”…`;
++ try {
++ let made;
++ if (typeof createAt === 'function') made = await createAt(r.item.name);
++ else {
++ const res = await fetch(new URL(createAt, location.href).href, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ name: r.item.name, private: true }) });
++ made = await res.json().catch(() => ({}));
++ if (!res.ok) throw new Error(made.message || made.error || `HTTP ${res.status}`);
++ }
++ cache.clear();
++ put({ label: made.label || r.item.name, url: made.url, host: 'git.openvibe.codes', created: true });
++ } catch (e) {
++ const s = r.el.querySelector('small') || r.el.querySelector('.ovcombo-t').appendChild(el('small'));
++ r.el.querySelector('b').textContent = `Create “${r.item.name}”`;
++ s.textContent = String((e && e.message) || 'It was not created.').slice(0, 160);
++ s.style.color = 'var(--live-red, #f87171)';
++ } finally { creating = false; }
++ }
++ function put(item) {
++ input.value = item.url || item.label || '';
++ close();
++ input.dispatchEvent(new Event('input', { bubbles: true }));
++ input.dispatchEvent(new Event('change', { bubbles: true }));
++ input.dispatchEvent(new CustomEvent('ov-combo:pick', { bubbles: true, detail: item }));
++ input.__ovcomboPicked = input.value;
++ }
++
++ input.addEventListener('focus', () => { if (input.__ovcomboPicked !== input.value || !input.value) look(input.value.trim()); });
++ input.addEventListener('input', () => { if (input.__ovcomboPicked === input.value) return; input.__ovcomboPicked = null; clearTimeout(timer); timer = setTimeout(() => look(input.value.trim()), 160); });
++ input.addEventListener('blur', () => setTimeout(() => { if (document.activeElement !== input) close(); }, 120));
++ input.addEventListener('keydown', (e) => {
++ const shown = pop && !pop.hidden && cur === api;
++ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
++ e.preventDefault();
++ if (!shown) { look(input.value.trim()); return; }
++ if (!rows.length) return;
++ select(at < 0 ? (e.key === 'ArrowDown' ? 0 : rows.length - 1) : (at + (e.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length);
++ } else if (e.key === 'Enter' && shown && at >= 0) { e.preventDefault(); e.stopPropagation(); choose(at); }
++ else if (e.key === 'Escape' && shown) { e.preventDefault(); e.stopPropagation(); close(); }
++ else if (e.key === 'Tab' && shown) close();
++ });
++ root.addEventListener('resize', place);
++ root.addEventListener('scroll', place, true);
++ const api = { input, open: () => look(input.value.trim()), close, destroy() { close(); input.__ovcombo = null; root.removeEventListener('resize', place); root.removeEventListener('scroll', place, true); } };
++ input.__ovcombo = api;
++ return api;
++ }
++
++ function scan(scope) { (scope || document).querySelectorAll('input[data-ov-combo]').forEach((i) => enhance(i)); }
++ root.OVCombo = { enhance, scan };
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => scan(), { once: true }); else scan();
++ document.addEventListener('ov:boost:load', () => scan());
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/ov-composer.js b/ov-composer.js
iindex 93a4fd3..053ebac 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -27,6 +27,21 @@
if (root.OpenVibeComposer || typeof document === 'undefined') return;
const mods = [];
++ // 4.202.0: your settings open in the one window (ov-window.js: a sheet on a phone, swiped down to close), loaded
++ // from beside this file; without it, a plain <dialog>.
++ const COMPOSER_REV = { 'ov-window.js': 'c882d11d85ff' };
++ const selfSrc = (document.currentScript && document.currentScript.src) || '';
++ function needWindow() {
++ return new Promise((ok) => {
++ if (root.OVWindow) return ok(root.OVWindow);
++ const m = /^(https?:\/\/[^?#]*\/)ov-composer(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
++ if (!m) return ok(null);
++ const sc = document.createElement('script');
++ sc.src = m[1] + 'ov-window.js' + (COMPOSER_REV['ov-window.js'] ? '?v=' + COMPOSER_REV['ov-window.js'] : '');
++ sc.onload = () => ok(root.OVWindow || null); sc.onerror = () => ok(null);
++ document.head.appendChild(sc);
++ });
++ }
const live = new Set();
const PREFS_KEY = 'ov:composer';
const DEFAULTS = { send_key: 'enter', voice: true, density: 'comfy', snippets: [] };
@@@ -207,8 +222,11 @@
const t = cd.getData('text/plain') || '';
const lines = t.split('\n').length;
if (t.length > 6000 || lines > 120) {
++ // Only where the box takes a text file (or a mod uploads): a box for pictures keeps the paste as text.
++ const asFile = new File([t], 'pasted-text.txt', { type: 'text/plain' });
++ if (!uploader() && !accepts(asFile)) return;
e.preventDefault();
-- add([new File([t], 'pasted-text.txt', { type: 'text/plain' })], { lines, text: t });
++ add([asFile], { lines, text: t });
say('The long paste went in as a file: ↩ on it puts it back in the box.');
}
});
@@@ -308,13 +326,15 @@
text.setRangeText(String(it.insert || ''), from, from, 'end'); fire(); text.focus();
}
let lookT = 0;
-- text.addEventListener('input', () => { clearTimeout(lookT); lookT = setTimeout(look, trig && trig.kind === 'at' ? 140 : 0); });
++ text.addEventListener('input', () => { clearTimeout(lookT); lookT = setTimeout(() => { lookT = 0; look(); }, trig && trig.kind === 'at' ? 140 : 0); });
text.addEventListener('blur', () => setTimeout(() => { if (document.activeElement !== text) close(); }, 120));
text.setAttribute('aria-autocomplete', 'list');
// Keys, before the thread's own: the list's arrows and Enter, what sends, and the marks.
form.addEventListener('keydown', (e) => {
if (e.target !== text) return;
++ // Enter right after the last letter: the slash list is read now, so "/settings" + Enter picks it.
++ if (e.key === 'Enter' && lookT) { clearTimeout(lookT); lookT = 0; const w = caretWord(); if (w && w.kind === 'slash') look(); }
if (!pop.hidden && items.length) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); at = (at + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length; paintPop(); return; }
if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); choose(at); return; }
@@@ -419,26 +439,32 @@
// ── Your settings, in a sheet: what sends, voice, compact, your own commands.
let sheet = null;
-- function openSettings() {
-- if (!sheet) {
++ function openSettings(from) {
++ const opener = from || document.activeElement;
++ needWindow().then((W) => settingsIn(W, opener));
++ }
++ function settingsIn(W, opener) {
++ let win = null;
++ if (!W && !sheet) {
sheet = el('dialog', 'ovc-set');
sheet.setAttribute('aria-label', 'Your composer');
sheet.addEventListener('click', (e) => { if (e.target === sheet) sheet.close(); });
document.body.appendChild(sheet);
}
const k = mac ? '⌘' : 'Ctrl';
-- sheet.innerHTML = `<form method="dialog" class="ovc-set-f"><header class="ovc-set-h"><b>Your composer</b><button class="ovt-btn" value="done" aria-label="Done">${svg(IC.x)}</button></header>
++ const host = W ? el('div', 'ovc-set ovc-set-in') : sheet;
++ host.innerHTML = `<form method="dialog" class="ovc-set-f">${W ? '' : `<header class="ovc-set-h"><b>Your composer</b><button class="ovt-btn" value="done" aria-label="Done">${svg(IC.x)}</button></header>`}
<fieldset class="ovc-set-g"><legend>Send with</legend><label><input type="radio" name="send_key" value="enter"> Enter <small>(Shift+Enter for a new line)</small></label><label><input type="radio" name="send_key" value="mod-enter"> ${k}+Enter <small>(Enter for a new line)</small></label></fieldset>
<label class="ovc-set-c"><input type="checkbox" name="voice"> A microphone button${Speech ? '' : ' <small>(this browser cannot listen)</small>'}</label>
<label class="ovc-set-c"><input type="checkbox" name="compact"> A compact box</label>
<fieldset class="ovc-set-g"><legend>Your commands</legend><p class="ovc-set-p">Type / and the word, and its text goes in. Up to 50.</p><ul class="ovc-set-l"></ul>
<div class="ovc-set-add"><input name="trigger" placeholder="word" maxlength="30" pattern="[A-Za-z0-9_-]+" aria-label="The word after /"><textarea name="snippet" rows="2" maxlength="4000" placeholder="The text it puts in" aria-label="The text it puts in"></textarea><button type="button" class="ovc-set-btn" data-ovc-add>Add</button></div></fieldset>
<p class="ovc-set-p">${synced ? 'Saved to your account: the same on every OpenVibe site.' : 'Saved in this browser.'}</p></form>`;
-- const f = sheet.querySelector('form');
++ const f = host.querySelector('form');
f.send_key.value = prefs.send_key;
f.voice.checked = !!prefs.voice; f.voice.disabled = !Speech;
f.compact.checked = prefs.density === 'compact';
-- const list = sheet.querySelector('.ovc-set-l');
++ const list = host.querySelector('.ovc-set-l');
const paintList = () => {
list.innerHTML = '';
(prefs.snippets || []).forEach((s, i) => {
@@@ -456,7 +482,7 @@
else if (e.target.name === 'voice') setPrefs({ voice: f.voice.checked });
else if (e.target.name === 'compact') setPrefs({ density: f.compact.checked ? 'compact' : 'comfy' });
});
-- sheet.querySelector('[data-ovc-add]').addEventListener('click', () => {
++ host.querySelector('[data-ovc-add]').addEventListener('click', () => {
const w = f.trigger.value.trim().replace(/^\//, ''), t = f.snippet.value;
if (!/^[\w-]{1,30}$/.test(w)) { f.trigger.focus(); return; }
if (!t.trim()) { f.snippet.focus(); return; }
@@@ -464,8 +490,10 @@
f.trigger.value = ''; f.snippet.value = '';
paintList(); f.trigger.focus();
});
-- if (typeof sheet.showModal === 'function') sheet.showModal(); else sheet.setAttribute('open', '');
-- const first = f.querySelector('input[name=send_key]:checked'); if (first) first.focus();
++ f.addEventListener('submit', (e) => e.preventDefault());
++ if (W) win = W.open({ id: 'ov-composer-settings', title: 'Your composer', body: host, kind: 'dialog', size: 'sm', from: opener, focus: 'input[name=send_key]:checked' });
++ else { if (typeof sheet.showModal === 'function') sheet.showModal(); else sheet.setAttribute('open', ''); const first = f.querySelector('input[name=send_key]:checked'); if (first) first.focus(); }
++ return win;
}
function mod(m) {
ddiff --git a/ov-fx.js b/ov-fx.js
iindex e77b965..7cbac97 100644
---- a/ov-fx.js
++++ b/ov-fx.js
@@@ -4,7 +4,10 @@
* navbar.js loads it from beside itself once the page settles, and only when the page has an element with data-fx.
*
* <a class="card" data-fx="glint lift press">…</a> effects by name, any number of them
-- * <ul data-fx="reveal" data-fx-stagger="60">…</ul> its children rise in as they come on screen, 60 ms apart
++ * <section data-fx="reveal" data-fx-stagger="40">…</section>
++ * its heading rises 12 px (8 on a phone) out of a 4 px blur,
++ * a [data-fx-rule] draws from the left, and its cards follow in
++ * 40 ms steps (at most six; the rest together), once.
* <b data-fx="count">12,480</b> the number counts up the first time it is seen
* <button data-fx="confetti press">Send tip</button> a burst of confetti from the button when it is pressed
* OVFx.confetti({ x, y, count, colors }) the same burst from anywhere (a first stream, a goal)
@@@ -18,9 +21,17 @@
* --ov-ease-spring, --ov-ease-in-out. Each look has a feel (4.67.0): the quiet paper and light themes move calmly (a
* little slower, no overshoot), the neon and terminal ones snap (quicker, a livelier spring); the rest are lively. Nothing here runs by itself: every effect waits for a hover, a press, or the
* element coming on screen, plays once, and stops. Calm (the person's choice, or the device asking for less motion:
-- * html[data-ov-motion=reduced]) keeps every effect and makes it smaller and slower: a lift rises a pixel, a reveal
-- * rises five, a tilt leans a third as far, a burst of confetti is a third the size, nothing overshoots. Only Off
-- * (html[data-ov-motion=off]) stops them: reveals and numbers are simply there, and no confetti falls.
++ * html[data-ov-motion=reduced]) keeps every effect and makes it smaller and slower: a lift rises a pixel, a tilt
++ * leans a third as far, a burst of confetti is a third the size, nothing overshoots; a reveal keeps only a short
++ * fade. Only Off (html[data-ov-motion=off]) stops them: reveals and numbers are simply there, and no confetti falls.
++ *
++ * The reveal (278) is driven by the scroll timeline where the browser has animation-timeline: view(), so no script
++ * runs on the main thread while scrolling; elsewhere one shared IntersectionObserver does the same job. Content is
++ * visible by default: the start state is only applied (a) by CSS inside @supports, or (b) to elements the script
++ * knows are below the fold, with an observer ready to show them. A heading is [data-fx-heading] or the first h1–h6
++ * in the container; a rule is [data-fx-rule] (or an <hr>); cards are [data-fx-card]s, the container's own children,
++ * or the children of a plain list/grid child. Point at them with data-fx-cards="selector" when the markup is not
++ * one of those shapes. data-fx-self reveals the element itself. Distances halve on a phone, Calm fades, Off is still.
*/
(function (root) {
'use strict';
@@@ -53,8 +64,30 @@
+ '.ovfx-rip{position:absolute;border-radius:50%;pointer-events:none;background:currentColor;opacity:.22;transform:scale(0);animation:ovfxRip .6s var(--ov-ease-out) forwards}'
+ '@keyframes ovfxRip{to{transform:scale(1);opacity:0}}'
+ '.ovfx-tilt{transition:transform var(--ov-dur-3) var(--ov-ease-out);transform-style:preserve-3d;will-change:transform}'
-- + '.ovfx-hide{opacity:0;transform:translateY(calc(14px * var(--ov-move,1)))}'
-- + '.ovfx-in{opacity:1;transform:none;transition:opacity .5s var(--ov-ease-out),transform .6s var(--ov-ease-spring);transition-delay:var(--ovfx-d,0ms)}'
++ // The reveal (278): a heading out of a blur, its rule drawing from the left, then its cards. The scroll timeline
++ // plays it where view() exists; the classes only carry the start state, so the page is never left blank by CSS.
++ + '.ovfx-head{--ovfx-rise:12px}.ovfx-card{--ovfx-card-rise:10px}'
++ + '@media (max-width:600px){.ovfx-head{--ovfx-rise:8px}.ovfx-card{--ovfx-card-rise:6px}}'
++ + '.ovfx-rule{transform-origin:left center}'
++ + '@keyframes ovfxHeadRise{from{opacity:0;transform:translateY(calc(var(--ovfx-rise,12px) * var(--ov-move,1)));filter:blur(4px)}to{opacity:1;transform:none;filter:blur(0)}}'
++ + '@keyframes ovfxCardRise{from{opacity:0;transform:translateY(calc(var(--ovfx-card-rise,10px) * var(--ov-move,1)))}to{opacity:1;transform:none}}'
++ + '@keyframes ovfxRuleDraw{from{transform:scaleX(0);opacity:.35}to{transform:scaleX(1);opacity:1}}'
++ + '@keyframes ovfxFadeIn{from{opacity:0}to{opacity:1}}'
++ + '@supports (animation-timeline:view()){.ovfx-head.ovfx-scroll{animation:ovfxHeadRise linear backwards;animation-timeline:view();animation-range:entry 0% cover 25%}.ovfx-rule.ovfx-scroll{animation:ovfxRuleDraw linear backwards;animation-timeline:view();animation-range:entry 5% cover 35%}.ovfx-card.ovfx-scroll{animation:ovfxCardRise linear backwards;animation-timeline:view();animation-range:entry calc(5% + var(--ovfx-i,0) * 3%) cover calc(30% + var(--ovfx-i,0) * 3%)}.ovfx-fade.ovfx-scroll{animation:ovfxFadeIn linear backwards;animation-timeline:view();animation-range:entry 0% cover 15%}}'
++ + '.ovfx-head.ovfx-wait{opacity:0;transform:translateY(var(--ovfx-rise,12px));filter:blur(4px)}'
++ + '.ovfx-head.ovfx-in{opacity:1;transform:none;filter:blur(0);transition:opacity .5s var(--ov-ease-out),transform .55s var(--ov-ease-spring),filter .45s var(--ov-ease-out)}'
++ + '.ovfx-card.ovfx-wait{opacity:0;transform:translateY(var(--ovfx-card-rise,10px))}'
++ + '.ovfx-card.ovfx-in{opacity:1;transform:none;transition:opacity .45s var(--ov-ease-out),transform .55s var(--ov-ease-spring);transition-delay:var(--ovfx-d,0ms)}'
++ + '.ovfx-rule.ovfx-wait{transform:scaleX(0);opacity:.35}'
++ + '.ovfx-rule.ovfx-in{transform:scaleX(1);opacity:1;transition:transform .6s var(--ov-ease-out) 80ms,opacity .4s var(--ov-ease-out) 80ms}'
++ + '.ovfx-fade.ovfx-wait{opacity:0}'
++ + '.ovfx-fade.ovfx-in{opacity:1;transition:opacity .28s var(--ov-ease-out)}'
++ + 'html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card,.ovfx-rule).ovfx-scroll:not([data-fx-demo] *){animation-name:ovfxFadeIn;animation-range:entry 0% cover 15%}'
++ + 'html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card).ovfx-wait:not([data-fx-demo] *){opacity:0;transform:none;filter:none}'
++ + 'html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card).ovfx-in:not([data-fx-demo] *){opacity:1;transform:none;filter:none;transition:opacity .28s var(--ov-ease-out)}'
++ + '@media (prefers-reduced-motion:reduce){html:not([data-ov-motion]) :is(.ovfx-head,.ovfx-card,.ovfx-rule).ovfx-scroll:not([data-fx-demo] *){animation-name:ovfxFadeIn;animation-range:entry 0% cover 15%}html:not([data-ov-motion]) :is(.ovfx-head,.ovfx-card).ovfx-wait:not([data-fx-demo] *){opacity:0;transform:none;filter:none}html:not([data-ov-motion]) :is(.ovfx-head,.ovfx-card).ovfx-in:not([data-fx-demo] *){opacity:1;transform:none;filter:none;transition:opacity .28s var(--ov-ease-out)}}'
++ + 'html[data-ov-motion=off] :is(.ovfx-head,.ovfx-card,.ovfx-rule,.ovfx-fade):not([data-fx-demo] *){animation:none!important;transition:none!important}'
++ + 'html[data-ov-motion=off] :is(.ovfx-head,.ovfx-card,.ovfx-rule,.ovfx-fade).ovfx-wait:not([data-fx-demo] *){opacity:1!important;transform:none!important;filter:none!important}'
+ '.ovfx-shimmer{background:linear-gradient(100deg,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 30%,color-mix(in srgb,var(--text-muted,#8b93ad) 22%,transparent) 50%,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 70%) 0 0/300% 100%;animation:ovfxShim 1.4s linear infinite;color:transparent!important;border-radius:8px}'
+ '@keyframes ovfxShim{from{background-position:100% 0}to{background-position:0 0}}'
+ '.ovfx-confetti{position:fixed;inset:0;pointer-events:none;z-index:2147483000}'
@@@ -86,21 +119,100 @@
}
// ── Seen: reveal and count, once each, when they come on screen ──
-- function seen(el) {
-- if (el.__ovfxReveal) {
-- const kids = el.__ovfxReveal;
-- kids.forEach((k) => { k.classList.add('ovfx-in'); k.classList.remove('ovfx-hide'); });
-- root.setTimeout(() => kids.forEach((k) => { k.classList.remove('ovfx-in'); k.style.removeProperty('--ovfx-d'); }), 1400 + kids.length * 80);
-- el.__ovfxReveal = null;
-- }
-- if (el.__ovfxCount) { const c = el.__ovfxCount; el.__ovfxCount = null; countUp(el, c); }
-- }
-- function observe(el) {
-- if (!('IntersectionObserver' in root)) return seen(el);
-- io = io || new root.IntersectionObserver((entries) => entries.forEach((e) => { if (e.isIntersecting) { io.unobserve(e.target); seen(e.target); } }), { rootMargin: '0px 0px -8% 0px' });
++ function observe(el, fn) {
++ if (!('IntersectionObserver' in root)) return fn(el);
++ io = io || new root.IntersectionObserver((entries) => entries.forEach((e) => { if (e.isIntersecting) { io.unobserve(e.target); (e.target.__ovfxSeen || fn)(e.target); } }), { rootMargin: '0px 0px -8% 0px' });
++ el.__ovfxSeen = fn;
io.observe(el);
}
const below = (el) => { try { return el.getBoundingClientRect().top > (root.innerHeight || 0); } catch (e) { return false; } };
++ function seenCount(el) { const c = el.__ovfxCount; if (c) { el.__ovfxCount = null; countUp(el, c); } }
++
++ // ── Reveal (278): a heading, its rule and its cards, once. The scroll timeline (where view() exists) plays it
++ // without any script on the main thread; the shared observer is the fallback. Only elements the script knows are
++ // below the fold ever get a start state, and every path has a way back to the finished state.
++ const HEAD_SEL = 'h1,h2,h3,h4,h5,h6';
++ const CARDISH = 'li,article,a,[data-fx-card],[class*="card"],[class*="tile"],[class*="item"]';
++ function findHead(el) {
++ if (el.hasAttribute('data-fx-self')) return el;
++ const marked = el.querySelector('[data-fx-heading]');
++ if (marked) return marked;
++ if (el.matches(HEAD_SEL)) return el;
++ for (const h of el.querySelectorAll(HEAD_SEL)) {
++ const card = h.closest(CARDISH);
++ if (card && card !== el && el.contains(card)) continue; // a card's own title is not the section's heading
++ let depth = 0, p = h;
++ while (p && p !== el) { p = p.parentElement; depth++; }
++ if (depth <= 3) return h;
++ }
++ return null;
++ }
++ function findRule(el, head) {
++ const marked = el.querySelector('[data-fx-rule]');
++ if (marked && marked !== head) return marked;
++ const hr = el.querySelector('hr');
++ return hr && hr !== head ? hr : null;
++ }
++ function findCards(el, head, rule) {
++ const sel = el.getAttribute('data-fx-cards');
++ if (sel) { try { const list = Array.from(el.querySelectorAll(sel)); if (list.length) return list; } catch (e) { /* the default below */ } }
++ const marked = Array.from(el.querySelectorAll('[data-fx-card]'));
++ if (marked.length) return marked;
++ const out = [];
++ for (const k of Array.from(el.children)) {
++ if (k === head || k === rule || (head && k.contains(head)) || (rule && k.contains(rule))) continue;
++ const cls = typeof k.className === 'string' ? k.className : '';
++ if (k.matches('ul,ol,[role="list"]') || /(^|[\s_-])(grid|cards|rail|row|list)([\s_-]|$)/i.test(cls)) {
++ const inner = Array.from(k.children);
++ if (inner.length) { out.push(...inner); continue; }
++ }
++ out.push(k);
++ }
++ return out;
++ }
++ let viewTimeline = null;
++ function hasViewTimeline() {
++ if (viewTimeline != null) return viewTimeline;
++ try { viewTimeline = !!(root.CSS && root.CSS.supports && root.CSS.supports('animation-timeline: view()')); } catch (e) { viewTimeline = false; }
++ return viewTimeline;
++ }
++ function showReveal(el) {
++ if (el.__ovfxShown) return;
++ el.__ovfxShown = true;
++ el.classList.add('ovfx-in');
++ const delay = Number(String(el.style.getPropertyValue('--ovfx-d')).replace('ms', '')) || 0;
++ root.setTimeout(() => { el.classList.remove('ovfx-wait', 'ovfx-in'); el.style.removeProperty('--ovfx-d'); }, 900 + delay);
++ }
++ function wireReveal(el) {
++ if (off(el)) return;
++ const mode = demo(el) ? 'full' : move(el) < 1 ? 'fade' : 'full';
++ const self = el.hasAttribute('data-fx-self');
++ const head = findHead(el), rule = self ? null : findRule(el, head), cards = self ? [] : findCards(el, head, rule);
++ const parts = [];
++ if (head) parts.push([head, 'head', 0]);
++ if (rule) parts.push([rule, 'rule', 0]);
++ cards.forEach((k, i) => parts.push([k, 'card', Math.min(i, 5)]));
++ if (!parts.length) return;
++ // Never hide what someone can already see: a section below the fold is safe whole, one already on screen
++ // is checked part by part.
++ const live = below(el) ? parts : parts.filter(([k]) => below(k));
++ if (!live.length) return;
++ const timeline = hasViewTimeline();
++ if (!timeline && !('IntersectionObserver' in root)) return;
++ const gap = Math.max(0, Math.min(200, Number(el.getAttribute('data-fx-stagger')) || 40));
++ const follow = head ? 140 : 0;
++ live.forEach(([k, kind, i]) => {
++ k.classList.add(mode === 'fade' ? 'ovfx-fade' : kind === 'head' ? 'ovfx-head' : kind === 'rule' ? 'ovfx-rule' : 'ovfx-card');
++ if (timeline) {
++ k.classList.add('ovfx-scroll');
++ if (kind === 'card') k.style.setProperty('--ovfx-i', String(i));
++ } else {
++ k.classList.add('ovfx-wait');
++ if (kind === 'card' && mode === 'full') k.style.setProperty('--ovfx-d', `${follow + i * gap}ms`);
++ observe(k, showReveal);
++ }
++ });
++ }
function countUp(el, c) {
const t0 = root.performance ? root.performance.now() : Date.now(), ms = Math.min(1600, 500 + Math.log10(Math.max(10, c.to)) * 220) * (move(el) < 1 ? 0.6 : 1);
@@@ -188,16 +300,10 @@
if (n === 'ripple') el.addEventListener('pointerdown', (ev) => ripple(el, ev));
if (n === 'tilt' && fine()) { el.classList.add('ovfx-tilt'); tilt(el); }
if (n === 'confetti') el.addEventListener('click', () => { const r = el.getBoundingClientRect(); confetti({ x: r.left + r.width / 2, y: r.top + r.height / 2, count: 70, from: el }); });
-- if (n === 'reveal' && !off(el)) {
-- const kids = el.children.length && el.getAttribute('data-fx-self') == null ? Array.from(el.children) : [el];
-- if (!below(el)) continue; // already on screen when wired: never hide what someone can see
-- const gap = Math.max(0, Math.min(200, Number(el.getAttribute('data-fx-stagger')) || 50));
-- kids.forEach((k, i) => { k.classList.add('ovfx-hide'); k.style.setProperty('--ovfx-d', `${Math.min(i, 12) * gap}ms`); });
-- el.__ovfxReveal = kids; observe(el);
-- }
++ if (n === 'reveal') wireReveal(el);
if (n === 'count' && !off(el)) {
const c = countOf(el);
-- if (c && below(el)) { el.__ovfxCount = c; observe(el); } else if (c) { el.__ovfxCount = c; seen(el); }
++ if (c && below(el)) { el.__ovfxCount = c; observe(el, seenCount); } else if (c) { el.__ovfxCount = c; seenCount(el); }
}
}
}
ddiff --git a/ov-home.js b/ov-home.js
iindex 53da2f8..717bce7 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -306,35 +306,52 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
(o.yours || []).forEach((a) => { if (a && a.id && safe(a.url)) byId.set(a.id, a); });
return byId;
}
-- // The network's groups, as folders for the sites past the first page (until the person arranges their home).
-- const GROUP_FOLDERS = [['streams', 'Watch'], ['people', 'People'], ['make', 'Make'], ['answers', 'Read'], ['games', 'Play'], ['shop', 'Shop']];
++ // The network's eight groups, in the menu's own order (OpenVibe.Network frame/sites.js GROUPS, the owner's
++ // regrouping of 2026-11-10), as folders for the sites past the first page (until the person arranges their home).
++ const GROUP_FOLDERS = [['watch', 'Watch & stream'], ['talk', 'Talk & belong'], ['play', 'Play'], ['read', 'Read & ask'], ['shop', 'Shop & money'], ['tools', 'Free tools'], ['make', 'Make with AI'], ['build', 'For developers']];
++ // A group folder's id: `g-watch` …; Build keeps `build`, the id saved home screens already use.
++ const folderId = (g) => (g === 'build' ? 'build' : `g-${g}`);
++ // Saved before the regrouping: the old group ids map onto the new folders.
++ const FOLDER_ALIAS = { 'g-streams': 'g-watch', 'g-people': 'g-talk', 'g-games': 'g-play', 'g-answers': 'g-read' };
++ const normalizeFolder = (id) => (id.startsWith('folder:') ? `folder:${FOLDER_ALIAS[id.slice(7)] || id.slice(7)}` : id);
function topSites(o) {
const mineFirst = (o.yours || []).filter((y) => ['you:vibe', 'you:codes', 'you:files', 'you:wallet'].includes(y.id));
-- const sites = (o.apps || []).filter((a) => a.group !== 'build').map((a) => a.id);
-- return sites.slice(0, Math.max(6, 12 - 2 - mineFirst.length));
++ const limit = Math.max(6, 12 - 2 - mineFirst.length);
++ // Leave at least one app in every group for its folder: a group that is all singles has no folder to open.
++ const left = new Map();
++ for (const a of o.apps || []) left.set(a.group, (left.get(a.group) || 0) + 1);
++ const sites = [];
++ for (const a of o.apps || []) {
++ if (a.group === 'build') continue;
++ if ((left.get(a.group) || 0) <= 1) continue;
++ left.set(a.group, left.get(a.group) - 1);
++ sites.push(a.id);
++ if (sites.length >= limit) break;
++ }
++ return sites;
}
function folderDefs(o) {
const apps = o.apps || [];
const top = new Set(topSites(o));
-- const groups = GROUP_FOLDERS.map(([g, name]) => ({ id: `g-${g}`, name, apps: apps.filter((a) => a.group === g && !top.has(a.id)).map((a) => a.id), glyph: 'grid' }))
-- .filter((f) => f.apps.length >= 2);
++ const groups = GROUP_FOLDERS.map(([g, name]) => ({ id: folderId(g), name, group: g, apps: apps.filter((a) => a.group === g && !top.has(a.id)).map((a) => a.id), glyph: 'grid' }))
++ .filter((f) => f.apps.length);
return groups.concat([
{ id: 'tools', name: 'Quick tools', apps: (o.tools || []).map((t) => t.id), glyph: 'tools', more: { label: `All ${o.toolCount || (o.tools || []).length} tools`, href: 'https://openvibe.tools/' } },
-- { id: 'build', name: 'Build', apps: apps.filter((a) => a.group === 'build').map((a) => a.id), glyph: 'code' },
]).filter((f) => f.apps.length);
}
-- /** Until the person arranges it: their own places first (signed in), then the most used and popular sites. */
++ /** Until the person arranges it: their own places first (signed in), then the most used and popular sites, then
++ * one folder per group (the menu's eight, in its order) and the quick tools. */
function defaultHome(o) {
const mineFirst = (o.yours || []).filter((y) => ['you:vibe', 'you:codes', 'you:files', 'you:wallet'].includes(y.id)).map((y) => y.id);
-- const groupFolders = folderDefs(o).filter((f) => f.id.startsWith('g-')).map((f) => `folder:${f.id}`);
-- return mineFirst.concat(topSites(o), groupFolders, ['folder:tools', 'folder:build']);
++ const groupFolders = folderDefs(o).filter((f) => f.group).map((f) => `folder:${f.id}`);
++ return mineFirst.concat(topSites(o), groupFolders, ['folder:tools']);
}
function layoutOf(o) {
const byId = universe(o);
const folders = folderDefs(o);
const known = (id) => byId.has(id) || (id.startsWith('folder:') && folders.some((f) => `folder:${f.id}` === id));
const l = readLocal();
-- const home = (l ? l.home : defaultHome(o)).filter((id, i, a) => known(id) && a.indexOf(id) === i).slice(0, HOME_MAX);
++ const home = (l ? l.home : defaultHome(o)).map(normalizeFolder).filter((id, i, a) => known(id) && a.indexOf(id) === i).slice(0, HOME_MAX);
return { home, folders, arranged: !!l, byId };
}
ddiff --git a/ov-look.js b/ov-look.js
iindex aa3e486..95614eb 100644
---- a/ov-look.js
++++ b/ov-look.js
@@@ -104,6 +104,8 @@
panel.append(row('text', 'Text size', [[100, 'Default'], [112, 'Large'], [125, 'Largest']]));
// Motion (4.183.0): Auto follows the device; Calm keeps every effect, smaller and slower; only Off stops them.
panel.append(row('motion', 'Motion', [['auto', 'Auto'], ['full', 'Full'], ['reduced', 'Calm'], ['off', 'Off']]));
++ // Windows (4.202.0, asks 304 and 318): every window at once (ov-window.js), glass, solid or outlined.
++ panel.append(row('window', 'Windows', [['glass', 'Glass'], ['solid', 'Solid'], ['outline', 'Outline']]));
}
const signedIn = opts.signedIn != null ? !!opts.signedIn : /(?:^|; )ov_sso_hint=account/.test(document.cookie || '') || !!(root.localStorage && (() => { try { return root.localStorage.getItem('ov_token'); } catch (e) { return null; } })());
const named = showing ? showing.name : ((PICKS.find(([id]) => id === cur) || [0, cur])[1]);
ddiff --git a/ov-thread.js b/ov-thread.js
iindex f265248..a490d5d 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -24,9 +24,19 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
-- // 4.201.0: the one composer (ov-composer.js: files, / and @, voice, drafts, your settings), from beside this file.
-- const THREAD_REV = { 'ov-composer.js': 'b70e544155ac' };
++ const THREAD_REV = { 'ov-composer.js': 'bab301d99cb6', 'ov-combo.js': '3e426e56fc75' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
++ // 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
++ function loadCombo() {
++ if (root.OVCombo) { root.OVCombo.scan(); return; }
++ if (loadCombo.on || !document.querySelector('input[data-ov-combo]')) return;
++ loadCombo.on = true;
++ var m = /^(https?:\/\/[^?#]*\/)ov-thread(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
++ var s = document.createElement('script');
++ s.async = true;
++ s.src = (m ? m[1] : 'https://openvibe.network/shared/') + 'ov-combo.js' + (THREAD_REV['ov-combo.js'] ? '?v=' + THREAD_REV['ov-combo.js'] : '');
++ document.head.appendChild(s);
++ }
function loadComposer() {
if (root.OpenVibeComposer) { root.OpenVibeComposer.scan(); return; }
if (loadComposer.on) return;
@@@ -243,6 +253,7 @@
root.OVThread = api;
function start() {
++ loadCombo();
qa(document, '[data-ovt]').forEach(function (box) {
if (box.dataset.ovtReady) return;
box.dataset.ovtReady = '1';
ddiff --git a/ov-user.js b/ov-user.js
nnew file mode 100644
iindex 0000000..8ccb713
---- /dev/null
++++ b/ov-user.js
@@@ -0,0 +1,273 @@
++/* The public user card (ask 287, the owner: "one user card … across all sites … level … live streams … like Discord, but
++ * way better"). Its look, 4.202.0: a banner from the person's own picture (blurred, tinted by their name's hue), the face
++ * with its ring (red while live), the name and @handle, Live now as the first action when they are, level / streak /
++ * badges / followers as chips, the sites they used this week, and the way to their profile and channel. Opened from a
++ * mention or a face (OVUser.bind), it sits in the one window (ov-window.js, a panel beside what was pressed; a sheet on
++ * a phone), loaded from beside this file.
++ * OVUser.card(host, { username }) -> { ready, refresh(), destroy() }
++ * Nothing mounts or intercepts clicks merely by loading this file. */
++(function (root, factory) {
++ 'use strict';
++ const api = factory(root);
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else root.OVUser = api;
++})(typeof window !== 'undefined' ? window : globalThis, function (root) {
++ 'use strict';
++ const NETWORK = 'https://openvibe.network';
++ const USER_REV = { 'ov-window.js': 'c882d11d85ff' };
++ const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
++ const CSS = `.ovu { position: relative; display: grid; gap: 14px; padding: 0 0 18px; color: var(--text-primary, #e6edf7); font: 500 14.5px/1.4 var(--font, Inter, system-ui, sans-serif); }
++.ovu-banner { position: relative; height: 92px; overflow: hidden; background: linear-gradient(135deg, hsl(var(--ovu-h, 220) 70% 42%), hsl(calc(var(--ovu-h, 220) + 40) 70% 28%)); }
++.ovu-banner::before { content: ""; position: absolute; inset: -30%; background: var(--ovu-bg, none) center / cover; filter: blur(26px) saturate(1.5); opacity: .75; }
++.ovu-banner::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 30%, color-mix(in srgb, var(--ovw-bg, #111826) 55%, transparent)); }
++.ovu-head { position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: end; gap: 12px; margin: -44px 16px 0; }
++.ovu-face { position: relative; display: grid; place-items: center; width: 76px; height: 76px; border-radius: 50%; overflow: hidden; background: hsl(var(--ovu-h, 220) 55% 40%); color: #fff; font-size: 30px; font-weight: 800; box-shadow: 0 0 0 4px var(--ovw-bg, var(--bg-secondary, #111826)); }
++.ovu-face img { width: 100%; height: 100%; object-fit: cover; }
++.ovu-face.is-live { box-shadow: 0 0 0 3px var(--live-red, #ef4444), 0 0 0 6px var(--ovw-bg, var(--bg-secondary, #111826)); }
++.ovu-names { min-width: 0; padding-bottom: 4px; }
++.ovu-name { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 19px; font-weight: 800; letter-spacing: -.01em; }
++.ovu-handle { margin: 0; color: var(--text-secondary, #a8b3c4); font-size: 14px; }
++.ovu-state { margin: 0 16px; font-size: 13px; color: var(--text-muted, #8b98ad); }
++.ovu-live { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; margin: 0 16px; border-radius: 12px; background: var(--live-red, #ef4444); color: #fff; font-weight: 750; text-decoration: none; }
++.ovu-live::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #fff; animation: ovu-beat 1.4s ease-in-out infinite; }
++.ovu-live small { font-weight: 600; opacity: .85; }
++.ovu-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 16px; padding: 0; list-style: none; }
++.ovu-chips li { display: inline-flex; align-items: baseline; gap: 5px; padding: 6px 10px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }
++.ovu-chips b { font-size: 14px; color: var(--text-primary, #e6edf7); }
++.ovu-sec { margin: 0 16px; }
++.ovu-sec h3 { margin: 0 0 6px; font-size: 11.5px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #8b98ad); }
++.ovu-sites { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
++.ovu-sites a { display: inline-flex; align-items: center; min-height: 32px; padding: 0 11px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); color: inherit; text-decoration: none; font-size: 13px; font-weight: 600; }
++.ovu-sites a:hover { border-color: var(--accent, #3b82f6); }
++.ovu-acts { display: flex; gap: 8px; margin: 2px 16px 0; }
++.ovu-acts a { flex: 1; display: grid; place-items: center; min-height: 42px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); color: inherit; font-weight: 650; text-decoration: none; }
++.ovu-acts a:first-child { background: var(--accent, #3b82f6); color: var(--on-accent, #fff); }
++.ovu-acts a:hover { filter: brightness(1.1); }
++.ovu-wait { display: grid; gap: 10px; padding: 0 16px 18px; }
++.ovu-wait i { display: block; height: 12px; border-radius: 6px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); animation: ovu-shine 1.2s ease-in-out infinite; }
++.ovu-wait i:first-child { width: 55%; height: 18px; }
++.ovu-wait i:nth-child(2) { width: 35%; }
++.ovu-wait i:nth-child(3) { width: 80%; }
++.ovu-none { margin: 0 16px; color: var(--text-secondary, #a8b3c4); }
++@keyframes ovu-beat { 50% { opacity: .35; transform: scale(.75); } }
++@keyframes ovu-shine { 50% { opacity: .45; } }
++html[data-ov-motion=off] .ovu-live::before, html[data-ov-motion=off] .ovu-wait i { animation: none; }`;
++ function styles(doc) { if (doc.getElementById('ovu-styles')) return; const st = doc.createElement('style'); st.id = 'ovu-styles'; st.textContent = CSS; doc.head.appendChild(st); }
++ // The banner's hue: from the name, so a person's card always wears the same colour.
++ const hueOf = (name) => { let h = 0; for (const ch of String(name || '')) h = (h * 31 + ch.codePointAt(0)) % 360; return h; };
++ const compact = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(n < 1e7 ? 1 : 0)}M` : n >= 1e4 ? `${Math.round(n / 1e3)}k` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
++ function needWindow() {
++ return new Promise((ok) => {
++ if (root.OVWindow) return ok(root.OVWindow);
++ const m = /^(https?:\/\/[^?#]*\/)ov-user(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
++ if (!m || typeof document === 'undefined') return ok(null);
++ const sc = document.createElement('script');
++ sc.src = m[1] + 'ov-window.js' + (USER_REV['ov-window.js'] ? '?v=' + USER_REV['ov-window.js'] : '');
++ sc.onload = () => ok(root.OVWindow || null); sc.onerror = () => ok(null);
++ document.head.appendChild(sc);
++ });
++ }
++ const mounts = new WeakMap();
++ const bindings = new WeakMap();
++ const usernameOf = value => typeof value === 'string' && /^[A-Za-z0-9_]{3,24}$/.test(value.replace(/^@/, '')) ? value.replace(/^@/, '') : null;
++ function safeUrl(value) {
++ try {
++ const u = new URL(value);
++ return u.protocol === 'https:' && !u.username && !u.password ? u.href : null;
++ } catch { return null; }
++ }
++ function element(doc, tag, text, className) {
++ const el = doc.createElement(tag);
++ if (text !== undefined) el.textContent = String(text);
++ if (className) el.className = className;
++ return el;
++ }
++ function link(doc, label, url) {
++ const safe = safeUrl(url);
++ if (!safe) return null;
++ const a = element(doc, 'a', label, 'ovk-btn ovk-btn--quiet');
++ a.href = safe;
++ return a;
++ }
++ function render(section, person) {
++ const doc = section.ownerDocument;
++ section.replaceChildren();
++ section.setAttribute('aria-label', `User @${person.username}`);
++ section.style.setProperty('--ovu-h', String(hueOf(person.username)));
++ const avatar = safeUrl(person.avatar_url);
++ const banner = element(doc, 'div', undefined, 'ovu-banner');
++ if (avatar && !/["'()\\\s]/.test(avatar)) banner.style.setProperty('--ovu-bg', `url("${avatar}")`);
++ section.appendChild(banner);
++ const head = element(doc, 'div', undefined, 'ovu-head');
++ const live = person.live && person.live.now === true;
++ const face = element(doc, 'span', undefined, `ovu-face${live ? ' is-live' : ''}`);
++ if (avatar) { const img = doc.createElement('img'); img.src = avatar; img.alt = ''; img.decoding = 'async'; img.addEventListener('error', () => { img.remove(); face.textContent = person.username[0].toUpperCase(); }); face.appendChild(img); }
++ else face.textContent = person.username[0].toUpperCase();
++ const names = element(doc, 'div', undefined, 'ovu-names');
++ names.appendChild(element(doc, 'h2', typeof person.display_name === 'string' && person.display_name.trim() ? person.display_name : person.username, 'ovu-name'));
++ names.appendChild(element(doc, 'p', `@${person.username}`, 'ovu-handle'));
++ head.append(face, names);
++ section.appendChild(head);
++ if (live) {
++ const a = link(doc, 'Live now', person.live.url);
++ if (a) { a.className = 'ovu-live'; if (typeof person.live.where === 'string' && person.live.where) a.appendChild(element(doc, 'small', `on ${person.live.where}`)); section.appendChild(a); }
++ } else if (person.live && person.live.now === false) section.appendChild(element(doc, 'p', 'Not live right now', 'ovu-state'));
++ const chips = element(doc, 'ul', undefined, 'ovu-chips');
++ for (const [key, label] of [['level', 'Level'], ['streak', 'day streak'], ['badges_count', 'badges'], ['followers', 'followers']]) {
++ const value = person[key];
++ if (!Number.isSafeInteger(value) || value < 0) continue;
++ const li = element(doc, 'li');
++ if (key === 'level') { li.append(label + ' '); li.appendChild(element(doc, 'b', value)); }
++ else { li.appendChild(element(doc, 'b', compact(value))); li.append(` ${key === 'streak' && value === 1 ? 'day streak' : key === 'badges_count' && value === 1 ? 'badge' : key === 'followers' && value === 1 ? 'follower' : label}`); }
++ chips.appendChild(li);
++ }
++ if (chips.childNodes.length) section.appendChild(chips);
++ if (Array.isArray(person.active_sites)) {
++ const list = element(doc, 'ul', undefined, 'ovu-sites');
++ for (const site of person.active_sites) {
++ if (!site || typeof site.name !== 'string') continue;
++ const a = link(doc, site.name, site.url);
++ if (a) { a.className = ''; const li = element(doc, 'li'); li.appendChild(a); list.appendChild(li); }
++ }
++ if (list.childNodes.length) { const sec = element(doc, 'div', undefined, 'ovu-sec'); sec.append(element(doc, 'h3', 'This week'), list); section.appendChild(sec); }
++ }
++ const acts = element(doc, 'div', undefined, 'ovu-acts');
++ for (const [label, url] of [['Profile', person.profile_url], ['Channel', person.channel_url]]) {
++ const a = link(doc, label, url);
++ if (a) { a.className = ''; acts.appendChild(a); }
++ }
++ if (acts.childNodes.length) section.appendChild(acts);
++ }
++ function card(host, options = {}) {
++ const username = usernameOf(options.username);
++ if (!host?.ownerDocument || typeof host.replaceChildren !== 'function') throw new TypeError('OVUser.card: pass a host element');
++ if (!username) throw new TypeError('OVUser.card: use a username of 3–24 letters, digits or underscores');
++ mounts.get(host)?.destroy();
++ const doc = host.ownerDocument;
++ styles(doc);
++ const section = element(doc, 'section', undefined, 'ovu');
++ section.setAttribute('data-ov-user-card', '');
++ host.replaceChildren(section);
++ let generation = 0, controller, disposed = false;
++ const profile = `${NETWORK}/@${encodeURIComponent(username)}`;
++ const api = {
++ ready: null,
++ refresh() {
++ if (disposed) return Promise.resolve(null);
++ const ticket = ++generation;
++ controller?.abort();
++ const request = controller = new AbortController();
++ const timer = setTimeout(() => request.abort(), 8000);
++ const status = element(doc, 'div', undefined, 'ovu-wait'); status.setAttribute('role', 'status'); status.setAttribute('aria-label', 'Loading the profile');
++ status.append(element(doc, 'i'), element(doc, 'i'), element(doc, 'i'));
++ section.style.setProperty('--ovu-h', String(hueOf(username)));
++ section.replaceChildren(element(doc, 'div', undefined, 'ovu-banner'), status); section.setAttribute('aria-busy', 'true');
++ const fetcher = options.fetchImpl || ((...args) => root.fetch(...args));
++ const url = `${String(options.networkUrl || NETWORK).replace(/\/+$/, '')}/api/v1/people/${encodeURIComponent(username)}/card`;
++ api.ready = Promise.resolve().then(() => fetcher(url, { credentials: 'omit', cache: 'no-store',
++ headers: { Accept: 'application/json' }, signal: request.signal }))
++ .then(async response => {
++ if (!response.ok) throw new Error('Profile unavailable');
++ const person = await response.json();
++ if (!person || !usernameOf(person.username)) throw new Error('Profile unavailable');
++ if (!disposed && ticket === generation) render(section, person);
++ return person;
++ }).catch(() => {
++ if (!disposed && ticket === generation) {
++ const message = element(doc, 'p', 'The profile did not load.', 'ovu-none'); message.setAttribute('role', 'status');
++ const acts = element(doc, 'div', undefined, 'ovu-acts'); const a = link(doc, 'Open the profile', profile); if (a) { a.className = ''; acts.appendChild(a); }
++ section.replaceChildren(element(doc, 'div', undefined, 'ovu-banner'), message, acts);
++ }
++ return null;
++ }).finally(() => {
++ clearTimeout(timer);
++ if (!disposed && ticket === generation) section.removeAttribute('aria-busy');
++ });
++ return api.ready;
++ },
++ destroy() {
++ if (disposed) return;
++ disposed = true; generation++; controller?.abort();
++ section.remove();
++ if (mounts.get(host) === api) mounts.delete(host);
++ },
++ };
++ mounts.set(host, api);
++ api.refresh();
++ return api;
++ }
++ /** Delegated hook: marked mentions/avatars and profile links; call only when the site opts in. */
++ function bind(scope, options = {}) {
++ if (!scope?.addEventListener) throw new TypeError('OVUser.bind: pass a document or element');
++ bindings.get(scope)?.();
++ const doc = scope.ownerDocument || scope;
++ let close = null, disposed = false;
++ function open(username, trigger) {
++ close?.();
++ if (root.OVWindow || selfSrc) {
++ needWindow().then((W) => {
++ if (!W) { openDialog(username, trigger); return; }
++ const host = element(doc, 'div');
++ const mounted = card(host, { ...options, username });
++ let win = null;
++ const cleanup = () => { mounted.destroy(); if (win) { const w = win; win = null; w.close(); } if (close === cleanup) close = null; };
++ close = cleanup;
++ win = W.open({ id: 'ov-user-card', kind: 'panel', size: 'sm', bare: true, title: `@${username}`, body: host, from: trigger, onClose: () => { win = null; mounted.destroy(); if (close === cleanup) close = null; } });
++ });
++ return true;
++ }
++ return openDialog(username, trigger);
++ }
++ function openDialog(username, trigger) {
++ const dialog = element(doc, 'dialog');
++ if (typeof dialog.showModal !== 'function') return false;
++ dialog.setAttribute('aria-label', `User @${username}`);
++ const dismiss = element(doc, 'button', 'Close', 'ovk-btn ovk-btn--quiet'); dismiss.type = 'button';
++ const host = element(doc, 'div');
++ dialog.append(dismiss, host); doc.body.appendChild(dialog);
++ const mounted = card(host, { ...options, username });
++ const cleanup = () => {
++ mounted.destroy(); dialog.remove(); trigger.focus?.();
++ if (close === cleanup) close = null;
++ };
++ close = cleanup;
++ dismiss.addEventListener('click', cleanup);
++ dialog.addEventListener('cancel', cleanup);
++ dialog.addEventListener('close', cleanup);
++ dialog.addEventListener('click', event => { if (event.target === dialog) cleanup(); });
++ dialog.showModal();
++ return true;
++ }
++ function handle(event) {
++ if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
++ if (event.type === 'click' && event.button > 0) return;
++ const target = event.target?.closest?.('[data-ov-user], [data-username], a[href]');
++ if (!target || target.closest('[data-ov-user-card]')) return;
++ if (event.type === 'keydown' && (!['Enter', ' '].includes(event.key) || ['A', 'BUTTON'].includes(target.tagName) || event.repeat)) return;
++ let username = usernameOf(target.getAttribute('data-ov-user') || target.getAttribute('data-username'));
++ if (!username && target.tagName === 'A') {
++ try {
++ const base = doc.baseURI || NETWORK;
++ const url = new URL(target.getAttribute('href'), base);
++ if (!['https:', 'http:'].includes(url.protocol) || ![new URL(base).origin, NETWORK, 'https://openvibe.live'].includes(url.origin)) return;
++ const match = /^\/@([A-Za-z0-9_]{3,24})\/?$/.exec(url.pathname);
++ if (match) username = match[1];
++ } catch { return; }
++ }
++ if (!username) return;
++ if (typeof options.onOpen === 'function') {
++ event.preventDefault(); options.onOpen({ username, trigger: target, event });
++ } else if (open(username, target)) event.preventDefault();
++ }
++ for (const type of ['click', 'contextmenu', 'keydown']) scope.addEventListener(type, handle);
++ const cleanup = () => {
++ if (disposed) return;
++ disposed = true; close?.();
++ for (const type of ['click', 'contextmenu', 'keydown']) scope.removeEventListener(type, handle);
++ if (bindings.get(scope) === cleanup) bindings.delete(scope);
++ };
++ bindings.set(scope, cleanup);
++ return cleanup;
++ }
++ return { card, bind };
++});
ddiff --git a/ov-wallet.css b/ov-wallet.css
iindex fc9f482..1fa0aa6 100644
---- a/ov-wallet.css
++++ b/ov-wallet.css
@@@ -54,3 +54,4 @@ html[data-ov-motion=reduced] .ov-wallet-body.is-first > * { animation: ov-wallet
html[data-ov-motion=off] .ov-wallet-body.is-first > *, html[data-ov-motion=off] .ov-wallet-wait { animation: none; }
@media (prefers-reduced-motion: reduce) { html:not([data-ov-motion]) .ov-wallet-body.is-first > * { animation: ov-wallet-fade .2s ease both; animation-delay: 0s; } }
@keyframes ov-wallet-fade { from { opacity: 0; } }
++.ov-wallet.ov-wallet-in{width:auto;max-height:none;border:0;border-radius:0;box-shadow:none;overflow:visible}.ovw:has(.ov-wallet-in){background:none;border:0}.ov-wallet-in{border-radius:inherit}.ov-wallet-in .ov-wallet-head{padding-right:64px}.ovw.is-sheet .ov-wallet-in .ov-wallet-head{padding-top:26px}.ov-wallet-in .ov-wallet-body{max-height:none;overflow:visible}.ovw-b:has(>.ov-wallet-in){padding:0}
ddiff --git a/ov-wallet.js b/ov-wallet.js
iindex 5078c74..f14ba19 100644
---- a/ov-wallet.js
++++ b/ov-wallet.js
@@@ -31,6 +31,21 @@
const keep = (d) => { try { root.sessionStorage.setItem(KEEP, JSON.stringify(d)); } catch { /* a closed store: it opens with placeholders */ } };
if (typeof document !== 'undefined' && document.addEventListener) document.addEventListener('openvibe-auth-changed', () => { try { root.sessionStorage.removeItem(KEEP); } catch { /* */ } });
++ // 4.202.0 (asks 304 and 318): the wallet sits in the one window (ov-window.js, loaded from beside this file): the
++ // same motion, keys, phone sheet and window style as every other window; its own <dialog> where that is missing.
++ const WALLET_REV = { 'ov-window.js': 'c882d11d85ff' };
++ const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
++ function needWindow() {
++ return new Promise((ok) => {
++ if (root.OVWindow) return ok(root.OVWindow);
++ const m = /^(https?:\/\/[^?#]*\/)ov-wallet(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
++ if (!m) return ok(null);
++ const sc = document.createElement('script');
++ sc.src = m[1] + 'ov-window.js' + (WALLET_REV['ov-window.js'] ? '?v=' + WALLET_REV['ov-window.js'] : '');
++ sc.onload = () => ok(root.OVWindow || null); sc.onerror = () => ok(null);
++ document.head.appendChild(sc);
++ });
++ }
async function open(options) {
close();
const token = (options.getToken ? options.getToken() : options.token) || null;
@@@ -41,7 +56,9 @@
// sheet asks Network's relays (/api/frame/wallet/…); Network forwards the bearer and never a cookie.
const walletApi = `${(options.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '')}/wallet`;
const controller = new AbortController();
-- const dialog = el('dialog', 'ov-wallet');
++ const W = await needWindow();
++ let win = null;
++ const dialog = W ? el('div', 'ov-wallet ov-wallet-in') : el('dialog', 'ov-wallet');
dialog.setAttribute('aria-labelledby', 'ov-wallet-title');
const header = el('header', 'ov-wallet-head'), heading = el('div');
heading.append(el('h2', '', 'Wallet'));
@@@ -49,18 +66,20 @@
const exit = el('button', 'ov-wallet-close', '×'); exit.type = 'button'; exit.setAttribute('aria-label', 'Close wallet');
header.append(heading, exit);
const body = el('div', 'ov-wallet-body is-first'), status = el('p', 'ov-wallet-status', '');
-- status.setAttribute('role', 'status'); body.append(status); dialog.append(header, body);
++ status.setAttribute('role', 'status'); body.append(status); dialog.append(header, body); if (W) exit.remove(); // the window's own × closes it
const previousFocus = document.activeElement, previousOverflow = document.documentElement.style.overflow;
// A site's surfaces.js locks the page for any modal dialog and restores it on close; locking here as well would
// make its saved state "hidden". Only a page without it needs the wallet's own lock.
-- const ownLock = !root.OVSurfaces;
++ const ownLock = !!W || !root.OVSurfaces; // in the window there is no <dialog> for surfaces.js to see
let disposed = false, data = null, allItems = null, equipped = {}, kinds = [], storeItems = [];
const here = { store: false, items: false }; // which parts have answered: until then a placeholder of their size stands in
const wait = (n, cls) => { const w = el('div', 'ov-wallet-wait-set'); w.setAttribute('aria-hidden', 'true'); for (let i = 0; i < n; i += 1) w.append(el('div', `ov-wallet-wait ${cls || ''}`)); return w; };
const shown = {}; // what each balance last showed, so a change counts from it
const pending = new Map();
const session = { close() {
-- if (disposed) return; disposed = true; controller.abort(); dialog.close(); dialog.remove();
++ if (disposed) return; disposed = true; controller.abort();
++ if (win) { const w = win; win = null; w.close(); } else if (dialog.close) dialog.close();
++ dialog.remove();
if (ownLock) document.documentElement.style.overflow = previousOverflow;
document.removeEventListener('openvibe-auth-changed', session.close);
if (active === session) active = null;
@@@ -247,10 +266,12 @@
}
}
exit.addEventListener('click', session.close);
-- dialog.addEventListener('cancel', (e) => { e.preventDefault(); session.close(); });
-- dialog.addEventListener('click', (e) => { if (e.target !== dialog) return; const r = dialog.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) session.close(); });
++ if (!W) dialog.addEventListener('cancel', (e) => { e.preventDefault(); session.close(); });
++ if (!W) dialog.addEventListener('click', (e) => { if (e.target !== dialog) return; const r = dialog.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) session.close(); });
document.addEventListener('openvibe-auth-changed', session.close);
-- document.body.append(dialog); dialog.showModal(); if (ownLock) document.documentElement.style.overflow = 'hidden'; exit.focus();
++ if (W) win = W.open({ id: 'ov-wallet', title: 'Wallet', bare: true, body: dialog, kind: 'dialog', size: 'lg', width: 860, from: previousFocus, onClose: () => { win = null; session.close(); } });
++ else { document.body.append(dialog); dialog.showModal(); exit.focus(); }
++ if (ownLock) document.documentElement.style.overflow = 'hidden';
// There at once: what this tab last showed, or room held for it. Then everything is asked side by side (the
// store and the kinds need nothing; the items need to know whose they are, which a kept wallet already says).
const seed = kept();
ddiff --git a/ov-window.js b/ov-window.js
nnew file mode 100644
iindex 0000000..865d2e6
---- /dev/null
++++ b/ov-window.js
@@@ -0,0 +1,211 @@
++/**
++ * ov-window.js (openvibe-shared 4.202.0): one window for every little interface. Asks 304 and 318, the owner: "a system
++ * that's used for all the windows [in] harmony … people can make their own or … custom … everything … feels connected".
++ * One frame (a head with the title and ×, a body, a foot) in three kinds, the same motion, the same keys:
++ * dialog: centred on a computer; a sheet from the bottom on a phone
++ * sheet: a sheet from the bottom on every screen
++ * panel: floats beside what opened it on a computer, draggable by its head (where you leave it is kept per id);
++ * a sheet on a phone
++ * It grows out of the button that opened it (the navbar's pop motion), closes with ×, Escape, a press outside (a dialog
++ * or a sheet; a panel stays until closed, and the page under it stays usable) or a swipe down on a phone, keeps the focus inside while open, and gives it back to that button. Windows stack: Escape closes the
++ * top one. The look follows the person's theme and their window style, <html data-ov-window="glass|solid|outline">
++ * (OVWindow.setStyle), on every window at once.
++ *
++ * const w = OVWindow.open({ id, title, body: node | text, foot: node, kind: 'dialog', size: 'md', from: button,
++ * label, onClose, bare, width }); bare: the content draws its own head (the title is still
++ * read); width: px, for content that wants its own
++ * w.el, w.body, w.foot, w.setTitle(text), w.close()
++ * A site's content goes in as nodes (or text, set as text): the window never parses markup it is given.
++ */
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OVWindow) return;
++
++ const STYLES = ['glass', 'solid', 'outline'];
++ const CSS = `.ovw-scrim { position: fixed; inset: 0; z-index: var(--ovw-z, 10040); background: rgba(3, 7, 15, .5); animation: ovw-fade .2s ease-out both; }
++.ovw-scrim.is-clear { background: transparent; }
++.ovw { position: fixed; z-index: var(--ovw-z, 10040); box-sizing: border-box; display: flex; flex-direction: column; max-height: min(86vh, 760px); color: var(--text-primary, #e6edf7);
++ font: 500 15px/1.45 var(--font, Inter, system-ui, -apple-system, 'Segoe UI', sans-serif); border-radius: 20px; overflow: hidden;
++ background: var(--ovw-bg); border: 1px solid var(--ovw-line); box-shadow: var(--ovw-shadow); -webkit-backdrop-filter: var(--ovw-blur); backdrop-filter: var(--ovw-blur);
++ transform-origin: var(--ov-pop-x, 50%) var(--ov-pop-y, 0); animation: ovw-in .28s cubic-bezier(.2, 1.2, .3, 1) both; }
++:root { --ovw-bg: color-mix(in srgb, var(--bg-elevated, var(--bg-secondary, var(--bg-primary, #111826))) 92%, transparent); --ovw-line: var(--border, rgba(255, 255, 255, .12));
++ --ovw-shadow: 0 28px 70px rgba(0, 0, 0, .45), 0 2px 8px rgba(0, 0, 0, .2); --ovw-blur: blur(16px) saturate(1.3); }
++html[data-ov-window="solid"] { --ovw-bg: var(--bg-elevated, var(--bg-secondary, var(--bg-primary, #111826))); --ovw-blur: none; }
++html[data-ov-window="outline"] { --ovw-bg: var(--bg-primary, #0b1220); --ovw-blur: none; --ovw-line: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); --ovw-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 20%, transparent), 0 18px 40px rgba(0, 0, 0, .35); }
++.ovw.is-out { animation: ovw-out .16s ease-in both; }
++.ovw-h { flex: none; display: flex; align-items: center; gap: 10px; min-height: 56px; padding: 6px 8px 6px 18px; border-bottom: 1px solid color-mix(in srgb, var(--ovw-line) 70%, transparent); }
++.ovw-t { flex: 1; min-width: 0; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 16.5px; font-weight: 700; letter-spacing: -.01em; }
++.ovw-x { flex: none; display: grid; place-items: center; width: 40px; height: 40px; padding: 0; border: 0; border-radius: 12px; background: none; color: var(--text-secondary, #a8b3c4); cursor: pointer; }
++.ovw-x:hover, .ovw-x:focus-visible { background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); color: var(--text-primary, #e6edf7); }
++.ovw-x:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
++.ovw-b { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 16px 18px 18px; }
++.ovw-f { flex: none; display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding: 12px 18px calc(12px + env(safe-area-inset-bottom, 0px)); border-top: 1px solid color-mix(in srgb, var(--ovw-line) 70%, transparent); }
++.ovw-f:empty { display: none; }
++.ovw.is-dialog { left: 50%; top: 50%; width: min(var(--ovw-w), calc(100vw - 32px)); transform: translate(-50%, -50%); animation-name: ovw-in-c; }
++.ovw.is-dialog.is-out { animation-name: ovw-out-c; }
++.ovw.is-panel { width: min(var(--ovw-w), calc(100vw - 24px)); }
++.ovw.is-panel .ovw-h { cursor: grab; touch-action: none; }
++.ovw.is-panel.is-dragging { animation: none; transition: none; box-shadow: 0 34px 90px rgba(0, 0, 0, .55); }
++.ovw.is-panel.is-dragging .ovw-h { cursor: grabbing; }
++.ovw.is-sheet { left: 0; right: 0; bottom: 0; width: auto; max-height: 92vh; margin: 0 auto; max-width: 720px; border-radius: 22px 22px 0 0; border-bottom: 0; animation: ovw-up .32s cubic-bezier(.2, 1, .3, 1) both; }
++.ovw.is-sheet.is-out { animation: ovw-down .2s ease-in both; }
++.ovw.is-sheet .ovw-h { position: relative; padding-top: 16px; touch-action: none; }
++.ovw.is-sheet .ovw-h::before { content: ""; position: absolute; left: 50%; top: 6px; width: 38px; height: 4px; margin-left: -19px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 28%, transparent); }
++.ovw.is-sheet.is-dragging { animation: none; }
++.ovw { --ovw-w: 520px; }
++.ovw.is-sm { --ovw-w: 380px; }
++.ovw.is-lg { --ovw-w: 760px; }
++.ovw.is-full { --ovw-w: 1100px; }
++@keyframes ovw-in { from { opacity: 0; transform: translateY(calc(-6px * var(--ov-move, 1))) scale(.94); } }
++@keyframes ovw-out { to { opacity: 0; transform: scale(.96); } }
++@keyframes ovw-in-c { from { opacity: 0; transform: translate(-50%, calc(-50% + 10px * var(--ov-move, 1))) scale(.95); } }
++@keyframes ovw-out-c { to { opacity: 0; transform: translate(-50%, -50%) scale(.97); } }
++@keyframes ovw-up { from { transform: translateY(100%); } }
++@keyframes ovw-down { to { transform: translateY(100%); } }
++@keyframes ovw-fade { from { opacity: 0; } }
++html[data-ov-motion=off] .ovw, html[data-ov-motion=off] .ovw-scrim { animation: none !important; }
++html.ovw-locked { overflow: hidden; }
++/* bare: the content carries its own head (a card's banner); the title is for screen readers, × floats on top, and
++ the top strip is still the handle (drag a panel, swipe a sheet). */
++.ovw.is-bare .ovw-h { position: absolute; left: 0; right: 0; top: 0; z-index: 3; min-height: 52px; padding: 6px; justify-content: flex-end; border: 0; background: none; }
++.ovw.is-bare .ovw-t { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
++.ovw.is-bare .ovw-x { width: 36px; height: 36px; border-radius: 50%; color: #fff; background: rgba(4, 8, 16, .42); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
++.ovw.is-bare .ovw-x:hover, .ovw.is-bare .ovw-x:focus-visible { background: rgba(4, 8, 16, .62); color: #fff; }
++.ovw.is-bare .ovw-b { padding: 0; }
++.ovw.is-bare.is-sheet .ovw-h { padding-top: 14px; }`;
++
++ const stack = [];
++ let uid = 0;
++ function styles() { if (document.getElementById('ovw-styles')) return; const st = document.createElement('style'); st.id = 'ovw-styles'; st.textContent = CSS; document.head.appendChild(st); }
++ const phone = () => !!(root.matchMedia && root.matchMedia('(max-width: 640px)').matches);
++ const focusables = (el) => [...el.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')].filter((x) => x.offsetParent !== null || x === document.activeElement);
++ const place = { get(id) { try { return JSON.parse(root.localStorage.getItem(`ov:window:${id}`) || 'null'); } catch { return null; } }, set(id, v) { try { root.localStorage.setItem(`ov:window:${id}`, JSON.stringify(v)); } catch { /* private mode */ } } };
++ function put(parent, v) { if (v == null) return; if (typeof v === 'string') parent.textContent = v; else parent.appendChild(v); }
++
++ function open(o = {}) {
++ styles();
++ const kind0 = ['dialog', 'sheet', 'panel'].includes(o.kind) ? o.kind : 'dialog';
++ const kind = phone() ? 'sheet' : kind0;
++ const id = o.id || `ovw-${++uid}`;
++ const opener = o.from || document.activeElement;
++ const modal = o.modal !== false && kind !== 'panel';
++ const z = 10040 + stack.length * 2;
++ const scrim = document.createElement('div');
++ scrim.className = `ovw-scrim${modal ? '' : ' is-clear'}`;
++ scrim.style.setProperty('--ovw-z', String(z));
++ const el = document.createElement('section');
++ el.className = `ovw is-${kind} is-${['sm', 'md', 'lg', 'full'].includes(o.size) ? o.size : 'md'}${o.bare ? ' is-bare' : ''}`;
++ el.style.setProperty('--ovw-z', String(z + 1));
++ if (Number(o.width) > 0) el.style.setProperty('--ovw-w', `${Math.round(Number(o.width))}px`);
++ el.setAttribute('role', 'dialog');
++ if (modal) el.setAttribute('aria-modal', 'true');
++ el.dataset.ovWindow = id;
++ const head = document.createElement('header'); head.className = 'ovw-h';
++ const title = document.createElement('h2'); title.className = 'ovw-t'; title.id = `${id}-t`; title.textContent = o.title || '';
++ const x = document.createElement('button'); x.type = 'button'; x.className = 'ovw-x'; x.setAttribute('aria-label', 'Close');
++ x.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
++ head.append(title, x);
++ const body = document.createElement('div'); body.className = 'ovw-b'; put(body, o.body);
++ const foot = document.createElement('footer'); foot.className = 'ovw-f'; put(foot, o.foot);
++ el.append(head, body, foot);
++ if (o.title) el.setAttribute('aria-labelledby', title.id); else el.setAttribute('aria-label', o.label || 'Window');
++ if (modal) document.body.append(scrim, el); else document.body.append(el);
++ if (modal) document.documentElement.classList.add('ovw-locked');
++
++ // Where it opens: a panel beside its opener (or where it was left), a dialog centred, a sheet at the bottom.
++ if (kind === 'panel') {
++ const saved = o.id ? place.get(o.id) : null;
++ const r = el.getBoundingClientRect(), vw = root.innerWidth, vh = root.innerHeight;
++ let left, top;
++ if (saved) { left = saved.x; top = saved.y; }
++ else if (opener && opener.getBoundingClientRect) { const f = opener.getBoundingClientRect(); left = f.left; top = f.bottom + 8; if (top + r.height > vh - 12) top = Math.max(12, f.top - r.height - 8); }
++ else { left = vw - r.width - 24; top = 72; }
++ el.style.left = `${Math.max(8, Math.min(left, vw - r.width - 8))}px`;
++ el.style.top = `${Math.max(8, Math.min(top, vh - 60))}px`;
++ }
++ if (opener && opener.getBoundingClientRect && kind !== 'sheet') {
++ const r = el.getBoundingClientRect(), f = opener.getBoundingClientRect();
++ el.style.setProperty('--ov-pop-x', `${Math.round(f.left + f.width / 2 - r.left)}px`);
++ el.style.setProperty('--ov-pop-y', `${Math.round(f.top + f.height / 2 - r.top)}px`);
++ }
++
++ let closed = false;
++ const w = {
++ id, el, body, foot,
++ setTitle(t) { title.textContent = t || ''; },
++ close(why) {
++ if (closed) return; closed = true;
++ const i = stack.indexOf(w); if (i >= 0) stack.splice(i, 1);
++ if (!stack.some((s) => s.modal)) document.documentElement.classList.remove('ovw-locked');
++ const gone = () => { el.remove(); scrim.remove(); };
++ if (document.documentElement.getAttribute('data-ov-motion') === 'off') gone();
++ else { el.classList.add('is-out'); scrim.style.opacity = '0'; scrim.style.transition = 'opacity .16s'; let done = false; const end = () => { if (!done) { done = true; gone(); } }; el.addEventListener('animationend', end, { once: true }); setTimeout(end, 260); }
++ if (opener && opener.focus && document.contains(opener)) { try { opener.focus({ preventScroll: true }); } catch { /* */ } }
++ if (typeof o.onClose === 'function') { try { o.onClose(why || 'close'); } catch (e) { console.error(e); } }
++ },
++ modal,
++ };
++ stack.push(w);
++ x.addEventListener('click', () => w.close('x'));
++ if (modal) scrim.addEventListener('pointerdown', () => w.close('outside'));
++ el.addEventListener('keydown', (e) => {
++ if (e.key === 'Tab') {
++ const f = focusables(el); if (!f.length) return;
++ const first = f[0], last = f[f.length - 1];
++ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
++ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
++ }
++ });
++
++ // A panel moves by its head on a computer; a sheet goes down when swiped down on a phone.
++ if (kind === 'panel' || kind === 'sheet') {
++ head.addEventListener('pointerdown', (e) => {
++ if (e.target.closest('.ovw-x') || (e.button !== undefined && e.button !== 0)) return;
++ const r = el.getBoundingClientRect(), sx = e.clientX, sy = e.clientY;
++ let moved = false;
++ el.classList.add('is-dragging');
++ try { head.setPointerCapture(e.pointerId); } catch { /* */ }
++ const move = (ev) => {
++ const dx = ev.clientX - sx, dy = ev.clientY - sy;
++ if (!moved && Math.hypot(dx, dy) < 4) return;
++ moved = true;
++ if (kind === 'panel') {
++ el.style.left = `${Math.max(8, Math.min(r.left + dx, root.innerWidth - r.width - 8))}px`;
++ el.style.top = `${Math.max(8, Math.min(r.top + dy, root.innerHeight - 56))}px`;
++ } else el.style.transform = `translateY(${Math.max(0, dy)}px)`;
++ };
++ const up = (ev) => {
++ head.removeEventListener('pointermove', move); head.removeEventListener('pointerup', up); head.removeEventListener('pointercancel', up);
++ el.classList.remove('is-dragging');
++ if (kind === 'panel') { if (moved && o.id) { const b = el.getBoundingClientRect(); place.set(o.id, { x: Math.round(b.left), y: Math.round(b.top) }); } return; }
++ const dy = ev.clientY - sy;
++ if (moved && dy > Math.min(140, r.height * 0.28)) w.close('swipe');
++ else { el.style.transition = 'transform .2s cubic-bezier(.2, 1, .3, 1)'; el.style.transform = ''; setTimeout(() => { el.style.transition = ''; }, 220); }
++ };
++ head.addEventListener('pointermove', move); head.addEventListener('pointerup', up); head.addEventListener('pointercancel', up);
++ });
++ }
++
++ const first = o.focus ? body.querySelector(o.focus) : focusables(body)[0];
++ (first || x).focus({ preventScroll: true });
++ return w;
++ }
++
++ document.addEventListener('keydown', (e) => {
++ if (e.key !== 'Escape' || !stack.length || e.defaultPrevented) return;
++ e.preventDefault();
++ stack[stack.length - 1].close('escape');
++ });
++
++ // The style is the person's display setting (theme-loader: saved, synced to their account, painted at boot as
++ // <html data-ov-window>); setStyle saves through it, or straight onto the page where the theme loader is absent.
++ function setStyle(name) {
++ const v = STYLES.includes(name) ? name : 'glass';
++ const T = root.OpenVibeThemeLoader;
++ if (T && T.display && typeof T.display.set === 'function') { try { T.display.set({ window: v }); } catch { /* */ } }
++ if (v === 'glass') document.documentElement.removeAttribute('data-ov-window'); else document.documentElement.setAttribute('data-ov-window', v);
++ return v;
++ }
++
++ root.OVWindow = { open, setStyle, styles: STYLES.slice(), top: () => stack[stack.length - 1] || null, closeAll: () => [...stack].reverse().forEach((w) => w.close('all')) };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/package-lock.json b/package-lock.json
iindex ba10db9..e25b8b4 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.201.0",
++ "version": "4.202.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.201.0",
++ "version": "4.202.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex f469728..406b09d 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.201.0",
++ "version": "4.202.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -23,6 +23,7 @@
"./assets": "./assets.js",
"./auth-client": "./auth-client.js",
"./brand": "./brand.js",
++ "./bottom": "./bottom.js",
"./browser-harness": "./browser-harness.js",
"./builtin-themes": "./builtin-themes.js",
"./cache-policy": "./cache-policy.js",
@@@ -127,6 +128,7 @@
"./trace": "./trace.js",
"./ui": "./ui.js",
"./url-resolver": "./url-resolver.js",
++ "./ov-user": "./ov-user.js",
"./user-card": "./user-card.js",
"./web-runtime": "./web-runtime.js",
"./route-transition": "./route-transition.js",
@@@ -138,6 +140,8 @@
"./thread": "./thread.js",
"./messages": "./messages.js",
"./ov-thread": "./ov-thread.js",
++ "./ov-window": "./ov-window.js",
++ "./ov-combo": "./ov-combo.js",
"./ov-composer": "./ov-composer.js",
"./ov-ai-choice": "./ai-choice.js",
"./ov-ai-choice.js": "./ov-ai-choice.js",
@@@ -164,6 +168,7 @@
],
"scripts": {
"test": "node test/run.js",
++ "audit:page-end": "node scripts/audit-page-end.js",
"create-site": "node scripts/create-site.js",
"size": "node scripts/size-report.js",
"build:icons": "python3 scripts/build-nav-icons.py",
ddiff --git a/room.css b/room.css
iindex c7d29d4..71af4c4 100644
---- a/room.css
++++ b/room.css
@@@ -48,7 +48,7 @@
--ovk-radius-sm: var(--ov-radius-1);
--ovk-tap: 44px;
--ovk-top: var(--ovnav-real-h, 52px); /* the navbar's height: sticky things stop under it */
-- --ovk-bottom: var(--ovnav-appbar-h, 0px); /* the app bar's height on phones (navbar.js sets it) */
++ --ovk-bottom: var(--ovb-dock, var(--ovnav-appbar-h, 0px)); /* the app bar (and a small player above it) on phones, measured by bottom.js */
--ovk-font: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
}
/* Shape personalities, as in kit.css but at zero specificity (plan L45: "Paper is quiet and square, Neon glows and bounces"). A quiet look has square corners
ddiff --git a/rooms.js b/rooms.js
iindex 892154b..f8313b6 100644
---- a/rooms.js
++++ b/rooms.js
@@@ -21,26 +21,26 @@
'use strict';
const HOODS = Object.freeze({
-- streams: 'Streams & places', games: 'Games & laughs', answers: 'News & answers',
-- make: 'Make & build', shop: 'Shop & swap', people: 'People & you', build: 'For developers',
++ watch: 'Watch & stream', talk: 'Talk & belong', play: 'Play', read: 'Read & ask',
++ shop: 'Shop & money', tools: 'Free tools', make: 'Make with AI', build: 'For developers',
});
// id, name in menus, host, neighbourhood, OKLCH hue, dark accent, light accent, hero line (≤ 8 words), kit icon, motif
const ROWS = [
-- ['fun', 'Fun', 'openvibe.fun', 'games', 350, '#F47DB9', '#A42C71', 'Something to do, alone, together, or live', 'spark', 'a city map where idea pins pop up and a quest card flips'],
++ ['fun', 'Fun', 'openvibe.fun', 'play', 350, '#F47DB9', '#A42C71', 'Something to do, alone, together, or live', 'spark', 'a city map where idea pins pop up and a quest card flips'],
['shoes', 'Shoes', 'open.shoes', 'shop', 25, '#FF7E76', '#B02A2D', 'Every shoe, every market, the right price', 'shoe', 'a shoe drawn as one line, price tags from several stores sliding into one all-in price'],
-- ['tours', 'Tours', 'openvibe.tours', 'streams', 50, '#F98942', '#AA3900', 'Go anywhere: fly, stay, walk, watch', 'map', 'a plane\'s dotted arc landing on a map, then the walking route drawing itself'],
-- ['today', 'Today', 'openvibe.today', 'answers', 75, '#E49900', '#994E00', 'Everything happening for you, today', 'calendar', 'the day\'s ring with events as dots and "now" sweeping; the brief\'s lines typing in'],
-- ['meme', 'Meme', 'openvibe.meme', 'games', 100, '#C3AC00', '#7B6100', 'The internet\'s meme database, rebuilt with AI', 'image', 'a family tree of memes growing branch by branch, stickers fanning out'],
-- ['domains', 'Domains', 'openvibe.domains', 'make', 125, '#94BB3B', '#4D7100', 'Name it, price it, run it', 'globe', 'a name typing itself while extensions cycle, prices settling into a sorted list'],
-- ['casino', 'Casino', 'openvibe.casino', 'games', 150, '#51C672', '#007C25', 'Learn it, play it, never pay it', 'game', 'a card fan and chips, with a probability curve drawing beneath'],
-- ['exchange', 'Exchange', 'openvibe.exchange', 'shop', 175, '#00CAA5', '#007F5E', 'Trade what people make', 'swap', 'two arrows looping an item between makers, and a passport card stamping "safe"'],
-- ['cam', 'Cam', 'openvibe.cam', 'streams', 200, '#00C7D2', '#007C89', 'Your camera, the world\'s cams, face to face', 'video', 'a shutter iris opening onto a grid of live cams, a flash, the photo flying to a gallery'],
-- ['surf', 'Surf', 'openvibe.surf', 'games', 225, '#00BEF6', '#0072AB', 'Ride maps, surf the web, surf what\'s on', 'wave', 'a first-person surf run: ramps, speed lines, the timer ticking'],
-- ['report', 'Report', 'openvibe.report', 'answers', 250, '#50B0FF', '#0064BE', 'Every number on OpenVibe, honest', 'chart', 'live counters ticking, a line chart drawing itself, the after-show card sliding in'],
++ ['tours', 'Tours', 'openvibe.tours', 'watch', 50, '#F98942', '#AA3900', 'Go anywhere: fly, stay, walk, watch', 'map', 'a plane\'s dotted arc landing on a map, then the walking route drawing itself'],
++ ['today', 'Today', 'openvibe.today', 'read', 75, '#E49900', '#994E00', 'Everything happening for you, today', 'calendar', 'the day\'s ring with events as dots and "now" sweeping; the brief\'s lines typing in'],
++ ['meme', 'Meme', 'openvibe.meme', 'play', 100, '#C3AC00', '#7B6100', 'The internet\'s meme database, rebuilt with AI', 'image', 'a family tree of memes growing branch by branch, stickers fanning out'],
++ ['domains', 'Domains', 'openvibe.domains', 'tools', 125, '#94BB3B', '#4D7100', 'Name it, price it, run it', 'globe', 'a name typing itself while extensions cycle, prices settling into a sorted list'],
++ ['casino', 'Casino', 'openvibe.casino', 'play', 150, '#51C672', '#007C25', 'Learn it, play it, never pay it', 'game', 'a card fan and chips, with a probability curve drawing beneath'],
++ ['exchange', 'Exchange', 'openvibe.exchange', 'build', 175, '#00CAA5', '#007F5E', 'Trade what people make', 'swap', 'two arrows looping an item between makers, and a passport card stamping "safe"'],
++ ['cam', 'Cam', 'openvibe.cam', 'watch', 200, '#00C7D2', '#007C89', 'Your camera, the world\'s cams, face to face', 'video', 'a shutter iris opening onto a grid of live cams, a flash, the photo flying to a gallery'],
++ ['surf', 'Surf', 'openvibe.surf', 'play', 225, '#00BEF6', '#0072AB', 'Ride maps, surf the web, surf what\'s on', 'wave', 'a first-person surf run: ramps, speed lines, the timer ticking'],
++ ['report', 'Report', 'openvibe.report', 'read', 250, '#50B0FF', '#0064BE', 'Every number on OpenVibe, honest', 'chart', 'live counters ticking, a line chart drawing itself, the after-show card sliding in'],
['website', 'Website', 'openvibe.website', 'make', 275, '#8EA0FF', '#4B53C2', 'Serious sites, no login needed', 'computer', 'a browser frame assembling a real site from blocks while a cursor types the prompt'],
-- ['vision', 'Vision', 'openvibe.vision', 'answers', 300, '#BB91FF', '#7444B4', 'See with the best model for the job', 'eye', 'an aperture with two models\' boxes racing on the same image, cost and time ticking'],
-- ['design', 'Design', 'openvibe.design', 'make', 325, '#DD84E2', '#913797', 'Make it yours, find the best AI designer', 'palette', 'the theme orb: drag it and the whole page recolours live; an A/B vote flips'],
++ ['vision', 'Vision', 'openvibe.vision', 'read', 300, '#BB91FF', '#7444B4', 'See with the best model for the job', 'eye', 'an aperture with two models\' boxes racing on the same image, cost and time ticking'],
++ ['design', 'Design', 'openvibe.design', 'tools', 325, '#DD84E2', '#913797', 'Make it yours, find the best AI designer', 'palette', 'the theme orb: drag it and the whole page recolours live; an A/B vote flips'],
];
const ROOMS = Object.freeze(Object.fromEntries(ROWS.map(([id, name, host, hood, hue, dark, light, line, icon, motif]) =>
[id, Object.freeze({ id, name, host, hood, hue, dark, light, line, icon, motif })])));
ddiff --git a/scripts/audit-page-end.js b/scripts/audit-page-end.js
nnew file mode 100644
iindex 0000000..34adefb
---- /dev/null
++++ b/scripts/audit-page-end.js
@@@ -0,0 +1,120 @@
++#!/usr/bin/env node
++'use strict';
++// openvibe-shared/scripts/audit-page-end — plan 280 ("That huge wasted space … in between that and the footer …
++// It's just waste"): measure the gap between the last thing on a site's home page and its footer, at 390 and 1280,
++// and list every site over the rule: content ends, then 32 px on a computer (24 on a phone), then the footer.
++//
++// node scripts/audit-page-end.js every site, 390 and 1280
++// node scripts/audit-page-end.js --footer-rule with the Shared rule (32/24) applied, to see what is left
++// node scripts/audit-page-end.js --hosts openvibe.wiki,openvibe.games --width 390
++// node scripts/audit-page-end.js --json research/page-end.json --strict
++//
++// A gap is the top of the footer minus the bottom of the lowest visible piece of content before it (text a person
++// reads, or a picture/video), in the page's own coordinates. A site's bottom padding, a spacer or a margin on the
++// last block shows up here; the footer's own margin-top is the Shared rule and is not site dead space.
++// Real Chrome through ../browser-harness (CHROME_BIN or the usual paths). Reads only: no site is changed.
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const h = require('../browser-harness');
++
++// The first-party homes (the Network's own zones), the same ring the footer and the menu list.
++const SITES = [
++ 'openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community',
++ 'openvibe.space', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news',
++ 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals',
++ 'openvibe.coupons', 'openre.stream', 'open.shoes', 'openvibe.today', 'openvibe.vision', 'openvibe.domains',
++ 'openvibe.report', 'openvibe.meme', 'openvibe.casino', 'openvibe.surf', 'openvibe.fun', 'openvibe.tours',
++ 'openvibe.exchange', 'openvibe.cam', 'openvibe.design',
++];
++const RULE = { computer: 32, phone: 24 };
++
++const args = process.argv.slice(2);
++const flag = (name) => args.includes(`--${name}`);
++const value = (name, fallback) => { const i = args.indexOf(`--${name}`); return i >= 0 && args[i + 1] ? args[i + 1] : fallback; };
++const widths = (value('width', '') ? [Number(value('width'))] : [1280, 390]).filter((n) => Number.isFinite(n) && n > 0);
++const hosts = (value('hosts', '') ? value('hosts', '').split(',') : SITES).map((s) => s.trim()).filter(Boolean);
++const footerRule = flag('footer-rule');
++const strict = flag('strict');
++const out = value('json', '');
++const quiet = flag('quiet');
++
++// What the page answers: where the footer starts, the lowest visible content before it, and the site's own padding
++// around the end of the page (the context that says where the dead space comes from).
++const MEASURE = `(() => {
++ const toFixed = (n) => Math.round(n * 10) / 10;
++ const footers = [...document.querySelectorAll('footer, #ov-footer')].filter((f) => {
++ const r = f.getBoundingClientRect();
++ return r.height > 0 && getComputedStyle(f).display !== 'none' && getComputedStyle(f).visibility !== 'hidden';
++ });
++ if (!footers.length) return { footer: false };
++ const footer = footers[footers.length - 1];
++ const footerTop = footer.getBoundingClientRect().top + scrollY;
++ let last = 0, who = '';
++ for (const el of document.body.querySelectorAll('*')) {
++ if (el.closest('footer, #ov-footer, nav, .openvibe-navbar, .ovnav-appbar, .ovmini, .ovdk, .ovdk-pill, script, style, link, meta, noscript, template, [aria-hidden="true"]')) continue;
++ const cs = getComputedStyle(el);
++ if (cs.display === 'none' || cs.visibility === 'hidden' || cs.position === 'fixed' || cs.position === 'sticky') continue;
++ const r = el.getBoundingClientRect();
++ if (!r.width || !r.height) continue;
++ const bottom = r.bottom + scrollY;
++ if (bottom > footerTop + 1) continue;
++ const ownText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
++ const media = el.matches('img,picture,video,canvas') || [...el.children].some((c) => c.matches('img,picture,video,canvas'));
++ if (!ownText && !media) continue;
++ if (bottom > last) { last = bottom; who = el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\\s+/)[0] : ''); }
++ }
++ const main = document.querySelector('main') || document.body;
++ return {
++ footer: true, gap: toFixed(Math.max(0, footerTop - last)), who,
++ footerTop: toFixed(footerTop), lastBottom: toFixed(last),
++ footerMargin: getComputedStyle(footer).marginTop,
++ mainPad: getComputedStyle(main).paddingBottom,
++ bodyPad: getComputedStyle(document.body).paddingBottom,
++ scrollHeight: document.documentElement.scrollHeight,
++ };
++})()`;
++
++const ink = (on) => (process.stdout.isTTY ? (s) => `\u001b[${on}m${s}\u001b[0m` : (s) => s);
++const dim = ink(2), bold = ink(1), red = ink(31);
++
++(async () => {
++ if (!h.findChrome()) {
++ console.log('audit-page-end: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++ }
++ const browser = await h.launch({ tmpDir: fs.mkdtempSync(path.join(os.tmpdir(), 'ov-page-end-')) });
++ const rows = [];
++ try {
++ for (const host of hosts) {
++ for (const width of widths) {
++ const rule = width <= 760 ? RULE.phone : RULE.computer;
++ const page = await h.openPage(browser, await browser.newContext(), { width, height: width <= 760 ? 844 : 900 });
++ let row;
++ try {
++ await page.goto(`https://${host}/`, { maxMs: 25000, quietMs: 700 });
++ if (footerRule) await page.evaluate(`(() => { const s = document.createElement('style'); s.textContent = ${JSON.stringify('@media (max-width: 760px) { footer.ovf, #ov-footer { margin-top: 24px !important; } } @media (min-width: 761px) { footer.ovf, #ov-footer { margin-top: 32px !important; } }')}; document.head.appendChild(s); })()`);
++ const m = await page.evaluate(MEASURE);
++ row = { host, width, rule, ...m, over: m.footer ? Math.max(0, m.gap - rule) : null };
++ } catch (e) {
++ row = { host, width, rule, error: e.message.slice(0, 120) };
++ }
++ await page.close();
++ rows.push(row);
++ if (!quiet) {
++ if (row.error) console.log(`${host.padEnd(20)} ${String(width).padStart(4)} ${red('unreachable')} ${dim(row.error)}`);
++ else if (!row.footer) console.log(`${host.padEnd(20)} ${String(width).padStart(4)} ${red('no footer found')}`);
++ else {
++ const over = row.gap > rule + 1;
++ console.log(`${host.padEnd(20)} ${String(width).padStart(4)} gap ${String(row.gap).padStart(6)}px rule ${String(rule).padStart(2)}px ${over ? red(`+${row.over}px over`) : bold('ok')} ${dim(`last ${row.who || '?'} · main pad ${row.mainPad} · footer margin ${row.footerMargin}`)}`);
++ }
++ }
++ }
++ }
++ } finally { await browser.close(); }
++ if (out) { fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true }); fs.writeFileSync(out, JSON.stringify({ generatedAt: new Date().toISOString(), footerRule, widths, rows }, null, 1)); console.log(`audit-page-end: wrote ${out}`); }
++ const measured = rows.filter((r) => r.footer && !r.error);
++ const over = measured.filter((r) => r.gap > r.rule + 1);
++ console.log(`audit-page-end: ${measured.length}/${rows.length} measured, ${over.length} over the rule${footerRule ? ' (Shared rule applied)' : ''}${over.length ? ': ' + [...new Set(over.map((r) => r.host))].join(', ') : ''}`);
++ if (strict && over.length) process.exit(1);
++})().catch((e) => { console.error('audit-page-end:', e.stack || e.message); process.exit(2); });
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex 2a60f80..bee3ab3 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -13,7 +13,7 @@ const ROOT = path.join(__dirname, '..');
// went out without ?v=, so a release reached phones up to a day late (max-age=300, stale-while-revalidate=86400): the
// owner still saw the old menu hours after the new one shipped (11-07).
const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
-- 'search.js', 'dock.js', 'presence.js', 'panels.js', 'surfaces.js', 'notification-ui.js', 'history.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js', 'nav-slots.js',
++ 'search.js', 'dock.js', 'presence.js', 'panels.js', 'surfaces.js', 'notification-ui.js', 'history.js', 'bottom.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js', 'nav-slots.js',
// 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.
@@@ -27,7 +27,7 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-code.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
-- 'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'ov-thread.js': { name: 'THREAD_REV', files: ['ov-composer.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };
++ 'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'ov-thread.js': { name: 'THREAD_REV', files: ['ov-composer.js', 'ov-combo.js'] }, 'ov-composer.js': { name: 'COMPOSER_REV', files: ['ov-window.js'] }, 'ov-user.js': { name: 'USER_REV', files: ['ov-window.js'] }, 'ov-wallet.js': { name: 'WALLET_REV', files: ['ov-window.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };
const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;
function writeOwn() {
for (const [file, { name, files }] of Object.entries(OWN)) {
ddiff --git a/search.js b/search.js
iindex 28a74b0..d560d95 100644
---- a/search.js
++++ b/search.js
@@@ -291,6 +291,8 @@
.ovs-grid.is-mini .ovs-tile { min-height: 48px; padding: 6px 8px; border-radius: 12px; transition: background-color .15s, transform .12s; }
.ovs-grid.is-mini .ovs-tile:active { transform: scale(.97); }
.ovs-grid.is-mini .ovs-ic { width: 32px; height: 32px; border-radius: 10px; }
++/* 4.202.0: on a phone a site's name sits under its mark, so the whole name shows (Download, Community: not "Downlo…"). */
++@media (max-width: 480px) { .ovs-grid.is-mini .ovs-tile { flex-direction: column; justify-content: center; gap: 5px; min-height: 64px; padding: 6px 2px 5px; text-align: center; } .ovs-grid.is-mini .ovs-tile b { max-width: 100%; font-size: 12.5px; } }
.ovs-group[data-sec="all"] { display: flex; align-items: center; gap: 10px; }
.ovs-group[data-sec="all"]::after { content: ""; flex: 1; height: 1px; background: var(--border, rgba(255,255,255,.1)); }
.ovs-tabs[hidden] { display: none; }
ddiff --git a/showcase.js b/showcase.js
iindex f8d0f5c..07dca1a 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -306,45 +306,107 @@ const jsonLd = (obj) => `<script type="application/ld+json">${JSON.stringify(obj
const faqJsonLd = (items) => jsonLd({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: (items || []).filter((q) => Array.isArray(q) && q[0] && q[1]).map(([q, a]) => ({ '@type': 'Question', name: String(q), acceptedAnswer: { '@type': 'Answer', text: String(a) } })) });
/**
-- * Where each product leads next on the network: real, crawlable links with descriptive text, chosen per product so a
-- * reader (and a search engine) sees how the sites fit together, not one long list of every domain. [to, what, href].
++ * Where each home page leads next on the network: the other places in the same menu group, in the group's own order
++ * (OpenVibe.Network frame/sites.js GROUPS, the owner's regrouping of 2026-11-10), so "More on OpenVibe" shows the
++ * same neighbourhood the launcher and the footer show. Up to four. [to, what, href]; `to` is the name and icon the
++ * card shows (Email is `email`, OpenRestream `openre`, Studio `studio`, Locker `locker`).
*/
--const RELATED = {
-- live: [['openre', 'Stream to every platform at once', 'https://openre.stream/'], ['video', 'Watch past streams and clips', 'https://openvibe.video/'], ['chat', 'Chat rooms and DMs', 'https://openvibe.chat/'], ['tips', 'Tips and alerts for your stream', 'https://openvibe.tips/']],
-- openre: [['live', 'Go live from your browser', 'https://openvibe.live/'], ['video', 'Keep your broadcasts', 'https://openvibe.video/'], ['chat', 'One chat for every platform', 'https://openvibe.chat/']],
-- video: [['live', 'Live streams now', 'https://openvibe.live/'], ['pics', 'Pictures, fast and open', 'https://openvibe.pics/'], ['tools', 'Convert and trim media', 'https://openvibe.tools/video-tools']],
-- media: [['video', 'Videos, clips and VODs', 'https://openvibe.video/'], ['pics', 'Share pictures', 'https://openvibe.pics/'], ['download', 'Your files, anywhere', 'https://openvibe.download/']],
-- chat: [['live', 'Live streams with chat', 'https://openvibe.live/'], ['actor', 'Agents that work while you are away', 'https://openvibe.actor/'], ['community', 'Posts and pastes', 'https://openvibe.community/']],
-- tools: [['codes', 'Build apps with AI', 'https://openvibe.codes/'], ['services', 'APIs and SDK for developers', 'https://openvibe.services/'], ['actor', 'Hand a task to an agent', 'https://openvibe.actor/'], ['space', 'Host a site or an app', 'https://openvibe.space/']],
-- codes: [['run', 'Run jobs on your own machine', 'https://openvibe.run/'], ['services', 'The SDK and APIs', 'https://openvibe.services/'], ['space', 'Publish what you build', 'https://openvibe.space/']],
-- actor: [['tools', 'Hundreds of free tools', 'https://openvibe.tools/'], ['watch', 'Alerts when something changes', 'https://openvibe.watch/'], ['bot', 'Drive a robot from your browser', 'https://openvibe.bot/'], ['codes', 'Coding agents', 'https://openvibe.codes/']],
-- services: [['tools', 'Tools with open APIs', 'https://openvibe.tools/developers'], ['space', 'Host apps on the network', 'https://openvibe.space/'], ['network', 'Sign-in for your app', 'https://openvibe.network/']],
-- space: [['codes', 'Build it with AI', 'https://openvibe.codes/'], ['services', 'The SDK your app calls', 'https://openvibe.services/'], ['host', 'Hosting, compared', 'https://openvibe.host/']],
-- run: [['codes', 'Coding agents', 'https://openvibe.codes/'], ['bot', 'Robots and machines', 'https://openvibe.bot/'], ['actor', 'Agents', 'https://openvibe.actor/']],
-- watch: [['deals', 'Deals worth sharing', 'https://openvibe.deals/'], ['work', 'Job alerts', 'https://openvibe.work/'], ['actor', 'An agent to act on alerts', 'https://openvibe.actor/']],
-- bot: [['live', 'Stream your robot', 'https://openvibe.live/'], ['actor', 'Let an agent drive', 'https://openvibe.actor/'], ['run', 'Your machines', 'https://openvibe.run/']],
-- work: [['watch', 'Alerts for new jobs', 'https://openvibe.watch/'], ['actor', 'An agent on your side', 'https://openvibe.actor/'], ['rent', 'Find a place to live', 'https://openvibe.rent/']],
-- rent: [['homes', 'Homes for sale', 'https://openvibe.homes/'], ['work', 'Jobs nearby', 'https://openvibe.work/'], ['food', 'Food near you', 'https://openvibe.food/']],
-- homes: [['rent', 'Rentals', 'https://openvibe.rent/'], ['deals', 'Deals', 'https://openvibe.deals/'], ['help', 'Help nearby', 'https://openvibe.help/']],
-- food: [['help', 'Help nearby', 'https://openvibe.help/'], ['deals', 'Grocery deals', 'https://openvibe.deals/'], ['coupons', 'Coupons that work', 'https://openvibe.coupons/']],
-- deals: [['coupons', 'Coupons that work', 'https://openvibe.coupons/'], ['reviews', 'Reviews with receipts', 'https://openvibe.reviews/'], ['watch', 'Price alerts', 'https://openvibe.watch/']],
-- coupons: [['deals', 'Deals worth sharing', 'https://openvibe.deals/'], ['reviews', 'Reviews', 'https://openvibe.reviews/'], ['trade', 'Markets and watchlists', 'https://openvibe.trade/']],
-- reviews: [['deals', 'Deals', 'https://openvibe.deals/'], ['coupons', 'Coupons', 'https://openvibe.coupons/'], ['host', 'Hosting reviews', 'https://openvibe.host/']],
-- trade: [['news', 'News, sourced', 'https://openvibe.news/'], ['watch', 'Market alerts', 'https://openvibe.watch/'], ['deals', 'Deals', 'https://openvibe.deals/']],
-- news: [['wiki', 'Knowledge, with sources', 'https://openvibe.wiki/'], ['blog', 'Long-form writing', 'https://openvibe.blog/'], ['community', 'Discuss it', 'https://openvibe.community/']],
-- blog: [['news', 'News', 'https://openvibe.news/'], ['wiki', 'Wiki', 'https://openvibe.wiki/'], ['community', 'Community', 'https://openvibe.community/']],
-- wiki: [['news', 'News', 'https://openvibe.news/'], ['help', 'Help', 'https://openvibe.help/'], ['blog', 'Blog', 'https://openvibe.blog/']],
-- community: [['chat', 'Chat', 'https://openvibe.chat/'], ['blog', 'Blog', 'https://openvibe.blog/'], ['live', 'Live', 'https://openvibe.live/']],
-- help: [['food', 'Food near you', 'https://openvibe.food/'], ['wiki', 'Guides', 'https://openvibe.wiki/'], ['community', 'Ask the community', 'https://openvibe.community/']],
-- games: [['quest', 'Quests and rewards', 'https://openvibe.quest/'], ['live', 'Stream your game', 'https://openvibe.live/'], ['vip', 'Memberships', 'https://openvibe.vip/']],
-- quest: [['games', 'Browser games', 'https://openvibe.games/'], ['vip', 'Memberships', 'https://openvibe.vip/'], ['tips', 'Tips', 'https://openvibe.tips/']],
-- tips: [['live', 'Go live', 'https://openvibe.live/'], ['vip', 'Memberships for creators', 'https://openvibe.vip/'], ['openre', 'Restream', 'https://openre.stream/']],
-- vip: [['tips', 'Tips and paid messages', 'https://openvibe.tips/'], ['live', 'Live', 'https://openvibe.live/'], ['games', 'Games', 'https://openvibe.games/']],
-- host: [['space', 'Host on OpenVibe', 'https://openvibe.space/'], ['reviews', 'Reviews', 'https://openvibe.reviews/'], ['tools', 'Network tools', 'https://openvibe.tools/network-tools']],
-- pics: [['video', 'Videos', 'https://openvibe.video/'], ['tools', 'Image tools', 'https://openvibe.tools/image-tools'], ['community', 'Community', 'https://openvibe.community/']],
-- network: [['live', 'Live', 'https://openvibe.live/'], ['tools', 'Tools', 'https://openvibe.tools/'], ['chat', 'Chat', 'https://openvibe.chat/'], ['actor', 'Agents', 'https://openvibe.actor/']],
++const GROUPS = {
++ watch: [
++ ['live', 'Go live, watch streams', 'https://openvibe.live/'],
++ ['video', 'Clips and past streams', 'https://openvibe.video/'],
++ ['pics', 'Share pictures', 'https://openvibe.pics/'],
++ ['download', 'Your files', 'https://openvibe.download/'],
++ ['openre', 'Stream everywhere', 'https://openre.stream/'],
++ ['cam', 'Your camera, in private', 'https://openvibe.cam/'],
++ ['tours', 'Cities, routes, stays', 'https://openvibe.tours/'],
++ ],
++ talk: [
++ ['community', 'Posts and pastes', 'https://openvibe.community/'],
++ ['chat', 'Chat with anyone', 'https://openvibe.chat/'],
++ ['tips', 'Support streamers', 'https://openvibe.tips/'],
++ ['vip', 'Back your creators', 'https://openvibe.vip/'],
++ ['email', 'Masks and inboxes', 'https://openvibe.email/'],
++ ['help', 'Help nearby', 'https://openvibe.help/'],
++ ['network', 'Your account', 'https://openvibe.network/'],
++ ],
++ play: [
++ ['games', 'Play in your browser', 'https://openvibe.games/'],
++ ['quest', 'Earn badges', 'https://openvibe.quest/'],
++ ['surf', 'Surf maps and the web', 'https://openvibe.surf/'],
++ ['fun', 'Something to do', 'https://openvibe.fun/'],
++ ['meme', 'Memes with receipts', 'https://openvibe.meme/'],
++ ['casino', 'Odds shown, chips free', 'https://openvibe.casino/'],
++ ['inventory', 'Your items', 'https://openvibe.my/inventory'],
++ ],
++ read: [
++ ['news', 'Sourced, not spun', 'https://openvibe.news/'],
++ ['today', 'Your schedule', 'https://openvibe.today/'],
++ ['report', 'Your numbers, honest', 'https://openvibe.report/'],
++ ['search', 'Search everything', 'https://search.openvibe.network/'],
++ ['wiki', 'Facts with sources', 'https://openvibe.wiki/'],
++ ['blog', 'Write long-form', 'https://openvibe.blog/'],
++ ['vision', 'Ask a picture', 'https://openvibe.vision/'],
++ ],
++ shop: [
++ ['deals', 'Deals people share', 'https://openvibe.deals/'],
++ ['coupons', 'Codes that work', 'https://openvibe.coupons/'],
++ ['shoes', 'Every shoe, every price', 'https://open.shoes/'],
++ ['reviews', 'Honest reviews', 'https://openvibe.reviews/'],
++ ['trade', 'Watch the markets', 'https://openvibe.trade/'],
++ ['rent', 'Places to rent', 'https://openvibe.rent/'],
++ ['work', 'Find a job', 'https://openvibe.work/'],
++ ],
++ tools: [
++ ['tools', 'Every online tool', 'https://openvibe.tools/'],
++ ['design', 'Try themes live', 'https://openvibe.design/'],
++ ['domains', 'Names and prices', 'https://openvibe.domains/'],
++ ['host', 'Compare hosting', 'https://openvibe.host/'],
++ ['studio', 'Edit photos in your browser', 'https://openvibe.studio/'],
++ ['locker', 'Files only you can open', 'https://openvibe.locker/'],
++ ['food', 'Food near you', 'https://openvibe.food/'],
++ ],
++ make: [
++ ['space', 'Host a site or app', 'https://openvibe.space/'],
++ ['codes', 'Code with any agent', 'https://openvibe.codes/'],
++ ['bot', 'Drive a robot', 'https://openvibe.bot/'],
++ ['actor', 'The agent engine', 'https://openvibe.actor/'],
++ ['computer', 'Your computer, your way', 'https://openvibe.computer/'],
++ ['connect', 'Share a port with Link', 'https://connect.openvibe.link/'],
++ ],
++ build: [
++ ['services', 'APIs, SDK and keys', 'https://openvibe.services/'],
++ ['exchange', 'AI models, one account', 'https://openvibe.exchange/'],
++ ['run', 'Run jobs anywhere', 'https://openvibe.run/'],
++ ['zone', 'S3 storage', 'https://openvibe.zone/'],
++ ['events', 'Event streams', 'https://openvibe.events/'],
++ ['watch', 'Know when it changes', 'https://openvibe.watch/'],
++ ['git', 'Host and browse code', 'https://git.openvibe.codes/'],
++ ],
};
--const DEFAULT_RELATED = RELATED.network;
++// The service names home pages pass that are not the card's own name (Video is media-hub, Media is its API,
++// Restream's service is openre, Email's mail, Studio's image, Locker's lock, Homes is Rent's section, Bench Report's).
++const RELATED_ALIAS = {
++ media: 'video', 'media-hub': 'video', video: 'video', stream: 'openre', openre: 'openre', restream: 'openre',
++ mail: 'email', email: 'email', image: 'studio', studio: 'studio', lock: 'locker', locker: 'locker',
++ homes: 'rent', bench: 'report',
++};
++const RELATED_ALL = Object.entries(GROUPS).flatMap(([group, members]) => members.map(([to, what, href]) => ({ group, to, what, href })));
++const RELATED_BY_ID = new Map(RELATED_ALL.map((x) => [x.to, x]));
++function relatedFor(key) {
++ const id = RELATED_ALIAS[key] || key;
++ const here = RELATED_BY_ID.get(id);
++ if (!here) return null;
++ return RELATED_ALL.filter((x) => x.group === here.group && x.to !== id).slice(0, 4).map((x) => [x.to, x.what, x.href]);
++}
++const RELATED = {};
++for (const x of RELATED_ALL) RELATED[x.to] = relatedFor(x.to);
++for (const [alias, id] of Object.entries(RELATED_ALIAS)) if (RELATED_BY_ID.has(id)) RELATED[alias] = relatedFor(id);
++// A product without its own list shows one place from each of the first four groups, in the groups' order.
++const DEFAULT_RELATED = ['live', 'community', 'games', 'news'].map((id) => {
++ const x = RELATED_BY_ID.get(id);
++ return [x.to, x.what, x.href];
++});
/**
* "Part of OpenVibe": up to four related products for this one (RELATED, or the caller's own items in the same
@@@ -676,4 +738,4 @@ html{overflow-x:clip}
}
`;
--module.exports = { NETWORK_JS, icon, room, hero, features, steps, code, demo, fold, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, cta, jsonLd, faqJsonLd, RELATED, CSS, STYLESHEET: 'showcase.css', esc };
++module.exports = { NETWORK_JS, icon, room, hero, features, steps, code, demo, fold, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, cta, jsonLd, faqJsonLd, RELATED, DEFAULT_RELATED, CSS, STYLESHEET: 'showcase.css', esc };
ddiff --git a/test/bottom-chrome.test.js b/test/bottom-chrome.test.js
nnew file mode 100644
iindex 0000000..ee9dae9
---- /dev/null
++++ b/test/bottom-chrome.test.js
@@@ -0,0 +1,186 @@
++'use strict';
++// The bottom of a phone (plan 279, the owner: "That fixed bottom position is kind of buggy … as you scroll up and
++// down, so let's make that nice and smooth and perfect across all sites"). A real headless Chrome at 360×740,
++// 390×844 and 430×932, with the browser's toolbar sliding away under the page:
++//
++// • the toolbar is emulated with a visualViewport whose height changes while the layout viewport stays put (the
++// shim below; a real toolbar can update the visible viewport before the layout one, which is where a bar at
++// bottom: 0 is left behind);
++// • the page scrolls up and down through four positions and the toolbar opens and closes;
++// • the app bar's bottom edge stays on the visible viewport's bottom (within 1 px), the small player stays 8 px
++// above the bar and the Dock's pill 16 px above the small player, and none of them overlap;
++// • the gaps do not jump by more than 1 px between samples.
++// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
++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('bottom-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const ROOT = path.join(__dirname, '..');
++const file = (res, name, type) => {
++ const p = path.join(ROOT, name);
++ res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' });
++ res.end(fs.existsSync(p) ? fs.readFileSync(p) : ''); // the baseline (origin/main) has no bottom.js: the page still loads
++};
++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Bottom</title>
++<style>body{margin:0;background:#0b1120;color:#e6edf7;font:15px system-ui;min-height:2600px}main{padding:20px 16px 64px}p{height:38px;margin:0 0 14px}</style></head>
++<body><main><h1>Bottom</h1>${'<p>The bar, the small player and the Dock all take their place from one measured number.</p>'.repeat(40)}</main>
++<script src="/bottom.js"></script>
++<script src="/appbar.js"></script>
++<script src="/mini.js"></script>
++<script src="/dock.js"></script>
++<script>
++const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++OpenVibeAppBar.render({
++ config: { appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Games', href: '/games', icon: 'fa-gamepad' }, { label: 'Tools', href: '/tools', icon: 'fa-wrench' }] },
++ links: [], activePage: null, nav: null,
++ navIcon: () => '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><circle cx="12" cy="12" r="7" fill="currentColor"/></svg>',
++ escapeAttr: esc, escapeHtml: esc, flatLinks: () => [],
++});
++OpenVibeMini.open({ page: '/', src: '/clip.webm', title: 'The harbour at night', by: 'Kim', t: 0, dur: 60, rate: 1, vol: 0, muted: true, playing: false });
++OpenVibeDock.mount({ apiBase: location.origin, token: 'tok', user: { id: 7, username: 'ada', display_name: 'Ada' } });
++</script></body></html>`;
++
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/clip.webm') return file(res, 'test/fixtures/ov-sync-clock.webm', 'video/webm');
++ if (u.pathname === '/bottom.js' || u.pathname === '/appbar.js' || u.pathname === '/mini.js' || u.pathname === '/dock.js') return file(res, u.pathname.slice(1), 'application/javascript');
++ if (u.pathname === '/api/v1/me/dock') {
++ const body = { state: { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: new Date().toISOString() }, counts: { running: 1, waiting: 0, unread: 0 }, sources_partial: false };
++ res.writeHead(200, { 'content-type': 'application/json' });
++ return res.end(JSON.stringify(body));
++ }
++ res.writeHead(200, { 'content-type': 'text/html', 'cache-control': 'no-store' });
++ res.end(PAGE);
++});
++
++// A visual viewport the test can shrink without resizing the layout viewport: the toolbar sliding over the page.
++const SHIM = `(() => {
++ let vh = innerHeight, top = 0;
++ const vv = new EventTarget();
++ Object.defineProperties(vv, {
++ height: { get: () => vh }, width: { get: () => innerWidth },
++ offsetTop: { get: () => top }, offsetLeft: { get: () => 0 },
++ pageTop: { get: () => top + scrollY }, pageLeft: { get: () => 0 }, scale: { get: () => 1 },
++ });
++ Object.defineProperty(window, 'visualViewport', { configurable: true, get: () => vv });
++ window.__ovvv = {
++ set(height, offset) { vh = height; top = offset || 0; vv.dispatchEvent(new Event('resize')); vv.dispatchEvent(new Event('scroll')); },
++ reset() { vh = innerHeight; top = 0; vv.dispatchEvent(new Event('resize')); vv.dispatchEvent(new Event('scroll')); },
++ };
++})();`;
++
++const MEASURE = `(() => {
++ const px = (n) => Math.round(n * 100) / 100;
++ const vv = visualViewport;
++ const visibleBottom = vv.offsetTop + vv.height;
++ const box = (sel) => {
++ const e = document.querySelector(sel);
++ if (!e) return null;
++ const cs = getComputedStyle(e);
++ if (e.hidden || cs.display === 'none' || cs.visibility === 'hidden') return null;
++ const r = e.getBoundingClientRect();
++ if (!r.width || !r.height) return null;
++ return { t: px(r.top), b: px(r.bottom), l: px(r.left), r: px(r.right), w: px(r.width), h: px(r.height) };
++ };
++ const bar = box('.ovnav-appbar'), mini = box('.ovmini:not(.is-leaving)'), pill = box('#ovdk-pill');
++ const area = (a, b) => (!a || !b) ? 0 : px(Math.max(0, Math.min(a.r, b.r) - Math.max(a.l, b.l)) * Math.max(0, Math.min(a.b, b.b) - Math.max(a.t, b.t)));
++ return {
++ visibleBottom: px(visibleBottom), innerHeight, lift: px(parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--ovb-lift')) || 0),
++ bar, mini, pill,
++ gaps: {
++ bar: bar && px(visibleBottom - bar.b),
++ mini: (bar && mini) ? px(bar.t - mini.b) : null,
++ pill: (mini && pill) ? px(mini.t - pill.b) : null,
++ },
++ overlap: { barMini: area(bar, mini), barPill: area(bar, pill), miniPill: area(mini, pill) },
++ };
++})()`;
++
++const SIZES = [[360, 740], [390, 844], [430, 932]];
++const SCROLLS = [0, 900, 1800, 500];
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const browser = await h.launch({ tmpDir: fs.mkdtempSync(path.join(os.tmpdir(), 'ov-bottom-')) });
++ const report = [];
++ let failed = 0;
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ try {
++ for (const [width, height] of SIZES) {
++ const page = await h.openPage(browser, await browser.newContext(), { width, height });
++ try {
++ await page.send('Page.addScriptToEvaluateOnNewDocument', { source: SHIM });
++ await page.goto(`${base}/`, { maxMs: 15000, quietMs: 600 });
++ const until = async (expr, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { let v = false; try { v = await page.evaluate(expr); } catch { v = false; } if (v) return true; await wait(80); } return false; };
++ assert.ok(await until(`!!document.querySelector('.ovnav-appbar') && !!document.querySelector('.ovmini:not(.is-leaving)') && !!document.querySelector('#ovdk-pill') && !document.querySelector('#ovdk-pill').hidden`), `${width}x${height}: the bar, the small player and the Dock's pill are on the page`);
++ await wait(450); // the small player's entrance and the Dock's first read
++ const samples = [];
++ for (const [i, y] of SCROLLS.entries()) {
++ for (const [label, visible] of [['toolbar-shown', height - 56], ['toolbar-collapsed', height], ['toolbar-half', height - 28], ['toolbar-collapsed', height]]) {
++ await page.evaluate(`__ovvv.set(${visible}); scrollTo(0, ${y}); 0`);
++ await page.evaluate('new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))');
++ await wait(90);
++ const m = await page.evaluate(MEASURE);
++ m.y = y; m.toolbar = label; m.i = i;
++ samples.push(m);
++ }
++ }
++ // The toolbar sliding while the page is still: the same samples, for the jump.
++ for (const visible of [height, height - 56, height - 56, height]) {
++ await page.evaluate(`__ovvv.set(${visible}); scrollTo(0, 900); 0`);
++ await page.evaluate('new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))');
++ await wait(90);
++ samples.push({ ...(await page.evaluate(MEASURE)), y: 900, toolbar: 'slide', i: 'slide' });
++ }
++
++ const failures = [];
++ const check = (ok, text) => { if (!ok) failures.push(text); };
++ for (const m of samples) {
++ const at = `${m.y}px/${m.toolbar}`;
++ check(!!m.bar, `${at}: the app bar is on the page`);
++ check(!!m.mini, `${at}: the small player is on the page`);
++ check(!!m.pill, `${at}: the Dock's pill is on the page`);
++ if (!m.bar || !m.mini || !m.pill) continue;
++ check(Math.abs(m.gaps.bar) <= 1, `${at}: the app bar's bottom is ${m.gaps.bar}px from the visible bottom`);
++ check(Math.abs(m.overlap.barMini) === 0, `${at}: the bar and the small player overlap by ${m.overlap.barMini}px²`);
++ check(Math.abs(m.overlap.barPill) === 0, `${at}: the bar and the Dock overlap by ${m.overlap.barPill}px²`);
++ check(Math.abs(m.overlap.miniPill) === 0, `${at}: the small player and the Dock overlap by ${m.overlap.miniPill}px²`);
++ check(Math.abs(m.gaps.mini - 8) <= 1, `${at}: the small player sits ${m.gaps.mini}px above the bar (8)`);
++ check(Math.abs(m.gaps.pill - 16) <= 1, `${at}: the Dock sits ${m.gaps.pill}px above the small player (16)`);
++ }
++ const spread = (key) => { const v = samples.map((m) => m.gaps[key]).filter((n) => typeof n === 'number'); return v.length ? Math.max(...v) - Math.min(...v) : 0; };
++ check(spread('bar') <= 1, `${width}x${height}: the bar jumps ${spread('bar')}px between samples`);
++ check(spread('mini') <= 1, `${width}x${height}: the small player jumps ${spread('mini')}px between samples`);
++ check(spread('pill') <= 1, `${width}x${height}: the Dock jumps ${spread('pill')}px between samples`);
++ const barGaps = samples.map((m) => m.gaps.bar);
++ const miniGaps = samples.map((m) => m.gaps.mini);
++ const pillGaps = samples.map((m) => m.gaps.pill);
++ report.push({
++ size: `${width}x${height}`, samples: samples.length,
++ bar: [Math.min(...barGaps), Math.max(...barGaps)],
++ mini: [Math.min(...miniGaps), Math.max(...miniGaps)],
++ pill: [Math.min(...pillGaps), Math.max(...pillGaps)],
++ overlap: Math.max(...samples.map((m) => m.overlap.barMini + m.overlap.barPill + m.overlap.miniPill)),
++ });
++ if (failures.length) {
++ failed++;
++ console.error(`${width}x${height}: ${failures.length} failure(s)`);
++ for (const f of failures.slice(0, 6)) console.error(` - ${f}`);
++ }
++ } finally { await page.close(); }
++ }
++ } finally { await browser.close(); }
++ for (const r of report) console.log(`bottom-chrome ${r.size}: ${r.samples} samples, bar gap ${r.bar[0]}..${r.bar[1]}px, mini ${r.mini[0]}..${r.mini[1]}px, pill ${r.pill[0]}..${r.pill[1]}px, overlap ${r.overlap}px²`);
++ if (failed || report.length !== SIZES.length) { console.error(`bottom-chrome: FAILED at ${failed} of ${SIZES.length} sizes`); process.exit(1); }
++ console.log('bottom-chrome: the bar, the small player and the Dock hold the bottom at every size and toolbar state');
++ server.close();
++})().catch((e) => { console.error('bottom-chrome:', e.stack || e.message); try { server.close(); } catch { /* */ } process.exit(1); });
ddiff --git a/test/display-comfort.test.js b/test/display-comfort.test.js
iindex e3b9c89..2eacc9a 100644
---- a/test/display-comfort.test.js
++++ b/test/display-comfort.test.js
@@@ -82,7 +82,15 @@ p.L.display.set({ icons: 'auto' }, { local: true });
assert.strictEqual(p.html.getAttribute('data-ov-icons'), null);
p = load({ store: new Map([['ov_display', JSON.stringify({ icons: 'line' })]]) });
assert.strictEqual(p.html.getAttribute('data-ov-icons'), 'line', 'a saved preference paints on the next page');
--assert.ok(/'data-ov-icons'\]/.test(SRC) || SRC.includes("'data-ov-oled', 'data-ov-icons']"), 'the preference travels with the look (PAINT_ATTRS)');
++assert.ok(/'data-ov-icons'[,\]]/.test(SRC) && /const PAINT_ATTRS = \[[^\]]*'data-ov-icons'/.test(SRC), 'the preference travels with the look (PAINT_ATTRS)');
++// Windows (4.202.0, asks 304 and 318): the person's window style is a display setting too, painted at boot.
++p.L.display.set({ window: 'outline' }, { local: true });
++assert.strictEqual(p.html.getAttribute('data-ov-window'), 'outline');
++p.L.display.set({ window: 'neon' }, { local: true });
++assert.strictEqual(p.html.getAttribute('data-ov-window'), 'outline', 'an unknown window style changes nothing');
++p.L.display.set({ window: 'glass' }, { local: true });
++assert.strictEqual(p.html.getAttribute('data-ov-window'), null, 'glass (the default) paints nothing');
++assert.ok(/const PAINT_ATTRS = \[[^\]]*'data-ov-window'/.test(SRC), 'the window style travels with the look');
const glyphCss = require('fs').readFileSync(require('path').join(__dirname, '..', 'ov-glyphs.js'), 'utf8');
assert.ok(glyphCss.includes('html[data-ov-icons=solid]{--ovg-pref:99999px}html[data-ov-icons=line]{--ovg-pref:0px}'), 'the glyphs read the preference');
assert.ok(glyphCss.includes('var(--ovg-pref, var(--ovg-solid-below, 18.4px))'), 'the person\'s preference wins over an area\'s switch point');
ddiff --git a/test/dock-code-chrome.test.js b/test/dock-code-chrome.test.js
iindex ee67191..d350fee 100644
---- a/test/dock-code-chrome.test.js
++++ b/test/dock-code-chrome.test.js
@@@ -41,7 +41,7 @@ const server = http.createServer((req, res) => {
res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:0;background:#111;color:#eee;font-family:sans-serif">
<div id="part" style="width:380px"></div><script src="/dock-code.js"></script>
<script>window.signedIn = ${u.searchParams.get('out') ? 'false' : 'true'};
--window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: () => (window.signedIn ? 'tok_abc' : null), frameBase: location.origin + '/api/frame', codes: location.origin + '/codes', active: () => true });</script>`);
++window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: () => (window.signedIn ? 'tok_abc' : null), frameBase: location.origin + '/api/frame', codes: location.origin + '/codes', active: () => true, take: () => { const t = window.handed || ''; window.handed = ''; return t; } });</script>`);
});
(async () => {
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
@@@ -82,6 +82,14 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (
assert.deepEqual(b, { text: '', note: 'Started. Follow it on openvibe.codes', link: 'https://openvibe.codes/tasks/t3' });
assert.equal((await rows())[0].s, 'Waiting', 'a queued task says it is waiting');
++ // Vibe's Build it in Code: the words another part hands over go in the box, ready to start.
++ await page.evaluate(`window.handed = 'Build a goal bar for my sub goal'; window.part.refresh()`);
++ b = await page.evaluate(`({ text: document.querySelector('.ovdc-text').value, note: document.querySelector('.ovdc-note').textContent, focused: document.activeElement === document.querySelector('.ovdc-text'), go: document.querySelector('.ovdc-go').disabled })`);
++ assert.deepEqual(b, { text: 'Build a goal bar for my sub goal', note: 'From your chat with Vibe. Start it, or change it first.', focused: true, go: false }, 'handed-over words are in the box, ready to start (My code keeps its repository)');
++ await page.evaluate(`document.querySelector('[data-mode="create"]').click()`);
++ assert.equal(await page.evaluate(`document.querySelector('.ovdc-go').disabled`), false);
++ await page.evaluate(`(() => { const t = document.querySelector('.ovdc-text'); t.value = ''; t.dispatchEvent(new Event('input')); })()`);
++
// Stop.
await page.evaluate(`[...document.querySelectorAll('.ovdc-task')].find((li) => li.textContent.includes('goal bar')).querySelector('.ovdc-stop').click()`);
assert.ok(await until(`[...document.querySelectorAll('.ovdc-task')].some((li) => li.textContent.includes('goal bar') && li.textContent.includes('Stopped'))`), 'Stop stops it');
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex 2bc47d2..6ee523b 100644
---- a/test/dock-size.test.js
++++ b/test/dock-size.test.js
@@@ -21,7 +21,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// time something is sent, not on every page.
// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
--const DOCK_BROTLI_BUDGET = 21040; // 20.55 KB with the Code tab (ask 350, 2026-11-10: Chat · Code · Agents; the tab lives in dock-code.js, only its mount branch ships in dock.js); was 20940 with "Open in Chat" as an icon over your chats and in a thread (ask 336, 2026-11-10); 20.30 KB 20.30 KB with a robot for Agents, so the spark means one thing (Vibe) in the Dock as in the menu's row of places (4.194.0, 2026-11-10); 20.25 KB: 20.04 KB with a phone's sheet opening and closing as the menu's does (a circle from the orb, the menu's close button in the menu's place, a ring as it opens; 4.186.0, 2026-11-10); 20.00 KB: 19.80 KB with a phone's sheet opening out of the navbar's orb (4.185.0, 2026-11-10: the hand-off's styles and its two functions, about 0.95 KB; it runs as the sheet opens, so it cannot be a sibling loaded later); 19.00 KB: 18.63 KB with Dock v2's AI chats row, the conversation list, the group faces and the maker's marks (2026-11-09); 18.50 KB: 18.1 KB with the Actor tab, the sheets' shared surface and Dock chats from a site's session (4.162.0, 2026-11-09); 18.34 KB measured on 2026-11-09 with Dock v2's Chats summary and its lazy Agents rows; 18.00 KB: 17.9 KB with the orb's × as Close, dots drawn on the centre line and the pop from the pill (4.161.0, 2026-11-09); 17.50 KB: 17.28 KB with the full-screen phone Dock, the one-row header and its More menu (2026-11-09, owner: the pin unclear, pop-overs that did not close) + headroom; 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
++const DOCK_BROTLI_BUDGET = 21100; // 20.61 KB with 4.202.0 page-end docking (#442, 2026-11-11); was 20.55 KB with the Code tab (ask 350, 2026-11-10: Chat · Code · Agents; the tab lives in dock-code.js, only its mount branch ships in dock.js); was 20940 with "Open in Chat" as an icon over your chats and in a thread (ask 336, 2026-11-10); 20.30 KB 20.30 KB with a robot for Agents, so the spark means one thing (Vibe) in the Dock as in the menu's row of places (4.194.0, 2026-11-10); 20.25 KB: 20.04 KB with a phone's sheet opening and closing as the menu's does (a circle from the orb, the menu's close button in the menu's place, a ring as it opens; 4.186.0, 2026-11-10); 20.00 KB: 19.80 KB with a phone's sheet opening out of the navbar's orb (4.185.0, 2026-11-10: the hand-off's styles and its two functions, about 0.95 KB; it runs as the sheet opens, so it cannot be a sibling loaded later); 19.00 KB: 18.63 KB with Dock v2's AI chats row, the conversation list, the group faces and the maker's marks (2026-11-09); 18.50 KB: 18.1 KB with the Actor tab, the sheets' shared surface and Dock chats from a site's session (4.162.0, 2026-11-09); 18.34 KB measured on 2026-11-09 with Dock v2's Chats summary and its lazy Agents rows; 18.00 KB: 17.9 KB with the orb's × as Close, dots drawn on the centre line and the pop from the pill (4.161.0, 2026-11-09); 17.50 KB: 17.28 KB with the full-screen phone Dock, the one-row header and its More menu (2026-11-09, owner: the pin unclear, pop-overs that did not close) + headroom; 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js'];
// ── the budget ──────────────────────────────────────────────
ddiff --git a/test/footer-folds.test.js b/test/footer-folds.test.js
iindex 5b3ea49..5df8fb5 100644
---- a/test/footer-folds.test.js
++++ b/test/footer-folds.test.js
@@@ -38,4 +38,12 @@ assert.ok(phone.length < css.length, 'the phone block exists');
assert.match(phone, /\.ovf-fold>summary\{pointer-events:auto;cursor:pointer;[^}]*min-height:48px\}/);
assert.match(phone, /\.ovf-fold\[open\]>summary::after\{transform:/);
++// An untitled or empty group is never a fold (4.202.0's screenshots showed two "0 links" folds with no heading).
++{
++ const empty = footer.buildHTML({ service: 'live', variant: 'full', links: [{ heading: '', items: [] }, { heading: 'Nothing', items: [] }, { heading: '', items: [{ name: 'X', url: '/x' }] }, ...links] });
++ assert.ok(!/<h3><\/h3>/.test(empty), 'no fold without a heading');
++ assert.ok(!/>0 links</.test(empty), 'no fold with no links');
++ assert.strictEqual((empty.match(/<details class="ovf-fold"/g) || []).length, folds.length, 'only the real columns are folds');
++}
++
console.log('footer-folds: all checks passed');
ddiff --git a/test/fx.test.js b/test/fx.test.js
iindex e90a17d..4a02b91 100644
---- a/test/fx.test.js
++++ b/test/fx.test.js
@@@ -1,8 +1,9 @@
'use strict';
--// ov-fx.js (4.66.0, plan L45): the network's effects by data-fx. Each name wires its class or listener; a reveal hides
--// only what is below the fold and shows it when it comes on screen; a number counts up and ends on its own text;
--// under reduced motion or Calm nothing is hidden, nothing counts and no confetti falls; markup added later is wired;
--// the navbar loads it only for a page that asks. linkedom with a fake IntersectionObserver and matchMedia.
++// ov-fx.js (4.66.0, plan L45; the reveal remade for 278): the network's effects by data-fx. Each name wires its class
++// or listener; a reveal gives its heading, rule and cards a start state only when they are below the fold and then
++// shows them once — with the scroll timeline where view() exists (no script on the main thread while scrolling) and
++// one shared IntersectionObserver otherwise. Calm keeps a short fade; Off shows everything at once. linkedom with a
++// fake IntersectionObserver, matchMedia and CSS.supports.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
@@@ -10,29 +11,32 @@ const { parseHTML } = require('linkedom');
const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-fx.js'), 'utf8');
--function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {
++function setup({ reduce = false, calm = false, off = false, timeline = false, html = '' } = {}) {
const { window, document, MutationObserver } = parseHTML(`<!doctype html><html${off ? ' data-ov-motion="off"' : calm ? ' data-ov-motion="reduced"' : ''}><head></head><body>${html}</body></html>`);
const observed = [];
class IO { constructor(cb) { this.cb = cb; } observe(el) { observed.push(el); } unobserve() {} fire(el) { this.cb([{ target: el, isIntersecting: true }]); } }
let io = null;
const frames = [];
++ const timers = [];
window.IntersectionObserver = class extends IO { constructor(cb) { super(cb); io = this; } };
window.MutationObserver = MutationObserver;
++ window.CSS = { supports: (q) => !!timeline && /animation-timeline/.test(q) };
window.matchMedia = (q) => ({ matches: /reduce/.test(q) ? reduce : /hover/.test(q) });
window.innerHeight = 800;
window.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length; };
window.cancelAnimationFrame = () => {};
-- window.setTimeout = (fn) => { fn(); return 0; };
++ window.setTimeout = (fn) => { timers.push(fn); return timers.length; };
window.performance = { now: () => 0 };
for (const el of document.querySelectorAll('[data-top]')) el.getBoundingClientRect = () => ({ top: Number(el.getAttribute('data-top')), left: 0, width: 100, height: 40 });
window.OVFx = undefined; // linkedom windows share their expando properties
new Function('window', SRC)(window);
const run = (t) => { let n = 0; while (frames.length && n++ < 500) frames.shift()(t); };
-- return { window, document, io: () => io, observed, run };
++ const flush = () => { let n = 0; while (timers.length && n++ < 500) timers.shift()(); };
++ return { window, document, io: () => io, observed, run, flush };
}
(async () => {
-- // ── names wire their classes; the CSS carries the motion tokens and the reduced-motion guards ──
++ // ── names wire their classes; the CSS carries the motion tokens and the Off/Calm guards ──
{
const t = setup({ html: '<a id="a" data-fx="glint lift press glow">x</a><span id="s" data-fx="shimmer"></span><b id="r" data-fx="ripple">r</b>' });
const a = t.document.getElementById('a');
@@@ -43,7 +47,7 @@ function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {
assert.match(css, /html\[data-ov-motion=reduced\]\{--ov-move:\.35;--ov-dur-1:\.15s;[^}]*--ov-ease-spring:var\(--ov-ease-out\)\}/, 'Calm: a third of the distance, the calm lengths, no overshoot; never zero');
assert.match(css, /@media \(prefers-reduced-motion:reduce\)\{html:not\(\[data-ov-motion\]\)\{--ov-move:\.35;/, 'the same when the device asks and no loader has spoken');
assert.match(css, /html\[data-ov-motion=off\]\{--ov-move:0;--ov-dur-1:0s/, 'only Off zeroes them');
-- assert.ok(css.includes('.ovfx-lift:hover{transform:translateY(calc(-3px * var(--ov-move,1)))') && css.includes('.ovfx-hide{opacity:0;transform:translateY(calc(14px * var(--ov-move,1)))}'), 'distances are multiplied by --ov-move');
++ assert.ok(css.includes('.ovfx-lift:hover{transform:translateY(calc(-3px * var(--ov-move,1)))') && css.includes('.ovfx-head.ovfx-wait{opacity:0;transform:translateY(var(--ovfx-rise,12px))'), 'distances are multiplied by --ov-move');
assert.ok(css.includes('html[data-ov-motion=reduced] .ovfx-glint:not([data-fx-demo] *)::after{opacity:.55}'), 'Calm keeps the glint, fainter and slower');
assert.ok(css.includes('html[data-ov-motion=off] .ovfx-glint:not([data-fx-demo] *)::after{display:none}'), 'motion off stops the glint, outside a demo scope');
assert.ok(!/data-ov-motion=reduced\][^{]*\{[^}]*(animation|transition):none/.test(css), 'nothing is stopped under Calm');
@@@ -57,47 +61,110 @@ function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {
assert.ok(css.indexOf('html[data-ov-motion=reduced]{--ov-move:.35') > css.indexOf('html:is([data-theme=neon-tokyo]'), 'Calm comes last, so it wins over any feel');
}
-- // ── reveal: below the fold it waits for the screen; on screen it is never hidden ──
++ // ── the reveal's CSS (278): heading out of a blur, rule from the left, six card steps, scroll timeline first ──
{
-- const t = setup({ html: '<ul id="low" data-fx="reveal" data-fx-stagger="40" data-top="1400"><li>a</li><li>b</li><li>c</li></ul><ul id="top" data-fx="reveal" data-top="100"><li>x</li></ul>' });
++ const t = setup();
++ const css = t.document.getElementById('ov-fx-css').textContent;
++ assert.ok(css.includes('--ovfx-rise:12px') && css.includes('@media (max-width:600px){.ovfx-head{--ovfx-rise:8px}'), '12 px on a computer, 8 on a phone');
++ assert.ok(css.includes('@keyframes ovfxHeadRise{from{opacity:0;transform:translateY(calc(var(--ovfx-rise,12px) * var(--ov-move,1)));filter:blur(4px)}'), 'the heading rises and sharpens from 4 px of blur');
++ assert.ok(css.includes('@keyframes ovfxRuleDraw{from{transform:scaleX(0)'), 'the rule is drawn, not faded in');
++ assert.ok(css.includes('.ovfx-rule{transform-origin:left center}'), 'from the left');
++ assert.ok(css.includes('@supports (animation-timeline:view())'), 'the scroll timeline, where the browser has it');
++ assert.ok(css.includes('animation-timeline:view();animation-range:entry 0% cover 25%'), 'the heading is driven by the scroll position');
++ assert.ok(css.includes('animation-range:entry calc(5% + var(--ovfx-i,0) * 3%) cover calc(30% + var(--ovfx-i,0) * 3%)'), 'the cards step by index');
++ assert.ok(css.includes('animation:ovfxHeadRise linear backwards'), 'backwards fill: once past the range the page\'s own styles are back, and nothing is filled forward');
++ assert.ok(!/\.ovfx-head\{[^}]*opacity:0/.test(css), 'content is visible by default; only a start state the script or @supports adds hides it');
++ assert.ok(css.includes('html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card,.ovfx-rule).ovfx-scroll:not([data-fx-demo] *){animation-name:ovfxFadeIn'), 'Calm keeps only the fade');
++ assert.ok(css.includes('html[data-ov-motion=off] :is(.ovfx-head,.ovfx-card,.ovfx-rule,.ovfx-fade):not([data-fx-demo] *){animation:none!important'), 'Off removes the motion');
++ }
++
++ // ── reveal: below the fold it waits for the screen; heading, rule and cards step; on screen it is never hidden ──
++ {
++ const html = `<section id="low" data-fx="reveal" data-fx-stagger="40" data-top="1400">
++ <h2 id="h" data-top="1400">Live Now</h2><span id="r" data-fx-rule data-top="1400"></span>
++ <div class="grid" id="grid">${[1, 2, 3, 4, 5, 6, 7].map((n) => `<a class="card" data-top="${1500 + n}">${n}</a>`).join('')}</div>
++ </section><section id="top" data-fx="reveal" data-top="100"><h2 data-top="100">x</h2><p data-top="100">y</p></section>`;
++ const t = setup({ html });
const low = t.document.getElementById('low'), top = t.document.getElementById('top');
-- assert.ok([...low.children].every((k) => k.classList.contains('ovfx-hide')), 'below the fold: hidden until seen');
-- assert.strictEqual(low.children[2].style.getPropertyValue('--ovfx-d'), '80ms', 'staggered');
-- assert.ok(![...top.children].some((k) => k.classList.contains('ovfx-hide')), 'on screen when wired: never hidden');
-- assert.ok(t.observed.includes(low) && !t.observed.includes(top));
-- t.io().fire(low);
-- assert.ok([...low.children].every((k) => !k.classList.contains('ovfx-hide')), 'seen: shown');
++ const head = t.document.getElementById('h'), rule = t.document.getElementById('r');
++ const cards = [...t.document.querySelectorAll('#grid > a')];
++ assert.ok(head.classList.contains('ovfx-head') && head.classList.contains('ovfx-wait'), 'the heading has a start state');
++ assert.ok(rule.classList.contains('ovfx-rule') && rule.classList.contains('ovfx-wait'), 'the rule has one too');
++ assert.ok(cards.every((k) => k.classList.contains('ovfx-card') && k.classList.contains('ovfx-wait')), 'so do the cards');
++ assert.ok(!t.document.getElementById('grid').classList.contains('ovfx-card'), 'the grid itself is not a card');
++ assert.deepStrictEqual(cards.map((k) => k.style.getPropertyValue('--ovfx-d')), ['140ms', '180ms', '220ms', '260ms', '300ms', '340ms', '340ms'], '40 ms apart after the heading, at most six, the rest together');
++ assert.strictEqual(t.observed.filter((x) => x === low).length, 0, 'the section is not observed as a whole: its parts are');
++ assert.strictEqual(t.observed.length, 9, 'heading, rule and seven cards wait with the one observer');
++ assert.ok(t.io() instanceof t.window.IntersectionObserver, 'one shared observer');
++ assert.ok(![...top.querySelectorAll('*')].some((k) => k.classList.contains('ovfx-wait')), 'on screen when wired: never hidden');
++ t.io().fire(head);
++ assert.ok(head.classList.contains('ovfx-in'), 'seen: the transition runs');
++ t.flush();
++ assert.ok(!head.classList.contains('ovfx-wait') && !head.classList.contains('ovfx-in'), 'and the start and transition classes are gone, so the page own styles are back');
++ t.io().fire(cards[2]);
++ assert.ok(cards[2].classList.contains('ovfx-in'), 'a card runs its own transition');
}
-- // ── count: from 0 up to the number, ending on its own text ──
++ // ── a section already on screen only waits for the parts that are below it ──
{
-- const t = setup({ html: '<b id="n" data-fx="count" data-top="1200">12,480 viewers</b><b id="m" data-fx="count" data-top="10">€3.50</b><b id="w" data-fx="count" data-top="1200">soon</b>' });
-- const n = t.document.getElementById('n');
-- t.io().fire(n);
-- t.run(100);
-- assert.notStrictEqual(n.textContent, '12,480 viewers', 'counting');
-- assert.match(n.textContent, / viewers$/, 'keeps its words');
-- t.run(5000);
-- assert.strictEqual(n.textContent, '12,480 viewers', 'ends exactly on its own text');
-- t.run(5000);
-- assert.strictEqual(t.document.getElementById('m').textContent, '€3.50', 'decimals end exact too');
-- assert.strictEqual(t.document.getElementById('w').textContent, 'soon', 'not a number: left alone');
++ const t = setup({ html: '<section data-fx="reveal" data-top="100"><h2 id="h" data-top="100">top</h2><a id="c" data-top="1400">card</a></section>' });
++ const h = t.document.getElementById('h'), c = t.document.getElementById('c');
++ assert.ok(!h.classList.contains('ovfx-head'), 'the heading someone can see is left alone');
++ assert.ok(c.classList.contains('ovfx-card') && c.classList.contains('ovfx-wait'), 'the card below the fold waits');
++ assert.deepStrictEqual(t.observed, [c], 'only that card is observed');
++ }
++
++ // ── data-fx-self reveals the element itself, not its children ──
++ {
++ const t = setup({ html: '<h2 id="h" data-fx="reveal" data-fx-self data-top="1400">Title</h2>' });
++ const h = t.document.getElementById('h');
++ assert.ok(h.classList.contains('ovfx-head') && h.classList.contains('ovfx-wait'), 'the element itself has the start state');
++ assert.strictEqual(t.observed.length, 1, 'one element waits for the screen');
++ }
++
++ // ── a rule only when the section has one; cards can be named when the markup is not a list or grid ──
++ {
++ const t = setup({ html: '<div data-fx="reveal" data-fx-cards=".pick" data-top="1400"><h2 data-top="1400">x</h2><p data-top="1400">lede</p><div><a class="pick" data-top="1400">1</a><a class="pick" data-top="1400">2</a></div></div>' });
++ assert.strictEqual(t.document.querySelector('.ovfx-rule'), null, 'no rule: nothing is drawn');
++ assert.deepStrictEqual([...t.document.querySelectorAll('.pick')].map((k) => k.classList.contains('ovfx-card')), [true, true], 'the named cards are the cards');
++ assert.ok(!t.document.querySelector('p').classList.contains('ovfx-card'), 'the lede is not a card when the cards are named');
++ }
++
++ // ── with the scroll timeline the browser drives it: no observer and no script while scrolling ──
++ {
++ const t = setup({ timeline: true, html: '<section data-fx="reveal" data-top="1400"><h2 id="h" data-top="1400">x</h2><span data-fx-rule data-top="1400"></span><div class="grid">' + [1, 2, 3, 4, 5, 6, 7].map((n) => `<a data-top="${1500 + n}">${n}</a>`).join('') + '</div></section>' });
++ const head = t.document.getElementById('h'), cards = [...t.document.querySelectorAll('.grid > a')];
++ assert.ok(head.classList.contains('ovfx-head') && head.classList.contains('ovfx-scroll'), 'the heading rides the scroll timeline');
++ assert.ok(!head.classList.contains('ovfx-wait'), 'no start class: the keyframes hold the start state');
++ assert.deepStrictEqual(cards.map((k) => k.style.getPropertyValue('--ovfx-i')), ['0', '1', '2', '3', '4', '5', '5'], 'six steps; the rest share the last');
++ assert.strictEqual(t.observed.length, 0, 'no observer is used, so no script runs on the main thread while scrolling');
}
-- // ── Calm (the setting, or the device asking for less): everything still plays, a third as far ──
++ // ── Calm (the setting, or the device asking for less): the reveal keeps only a short fade ──
for (const opts of [{ reduce: true }, { calm: true }]) {
-- const t = setup({ ...opts, html: '<ul data-fx="reveal" data-top="1400"><li>a</li></ul><b id="n" data-fx="count" data-top="1400">900</b>' });
-- assert.strictEqual(t.document.querySelectorAll('.ovfx-hide').length, 1, 'the reveal waits to play');
-- assert.strictEqual(t.observed.length, 2, 'the reveal and the number both wait for the screen');
-- assert.strictEqual(t.window.OVFx.move(), 0.35, 'a third of the distance');
++ const t = setup({ ...opts, html: '<section data-fx="reveal" data-top="1400"><h2 id="h" data-top="1400">x</h2><span data-fx-rule data-top="1400"></span><a id="c" data-top="1400">card</a></section>' });
++ const h = t.document.getElementById('h'), c = t.document.getElementById('c');
++ assert.ok(h.classList.contains('ovfx-fade') && !h.classList.contains('ovfx-head'), 'the heading only fades');
++ assert.ok(c.classList.contains('ovfx-fade') && !c.classList.contains('ovfx-card'), 'the card only fades');
++ assert.strictEqual(h.style.getPropertyValue('--ovfx-d'), '', 'no rise or stagger delay');
++ assert.strictEqual(t.observed.length, 3, 'the fade still waits for the screen');
++ assert.strictEqual(t.window.OVFx.move(), 0.35, 'a third of the distance for everything else');
++ }
++
++ // ── the same fade on the scroll timeline when the browser has it ──
++ {
++ const t = setup({ reduce: true, timeline: true, html: '<section data-fx="reveal" data-top="1400"><h2 id="h" data-top="1400">x</h2><a id="c" data-top="1400">card</a></section>' });
++ const h = t.document.getElementById('h'), c = t.document.getElementById('c');
++ assert.ok(h.classList.contains('ovfx-fade') && h.classList.contains('ovfx-scroll'), 'the heading fades on the scroll timeline');
++ assert.ok(c.classList.contains('ovfx-fade') && c.classList.contains('ovfx-scroll'), 'the card too');
++ assert.strictEqual(t.observed.length, 0, 'still no observer');
}
-- assert.strictEqual(setup().window.OVFx.move(), 1, 'full motion: the whole distance');
// ── motion off: nothing hidden, nothing counted, no confetti ──
{
-- const t = setup({ off: true, reduce: true, html: '<ul data-fx="reveal" data-top="1400"><li>a</li></ul><b id="n" data-fx="count" data-top="1400">900</b>' });
++ const t = setup({ off: true, reduce: true, html: '<section data-fx="reveal" data-top="1400"><h2 data-top="1400">x</h2><a data-top="1400">card</a></section><b id="n" data-fx="count" data-top="1400">900</b>' });
assert.strictEqual(t.window.OVFx.move(), 0);
-- assert.strictEqual(t.document.querySelectorAll('.ovfx-hide').length, 0, 'nothing hidden');
++ assert.strictEqual(t.document.querySelectorAll('.ovfx-wait,.ovfx-fade,.ovfx-head,.ovfx-card').length, 0, 'nothing hidden and nothing staged');
assert.strictEqual(t.observed.length, 0, 'nothing waits to count');
assert.strictEqual(t.document.getElementById('n').textContent, '900');
assert.strictEqual(t.window.OVFx.confetti({ x: 1, y: 1 }), false, 'no confetti');
@@@ -105,12 +172,27 @@ function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {
// ── the demo scope: a page that shows motion may play it inside [data-fx-demo], when the person asks ──
{
-- const t = setup({ off: true, html: '<div data-fx-demo><ul id="in" data-fx="reveal" data-top="1400"><li>a</li></ul></div><ul id="out" data-fx="reveal" data-top="1400"><li>b</li></ul>' });
-- assert.strictEqual(t.document.querySelectorAll('#in .ovfx-hide').length, 1, 'inside the scope the reveal waits to play');
-- assert.strictEqual(t.document.querySelectorAll('#out .ovfx-hide').length, 0, 'outside it nothing is hidden: the setting holds for the rest of the page');
++ const t = setup({ off: true, timeline: true, html: '<div data-fx-demo><section id="in" data-fx="reveal" data-top="1400"><h2 data-top="1400">a</h2></section></div><section id="out" data-fx="reveal" data-top="1400"><h2 data-top="1400">b</h2></section>' });
++ assert.ok(t.document.querySelector('#in h2').classList.contains('ovfx-scroll'), 'inside the scope the reveal plays in full');
++ assert.strictEqual(t.document.querySelectorAll('#out .ovfx-scroll').length, 0, 'outside it the setting holds');
assert.strictEqual(t.window.OVFx.confetti({ x: 1, y: 1 }), false, 'confetti with no source element stays off');
}
++ // ── count: from 0 up to the number, ending on its own text ──
++ {
++ const t = setup({ html: '<b id="n" data-fx="count" data-top="1200">12,480 viewers</b><b id="m" data-fx="count" data-top="10">€3.50</b><b id="w" data-fx="count" data-top="1200">soon</b>' });
++ const n = t.document.getElementById('n');
++ t.io().fire(n);
++ t.run(100);
++ assert.notStrictEqual(n.textContent, '12,480 viewers', 'counting');
++ assert.match(n.textContent, / viewers$/, 'keeps its words');
++ t.run(5000);
++ assert.strictEqual(n.textContent, '12,480 viewers', 'ends exactly on its own text');
++ t.run(5000);
++ assert.strictEqual(t.document.getElementById('m').textContent, '€3.50', 'decimals end exact too');
++ assert.strictEqual(t.document.getElementById('w').textContent, 'soon', 'not a number: left alone');
++ }
++
// ── markup added later is wired ──
{
const t = setup();
@@@ -129,5 +211,5 @@ function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {
assert.ok(require('../files').BROWSER.includes('ov-fx.js'), 'served');
}
-- console.log('fx: names wire their effects; reveal waits below the fold and never hides what is on screen; numbers end on their own text; Calm plays everything a third as far; only motion off keeps still; later markup wired; loaded only on request');
++ console.log('fx: names wire their effects; the reveal stages a heading out of a blur, its rule from the left and six card steps, on the scroll timeline or one observer; Calm fades; only motion off keeps still; later markup wired; loaded only on request');
})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/menu-home-screen-chrome.test.js b/test/menu-home-screen-chrome.test.js
iindex 5316cff..dec96ee 100644
---- a/test/menu-home-screen-chrome.test.js
++++ b/test/menu-home-screen-chrome.test.js
@@@ -97,8 +97,8 @@ const server = http.createServer((req, res) => {
await openTab(p, 'you');
assert.ok(await until(p, `document.querySelectorAll('.ovh-grid .ovh-app').length >= 12`), 'the home grid painted');
const first = await order(p);
-- assert.strictEqual(first.length, 12, `twelve on Home by default (${first})`);
-- assert.deepStrictEqual(first.slice(-2), ['folder:tools', 'folder:build'], 'Tools and Build are folders at the end');
++ assert.strictEqual(first.length, 13, `thirteen on Home by default (${first})`);
++ assert.deepStrictEqual(first.slice(-3), ['folder:g-play', 'folder:build', 'folder:tools'], 'the final group, Build and Quick tools folders stay at the end');
assert.ok(!first.includes('ai') && !first.includes('run'), 'developer services wait in the Build folder');
assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Popular on OpenVibe', 'until arranged, it says what it is');
assert.notStrictEqual(await p.evaluate(`document.querySelector('.ovs [data-tab="here"]').textContent`), 'Home', 'a page titled Home never names the first tab');
@@@ -132,7 +132,7 @@ const server = http.createServer((req, res) => {
assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-id')`), dragged[0], 'and it keeps the focus');
await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge').click(), true`);
const removed = await order(p);
-- assert.ok(!removed.includes(keyed[0]) && removed.length === 11, '− takes it off Home');
++ assert.ok(!removed.includes(keyed[0]) && removed.length === 12, '− takes it off Home');
// Done, then All apps puts it back.
await p.evaluate(`[...document.querySelectorAll('.ovh-sec button')].find((b) => b.textContent === 'Done').click(), true`);
await p.evaluate(`document.querySelector('.ovh-allbtn').click(), true`);
ddiff --git a/test/ov-combo-chrome.test.js b/test/ov-combo-chrome.test.js
nnew file mode 100644
iindex 0000000..dc07487
---- /dev/null
++++ b/test/ov-combo-chrome.test.js
@@@ -0,0 +1,124 @@
++'use strict';
++// The one dropdown for a field (ov-combo.js, 4.202.0; ask 348) in a real headless Chrome against a stub source: the
++// field becomes a combobox (no datalist); focus shows the sections, typing asks again (the earlier answer is not
++// painted over the newer), the match is marked, the arrows and Enter pick and the address goes in; a name nobody has
++// offers "Create … on OpenVibe Git", which posts the name and puts the new address in; a source that fails says so and
++// the field still takes a pasted address; Escape closes; the list stays on screen near the bottom (it opens upward).
++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-combo-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const root = path.join(__dirname, '..');
++const REPOS = [
++ { label: 'goosely/goal-bar', url: 'https://git.openvibe.codes/goosely/goal-bar.git', host: 'git.openvibe.codes', private: true },
++ { label: 'goosely/site', url: 'https://git.openvibe.codes/goosely/site.git', host: 'git.openvibe.codes' },
++ { label: 'goosely/overlay-kit', url: 'https://github.com/goosely/overlay-kit.git', host: 'github.com' },
++];
++const asked = [];
++let failing = false;
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/ov-combo.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-combo.js'))); }
++ if (u.pathname === '/repos') {
++ const q = (u.searchParams.get('q') || '').toLowerCase();
++ asked.push(q);
++ const answer = () => {
++ if (failing) { res.writeHead(500); return res.end(); }
++ const m = (r) => !q || r.label.toLowerCase().includes(q);
++ res.writeHead(200, { 'content-type': 'application/json' });
++ res.end(JSON.stringify({ sections: [{ id: 'recent', label: 'Recent', items: REPOS.slice(0, 1).filter(m) }, { id: 'git', label: 'On OpenVibe Git', items: REPOS.slice(1, 2).filter(m) }, { id: 'github', label: 'On GitHub', items: REPOS.slice(2).filter(m) }], create: { allowed: true, host: 'git.openvibe.codes', owner: 'goosely' } }));
++ };
++ // "go" answers late: a later query must not be painted over by it.
++ return q === 'go' ? setTimeout(answer, 600) : answer();
++ }
++ if (u.pathname === '/create' && req.method === 'POST') {
++ let body = ''; req.on('data', (c) => { body += c; });
++ req.on('end', () => { const b = JSON.parse(body); res.writeHead(201, { 'content-type': 'application/json' }); res.end(JSON.stringify({ url: `https://git.openvibe.codes/goosely/${b.name}.git`, label: `goosely/${b.name}`, private: b.private })); });
++ return;
++ }
++ 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;background:#111;color:#eee;font-family:sans-serif">
++<form id="f" style="padding:40px 16px"><input id="repo" name="repo" list="old" aria-label="Repository" style="width:100%;height:40px" data-ov-combo="/repos" data-ov-combo-create="/create"><datalist id="old"></datalist></form>
++<input id="low" data-ov-combo="/repos" aria-label="Low" style="position:fixed;bottom:8px;left:16px;width:300px;height:36px">
++<script>window.picks = []; document.addEventListener('ov-combo:pick', (e) => window.picks.push(e.detail.url));</script><script src="/ov-combo.js"></script>`);
++});
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-combo-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ try {
++ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 700 });
++ await page.goto(base + '/', { 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 wait(40); } return false; };
++ const key = async (k) => { const codes = { Enter: 13, ArrowDown: 40, ArrowUp: 38, Escape: 27 }; const ev = { key: k, code: k, windowsVirtualKeyCode: codes[k], nativeVirtualKeyCode: codes[k] }; await page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...ev }); await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...ev }); };
++ const view = () => page.evaluate(`(() => { const p = document.querySelector('.ovcombo'); if (!p || p.hidden) return null; return { secs: [...p.querySelectorAll('.ovcombo-sec')].map((x) => x.textContent), rows: [...p.querySelectorAll('.ovcombo-i b')].map((x) => x.textContent), sel: [...p.querySelectorAll('.ovcombo-i')].findIndex((x) => x.getAttribute('aria-selected') === 'true'), marks: [...p.querySelectorAll('.ovcombo-i b mark')].map((x) => x.textContent), none: (p.querySelector('.ovcombo-none, .ovcombo-err') || {}).textContent || '' }; })()`);
++
++ const a = await page.evaluate(`({ role: repo.getAttribute('role'), list: repo.getAttribute('list'), exp: repo.getAttribute('aria-expanded'), ac: repo.getAttribute('autocomplete') })`);
++ assert.deepEqual(a, { role: 'combobox', list: null, exp: 'false', ac: 'off' }, 'a combobox, the datalist gone');
++
++ await page.evaluate(`repo.focus()`);
++ assert.ok(await until(`!!document.querySelector('.ovcombo:not([hidden]) .ovcombo-i')`), 'focus shows the list');
++ let v = await view();
++ assert.deepEqual(v.secs, ['Recent', 'On OpenVibe Git', 'On GitHub']);
++ assert.deepEqual(v.rows, ['goosely/goal-bar', 'goosely/site', 'goosely/overlay-kit']);
++ assert.equal(await page.evaluate(`repo.getAttribute('aria-expanded')`), 'true');
++
++ // A slow answer for "go" must not paint over the answer for "goal".
++ await page.send('Input.insertText', { text: 'go' });
++ await wait(250);
++ await page.send('Input.insertText', { text: 'al' });
++ assert.ok(await until(`(() => { const p = document.querySelector('.ovcombo'); return p && !p.hidden && p.querySelectorAll('.ovcombo-i').length && [...p.querySelectorAll('.ovcombo-i b')][0].textContent === 'goosely/goal-bar'; })()`));
++ await wait(700);
++ v = await view();
++ assert.deepEqual(v.rows, ['goosely/goal-bar', 'Create “goal” on OpenVibe Git'], 'only what matches "goal", and a Create row');
++ assert.deepEqual(v.marks, ['goal'], 'the match is marked');
++ assert.equal(v.sel, 0, 'the first row is ready for Enter');
++ await key('Enter');
++ assert.equal(await page.evaluate('repo.value'), 'https://git.openvibe.codes/goosely/goal-bar.git', 'Enter put the address in');
++ assert.equal(await view(), null, 'and closed the list');
++ assert.deepEqual(await page.evaluate('window.picks'), ['https://git.openvibe.codes/goosely/goal-bar.git']);
++
++ // A new name: Create … on OpenVibe Git.
++ await page.evaluate(`(() => { repo.value = ''; repo.__ovcomboPicked = null; })()`);
++ await page.send('Input.insertText', { text: 'sub-goal-bar' });
++ assert.ok(await until(`[...document.querySelectorAll('.ovcombo .ovcombo-i b')].some((b) => b.textContent === 'Create “sub-goal-bar” on OpenVibe Git')`), 'a name nobody has can be created');
++ v = await view();
++ assert.match(await page.evaluate(`document.querySelector('.ovcombo .is-create small').textContent`), /Private, under @goosely/);
++ await key('ArrowDown');
++ await key('ArrowUp');
++ await key('Enter');
++ assert.ok(await until(`repo.value === 'https://git.openvibe.codes/goosely/sub-goal-bar.git'`), 'the new repository went in');
++ assert.equal(await page.evaluate('window.picks.length'), 2);
++
++ // Escape closes; ArrowDown opens again.
++ await key('ArrowDown');
++ assert.ok(await until(`!!document.querySelector('.ovcombo:not([hidden])')`));
++ await key('Escape');
++ assert.equal(await view(), null, 'Escape closes it');
++
++ // A source that fails says so; a pasted address still stands.
++ failing = true;
++ await page.evaluate(`(() => { repo.value = ''; repo.__ovcomboPicked = null; })()`);
++ await page.send('Input.insertText', { text: 'zz' });
++ assert.ok(await until(`/did not load/.test((document.querySelector('.ovcombo-err') || {}).textContent || '')`), 'a failed source says the list did not load');
++ failing = false;
++ assert.equal(await page.evaluate('repo.value'), 'zz');
++
++ // Near the bottom, it opens upward and stays on screen.
++ await page.evaluate(`document.getElementById('low').focus()`);
++ assert.ok(await until(`!!document.querySelector('.ovcombo.is-up:not([hidden]) .ovcombo-i')`), 'it opens upward near the bottom');
++ const r = await page.evaluate(`(() => { const b = document.querySelector('.ovcombo').getBoundingClientRect(); return { top: b.top, bottom: b.bottom, left: b.left, right: b.right, vw: innerWidth, vh: innerHeight }; })()`);
++ assert.ok(r.top >= 0 && r.bottom <= r.vh && r.left >= 8 && r.right <= r.vw - 8, `on screen ${JSON.stringify(r)}`);
++ await page.close();
++ console.log('ov-combo-chrome: a combobox (no datalist), sections, a slow answer not painted over a newer one, marks, Enter picks, Create on OpenVibe Git, Escape, a failed source, upward near the bottom');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/ov-composer-chrome.test.js b/test/ov-composer-chrome.test.js
iindex 85543ed..7a33801 100644
---- a/test/ov-composer-chrome.test.js
++++ b/test/ov-composer-chrome.test.js
@@@ -22,7 +22,7 @@ const form = thread.composer({
});
const server = http.createServer((req, res) => {
const u = req.url.split('?')[0];
-- const file = { '/ov-composer.js': 'application/javascript', '/thread.css': 'text/css' }[u];
++ const file = { '/ov-composer.js': 'application/javascript', '/ov-window.js': 'application/javascript', '/thread.css': 'text/css' }[u];
if (file) { res.writeHead(200, { 'content-type': file }); return res.end(fs.readFileSync(path.join(root, u.slice(1)))); }
if (u === '/send') { res.writeHead(204); return res.end(); }
res.writeHead(200, { 'content-type': 'text/html' });
@@@ -89,6 +89,12 @@ const server = http.createServer((req, res) => {
await page.evaluate(`document.querySelector('#c1 [data-ovc-back]').click()`);
assert.ok((await val()).startsWith('line 0\nline 1'), '↩ puts it back in the box');
assert.equal(await page.evaluate(`document.querySelectorAll('#c1 .ovc-file').length`), 0);
++ // A box that takes only pictures keeps a long paste as text (it is not turned into a file it would refuse).
++ await page.evaluate(`(() => { const i = document.querySelector('#c1 input[type=file]'); i.dataset.acc = i.getAttribute('accept'); i.setAttribute('accept', 'image/*'); })()`);
++ f = await page.evaluate(`(() => { const t = document.querySelector('#c1 textarea'); const dt = new DataTransfer(); dt.setData('text/plain', 'x\\n'.repeat(200));
++ const ev = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); t.dispatchEvent(ev); return { prevented: ev.defaultPrevented, chips: document.querySelectorAll('#c1 .ovc-file').length }; })()`);
++ assert.deepEqual(f, { prevented: false, chips: 0 }, 'a pictures-only box lets the long paste in as text');
++ await page.evaluate(`(() => { const i = document.querySelector('#c1 input[type=file]'); i.setAttribute('accept', i.dataset.acc); })()`);
await clear();
// "/": the site's commands, then settings; the arrows and Enter pick one, and Enter does not send.
@@@ -179,12 +185,13 @@ const server = http.createServer((req, res) => {
await type('/settings');
await until(`!document.querySelector('#c1 .ovc-pop').hidden`);
await key('Enter');
-- assert.ok(await until(`!!document.querySelector('dialog.ovc-set[open]')`), '/settings opens your composer settings');
-- f = await page.evaluate(`({ mine: [...document.querySelectorAll('.ovc-set-l b')].map((b) => b.textContent), box: document.querySelector('#c1 textarea').value })`);
++ assert.ok(await until(`!!document.querySelector('.ovw .ovc-set-in')`), '/settings opens your composer settings, in the one window (ov-window.js, loaded from beside the composer)');
++ assert.equal(await page.evaluate(`document.querySelector('.ovw .ovw-t').textContent`), 'Your composer');
++ f = await page.evaluate(`({ mine: [...document.querySelectorAll('.ovw .ovc-set-l b')].map((b) => b.textContent), box: document.querySelector('#c1 textarea').value })`);
assert.deepEqual(f, { mine: ['/sig'], box: '' }, 'the sheet lists your commands; the /settings word left the box');
// The count near the limit (600: it shows from 200 left).
-- await page.evaluate(`document.querySelector('dialog.ovc-set').close()`);
++ await page.evaluate(`OVWindow.closeAll()`);
await page.evaluate(`(() => { const t = document.querySelector('#c1 textarea'); t.value = 'x'.repeat(590); t.dispatchEvent(new Event('input', { bubbles: true })); })()`);
f = await page.evaluate(`({ shown: !document.querySelector('#c1 .ovc-count').hidden, text: document.querySelector('#c1 .ovc-count').textContent, low: document.querySelector('#c1 .ovc-count').classList.contains('is-low') })`);
assert.deepEqual(f, { shown: true, text: '10 left', low: true });
ddiff --git a/test/ov-home.test.js b/test/ov-home.test.js
iindex 3ff00b2..23433f3 100644
---- a/test/ov-home.test.js
++++ b/test/ov-home.test.js
@@@ -24,7 +24,17 @@ global.window = undefined;
const sandbox = {};
new Function('globalThis', 'document', src.replace("if (typeof document === 'undefined' || root.OpenVibeHome) return;", 'if (root.OpenVibeHome) return;'))(sandbox, {});
const H = sandbox.OpenVibeHome;
--const apps = ['live', 'chat', 'pics', 'video', 'games', 'space', 'design', 'news', 'fun', 'bot'].map((id) => ({ id, group: 'play' })).concat([{ id: 'ai', group: 'build' }]);
--assert.deepStrictEqual(H._defaultHome({ apps, yours: [] }), ['live', 'chat', 'pics', 'video', 'games', 'space', 'design', 'news', 'fun', 'bot', 'folder:tools', 'folder:build'], 'signed out: ten popular places and the two folders');
++const apps = [
++ ...['live', 'video', 'pics', 'stream', 'cam'].map((id) => ({ id, group: 'watch' })),
++ ...['community', 'chat', 'tips', 'mail'].map((id) => ({ id, group: 'talk' })),
++ ...['games', 'quest', 'surf', 'fun'].map((id) => ({ id, group: 'play' })),
++ ...['news', 'today', 'report', 'wiki'].map((id) => ({ id, group: 'read' })),
++ ...['deals', 'coupons', 'rent', 'work'].map((id) => ({ id, group: 'shop' })),
++ ...['tools', 'design', 'domains', 'host'].map((id) => ({ id, group: 'tools' })),
++ ...['space', 'codes', 'bot', 'actor'].map((id) => ({ id, group: 'make' })),
++ ...['services', 'exchange', 'run'].map((id) => ({ id, group: 'build' })),
++];
++// Ten popular singles (one left in every group for its folder), then the menu's eight groups in its order.
++assert.deepStrictEqual(H._defaultHome({ apps, yours: [] }), ['live', 'video', 'pics', 'stream', 'community', 'chat', 'tips', 'games', 'quest', 'surf', 'folder:g-watch', 'folder:g-talk', 'folder:g-play', 'folder:g-read', 'folder:g-shop', 'folder:g-tools', 'folder:g-make', 'folder:build', 'folder:tools'], 'signed out: ten popular places and one folder per group, in the menu\'s order');
assert.deepStrictEqual(H._defaultHome({ apps, yours: [{ id: 'you:vibe' }, { id: 'you:files' }, { id: 'you:account' }] }).slice(0, 3), ['you:vibe', 'you:files', 'live'], 'signed in: their own places first');
console.log('ov-home: text only, safe links, kept here and on Network, no cookies, the composer and wall, long-press and keys, the default Home');
ddiff --git a/test/ov-user.test.js b/test/ov-user.test.js
nnew file mode 100644
iindex 0000000..fed8a46
---- /dev/null
++++ b/test/ov-user.test.js
@@@ -0,0 +1,81 @@
++'use strict';
++const assert = require('assert');
++const { parseHTML } = require('linkedom');
++const OVUser = require('../ov-user');
++const { document, window } = parseHTML('<html><body><div id="host"></div></body></html>');
++const host = document.getElementById('host');
++const person = { username: 'Example', display_name: '<img src=x onerror=alert(1)>', avatar_url: 'https://openvibe.network/avatar/Example?s=160', profile_url: 'https://openvibe.network/@Example', channel_url: 'https://openvibe.live/@Example', level: 4, streak: 3, badges_count: 0, followers: 12, live: { now: true, site: 'live', url: 'https://openvibe.live/@Example' }, active_sites: [{ id: 'live', name: '<Live>', url: 'https://openvibe.live' }] };
++const event = (type, extras = {}) => { const e = new window.Event(type, { bubbles: true, cancelable: true }); Object.assign(e, extras); return e; };
++(async () => {
++ let body = person, calls = [];
++ const fetchImpl = async (url, options) => { calls.push({ url, options }); return { ok: true, json: async () => body }; };
++ const mounted = OVUser.card(host, { username: '@Example', fetchImpl });
++ await mounted.ready;
++ assert.strictEqual(calls.length, 1);
++ assert.strictEqual(calls[0].url, 'https://openvibe.network/api/v1/people/Example/card');
++ assert.strictEqual(calls[0].options.credentials, 'omit');
++ assert.strictEqual(calls[0].options.cache, 'no-store');
++ assert.ok(['Level 4', '3 day streak', '0 badges', '12 followers'].every((t) => host.textContent.includes(t)), 'the numbers as chips (4.202.0 card)');
++ assert.ok(host.textContent.includes('Live now') && host.textContent.includes('<Live>'));
++ assert.strictEqual(host.querySelectorAll('img').length, 1, 'display name is text, never markup');
++ assert.strictEqual(host.querySelector('img').getAttribute('onerror'), null);
++ assert.strictEqual(host.querySelectorAll('style').length, 0, 'plain kit markup only');
++ body = { username: 'Example', profile_url: person.profile_url, level: null, streak: null, badges_count: null, followers: null, avatar_url: 'javascript:alert(1)', channel_url: 'javascript:alert(1)' };
++ await mounted.refresh();
++ for (const label of ['Level', 'Streak', 'Badges', 'Followers', 'Offline', 'Live now', 'This week']) assert.ok(!host.textContent.includes(label), label + ' stays absent');
++ assert.strictEqual(host.querySelector('img'), null);
++ assert.strictEqual(host.querySelector('dl'), null);
++ assert.strictEqual(host.querySelectorAll('a').length, 1, 'unsafe links omitted');
++ mounted.destroy(); assert.strictEqual(host.childNodes.length, 0);
++ assert.throws(() => OVUser.card(host, { username: '../bad' }), TypeError);
++
++ let resolveOld, oldSignal;
++ const old = OVUser.card(host, { username: 'OldName', fetchImpl: (_url, options) => { oldSignal = options.signal; return new Promise(resolve => { resolveOld = resolve; }); } });
++ await Promise.resolve();
++ body = { username: 'NewName', profile_url: 'https://openvibe.network/@NewName' };
++ const newer = OVUser.card(host, { username: 'NewName', fetchImpl });
++ await newer.ready;
++ assert.ok(oldSignal.aborted);
++ resolveOld({ ok: true, json: async () => person }); await old.ready;
++ assert.ok(host.textContent.includes('@NewName') && !host.textContent.includes('@Example'), 'late response cannot replace a newer mount');
++ newer.destroy();
++ const unavailable = OVUser.card(host, { username: 'Example', fetchImpl: async () => ({ ok: false, status: 503 }) });
++ assert.strictEqual(await unavailable.ready, null);
++ assert.ok(host.textContent.includes('The profile did not load.') && host.querySelector('a').href === person.profile_url);
++ unavailable.destroy();
++
++ const mention = document.createElement('a'); mention.href = person.profile_url; mention.textContent = '@Example'; document.body.appendChild(mention);
++ const avatar = document.createElement('button'); avatar.setAttribute('data-ov-user', 'Example'); avatar.appendChild(document.createElement('img')); document.body.appendChild(avatar);
++ const marked = document.createElement('span'); marked.setAttribute('data-username', 'Example'); marked.setAttribute('role', 'button'); marked.tabIndex = 0; document.body.appendChild(marked);
++ const foreign = document.createElement('a'); foreign.href = 'https://example.org/@Example'; document.body.appendChild(foreign);
++ const opened = [];
++ let unbind = OVUser.bind(document, { onOpen: value => opened.push(value.username) });
++ mention.dispatchEvent(event('click')); avatar.firstChild.dispatchEvent(event('click')); marked.dispatchEvent(event('keydown', { key: 'Enter' }));
++ assert.deepStrictEqual(opened, ['Example', 'Example', 'Example']);
++ mention.dispatchEvent(event('click', { ctrlKey: true })); foreign.dispatchEvent(event('click'));
++ assert.strictEqual(opened.length, 3, 'modified and foreign links keep their native behavior');
++ body = person;
++ const inCard = OVUser.card(host, { username: 'Example', fetchImpl }); await inCard.ready;
++ host.querySelector('a').dispatchEvent(event('click'));
++ assert.strictEqual(opened.length, 3, 'links inside the card remain navigation');
++ inCard.destroy();
++ unbind = OVUser.bind(document, { onOpen: value => opened.push(value.username) });
++ mention.dispatchEvent(event('click')); assert.strictEqual(opened.length, 4, 'rebinding does not duplicate listeners');
++ unbind(); mention.dispatchEvent(event('click')); assert.strictEqual(opened.length, 4);
++
++ // Native dialog opening/closing and request disposal through the default hook.
++ const create = document.createElement.bind(document);
++ document.createElement = name => { const el = create(name); if (name === 'dialog') el.showModal = () => el.setAttribute('open', ''); return el; };
++ unbind = OVUser.bind(document, { fetchImpl });
++ mention.dispatchEvent(event('click'));
++ await new Promise(resolve => setImmediate(resolve));
++ const dialog = document.querySelector('dialog');
++ assert.ok(dialog?.hasAttribute('open'));
++ assert.ok(dialog.textContent.includes('@Example'));
++ dialog.querySelector('button').dispatchEvent(event('click'));
++ assert.strictEqual(document.querySelector('dialog'), null);
++ unbind();
++ assert.ok(require('../files').BROWSER.includes('ov-user.js'));
++ assert.strictEqual(require('../package.json').exports['./ov-user'], './ov-user.js');
++ console.log('ov-user: single read, safe kit markup, absent fields, cancellation, stale-response protection, mention/avatar/keyboard hooks and dialog lifecycle passed');
++})().catch(e => { console.error(e); process.exitCode = 1; });
ddiff --git a/test/ov-window-chrome.test.js b/test/ov-window-chrome.test.js
nnew file mode 100644
iindex 0000000..bc688ca
---- /dev/null
++++ b/test/ov-window-chrome.test.js
@@@ -0,0 +1,113 @@
++'use strict';
++// One window for every little interface (ov-window.js, 4.202.0; asks 304 and 318) in a real headless Chrome: a dialog
++// grows out of its button, is named by its title, keeps the focus inside, closes with Escape and gives the focus back;
++// windows stack and Escape closes the top one; a panel floats beside its button with no scrim, moves by its head, and
++// opens again where it was left; on a phone every kind is a sheet that a swipe down closes; the window style is one
++// attribute for every window; content given as text is set as text.
++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-window-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const root = path.join(__dirname, '..');
++const server = http.createServer((req, res) => {
++ if (req.url.startsWith('/ov-window.js')) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-window.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:1600px;background:#111;color:#eee">
++<button id="open" style="position:absolute;left:40px;top:40px">Open</button><button id="pan" style="position:absolute;left:300px;top:40px">Panel</button><input id="page" style="position:absolute;left:40px;top:600px">
++<script src="/ov-window.js"></script>
++<script>
++window.shut = [];
++window.mk = (o) => { const f = document.createElement('div'); const a = document.createElement('input'); a.id = 'first'; const b = document.createElement('button'); b.id = 'last'; b.textContent = 'OK'; f.append(a, b); return OVWindow.open(Object.assign({ title: 'Your wallet', body: f, onClose: (why) => window.shut.push(why) }, o)); };
++</script>`);
++});
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}/`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-window-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const key = async (page, k, shift) => { const codes = { Escape: 27, Tab: 9 }; const ev = { key: k, code: k, windowsVirtualKeyCode: codes[k], nativeVirtualKeyCode: codes[k], modifiers: shift ? 8 : 0 }; await page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...ev }); await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...ev }); };
++ try {
++ const page = await h.openPage(browser, await browser.newContext(), { width: 1280, height: 800 });
++ await page.goto(base, { maxMs: 5000 });
++ await page.evaluate(`document.getElementById('open').focus(); window.w1 = mk({ from: document.getElementById('open'), id: 'wallet' })`);
++ await wait(400);
++ let v = await page.evaluate(`(() => { const el = document.querySelector('.ovw'); const r = el.getBoundingClientRect(); return { kind: el.className, role: el.getAttribute('role'), modal: el.getAttribute('aria-modal'), named: document.getElementById(el.getAttribute('aria-labelledby')).textContent, focus: document.activeElement.id, scrim: !!document.querySelector('.ovw-scrim'), cx: Math.round(r.left + r.width / 2 - document.documentElement.clientWidth / 2), locked: document.documentElement.classList.contains('ovw-locked'), popX: el.style.getPropertyValue('--ov-pop-x') }; })()`);
++ assert.match(v.kind, /is-dialog/);
++ assert.deepEqual([v.role, v.modal, v.named, v.focus, v.scrim, v.locked], ['dialog', 'true', 'Your wallet', 'first', true, true], 'a named modal dialog, the focus on its first field, the page behind locked');
++ assert.ok(Math.abs(v.cx) < 3, `centred on a computer (${v.cx})`);
++ assert.ok(v.popX !== '', 'it grows out of its button');
++ // The focus stays inside: Tab from the last control goes to the first (×), Shift+Tab from the first to the last.
++ await page.evaluate(`document.getElementById('last').focus()`); await key(page, 'Tab');
++ assert.equal(await page.evaluate(`document.activeElement.className`), 'ovw-x', 'Tab wraps to the first control (×)');
++ await key(page, 'Tab', true);
++ assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'Shift+Tab wraps to the last');
++ // A second window on top: Escape closes the top one only.
++ await page.evaluate(`window.w2 = mk({ title: 'Send', from: document.getElementById('last') })`);
++ assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 2);
++ await key(page, 'Escape'); await wait(350);
++ assert.equal(await page.evaluate(`document.querySelectorAll('.ovw:not(.is-out)').length`), 1, 'Escape closed the top window');
++ assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'the focus went back to what opened it');
++ await key(page, 'Escape'); await wait(350);
++ v = await page.evaluate(`({ n: document.querySelectorAll('.ovw').length, scrims: document.querySelectorAll('.ovw-scrim').length, focus: document.activeElement.id, locked: document.documentElement.classList.contains('ovw-locked'), shut: window.shut })`);
++ assert.deepEqual(v, { n: 0, scrims: 0, focus: 'open', locked: false, shut: ['escape', 'escape'] }, 'both gone, the page free, the focus back on Open');
++ // A press outside a dialog closes it.
++ await page.evaluate(`mk({})`); await wait(80);
++ await page.evaluate(`document.querySelector('.ovw-scrim').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))`); await wait(350);
++ assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a press outside closes a dialog');
++ // Text is text.
++ await page.evaluate(`OVWindow.open({ title: '<b>x</b>', body: '<img src=x onerror=alert(1)>' })`);
++ v = await page.evaluate(`({ t: document.querySelector('.ovw-t').textContent, imgs: document.querySelectorAll('.ovw img').length })`);
++ assert.deepEqual(v, { t: '<b>x</b>', imgs: 0 }, 'markup given as text is shown as text');
++ await page.evaluate('OVWindow.closeAll()'); await wait(300);
++
++ // A panel: beside its button, no scrim, the page usable, moved by its head, opened again where it was left.
++ await page.evaluate(`window.p1 = mk({ kind: 'panel', id: 'dock-test', title: 'Dock', from: document.getElementById('pan') })`);
++ await wait(400);
++ v = await page.evaluate(`(() => { const el = document.querySelector('.ovw'); const r = el.getBoundingClientRect(), b = document.getElementById('pan').getBoundingClientRect(); return { scrim: !!document.querySelector('.ovw-scrim'), modal: el.getAttribute('aria-modal'), below: Math.round(r.top - b.bottom), left: Math.round(r.left - b.left), locked: document.documentElement.classList.contains('ovw-locked') }; })()`);
++ assert.deepEqual(v, { scrim: false, modal: null, below: 8, left: 0, locked: false }, 'a panel sits under its button, with no scrim, and the page stays usable');
++ const hb = await page.evaluate(`(() => { const r = document.querySelector('.ovw-h').getBoundingClientRect(); return { x: r.left + 40, y: r.top + 20 }; })()`);
++ await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: hb.x, y: hb.y, button: 'left', clickCount: 1 });
++ await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: hb.x + 60, y: hb.y + 30, button: 'left' });
++ await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: hb.x + 200, y: hb.y + 150, button: 'left' });
++ await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: hb.x + 200, y: hb.y + 150, button: 'left', clickCount: 1 });
++ const moved = await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`);
++ assert.deepEqual(await page.evaluate(`JSON.parse(localStorage.getItem('ov:window:dock-test'))`), moved, 'where it was left is kept');
++ await page.evaluate('p1.close()'); await wait(300);
++ await page.evaluate(`mk({ kind: 'panel', id: 'dock-test', title: 'Dock', from: document.getElementById('pan') })`);
++ await wait(400);
++ assert.deepEqual(await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`), moved, 'and it opens there again');
++ await page.evaluate('OVWindow.closeAll()'); await wait(300);
++
++ // The window style: one attribute, every window.
++ assert.equal(await page.evaluate(`OVWindow.setStyle('outline')`), 'outline');
++ assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), 'outline');
++ assert.equal(await page.evaluate(`OVWindow.setStyle('nonsense')`), 'glass', 'an unknown style is the default');
++ assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), null);
++ await page.close();
++
++ // A phone: every kind is a sheet; a swipe down on its head closes it.
++ const ph = await h.openPage(browser, await browser.newContext(), { width: 390, height: 800, mobile: true });
++ await ph.goto(base, { maxMs: 5000 });
++ await ph.evaluate(`mk({ kind: 'panel', title: 'Dock' })`); await wait(400);
++ v = await ph.evaluate(`(() => { const el = document.querySelector('.ovw'); const r = el.getBoundingClientRect(); return { sheet: el.classList.contains('is-sheet'), bottom: Math.round(innerHeight - r.bottom), width: Math.round(r.width) }; })()`);
++ assert.deepEqual(v, { sheet: true, bottom: 0, width: 390 }, 'a sheet across the bottom of a phone');
++ const sh = await ph.evaluate(`(() => { const r = document.querySelector('.ovw-h').getBoundingClientRect(); return { x: r.left + 120, y: r.top + 20 }; })()`);
++ await ph.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: sh.x, y: sh.y, button: 'left', clickCount: 1 });
++ await ph.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: sh.x, y: sh.y + 80, button: 'left' });
++ await ph.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: sh.x, y: sh.y + 260, button: 'left' });
++ await ph.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sh.x, y: sh.y + 260, button: 'left', clickCount: 1 });
++ await wait(400);
++ assert.equal(await ph.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a swipe down closes the sheet');
++ assert.deepEqual(await ph.evaluate('window.shut'), ['swipe']);
++ await ph.close();
++ console.log('ov-window-chrome: a named modal dialog from its button, focus kept and given back, stacked Escape, outside press, text as text, a panel beside its button that keeps its place, one window style, a phone sheet closed by a swipe');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/site-groups.test.js b/test/site-groups.test.js
nnew file mode 100644
iindex 0000000..40de18f
---- /dev/null
++++ b/test/site-groups.test.js
@@@ -0,0 +1,62 @@
++'use strict';
++// The owner's one site list (2026-11-10, plan ask 274): the same eight groups, in the same order, in the footer's
++// Network column and fallback, the rooms' neighbourhoods, the You tab's folders and each home page's "More on OpenVibe".
++// OpenVibe.Network frame/sites.js GROUPS is the list; these are its consumers in Shared, checked against it.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const footer = require('../footer');
++const rooms = require('../rooms');
++const showcase = require('../showcase');
++
++const LABELS = ['Watch & stream', 'Talk & belong', 'Play', 'Read & ask', 'Shop & money', 'Free tools', 'Make with AI', 'For developers'];
++const IDS = ['watch', 'talk', 'play', 'read', 'shop', 'tools', 'make', 'build'];
++// The tile ids as the home cards show them (Email, Restream, Studio and Locker keep their names).
++const MEMBERS = [
++ ['live', 'video', 'pics', 'download', 'openre', 'cam', 'tours'],
++ ['community', 'chat', 'tips', 'vip', 'email', 'help', 'network'],
++ ['games', 'quest', 'surf', 'fun', 'meme', 'casino', 'inventory'],
++ ['news', 'today', 'report', 'search', 'wiki', 'blog', 'vision'],
++ ['deals', 'coupons', 'shoes', 'reviews', 'trade', 'rent', 'work'],
++ ['tools', 'design', 'domains', 'host', 'studio', 'locker', 'food'],
++ ['space', 'codes', 'bot', 'actor', 'computer', 'connect'],
++ ['services', 'exchange', 'run', 'zone', 'events', 'watch', 'git'],
++];
++
++// The footer's fallback is the eight groups, in order, each linking to its /sites section.
++assert.deepStrictEqual(footer.NETWORK.map((x) => x.name), LABELS, 'footer fallback: the eight labels in order');
++assert.deepStrictEqual(footer.NETWORK.map((x) => x.url), IDS.map((id) => `https://openvibe.network/sites#group-${id}`), 'footer fallback: one link per group');
++// The rooms' neighbourhoods repeat the same ids and labels.
++assert.deepStrictEqual(Object.keys(rooms.HOODS), IDS, 'rooms: the eight ids in order');
++assert.deepStrictEqual(Object.values(rooms.HOODS), LABELS, 'rooms: the eight labels in order');
++for (const r of rooms.IDS) assert.ok(rooms.HOODS[rooms.get(r).hood], `${r}: a current neighbourhood`);
++
++// The You tab's folders are the same eight groups, in order (Build keeps the `build` id saved home screens use).
++const homeSrc = fs.readFileSync(path.join(__dirname, '..', 'ov-home.js'), 'utf8');
++const gf = /const GROUP_FOLDERS = (\[\[[\s\S]*?\]\]);/.exec(homeSrc);
++assert.ok(gf, 'ov-home has GROUP_FOLDERS');
++const folders = JSON.parse(gf[1].replace(/'/g, '"'));
++assert.deepStrictEqual(folders.map((f) => f[0]), IDS, 'You folders: the eight ids in order');
++assert.deepStrictEqual(folders.map((f) => f[1]), LABELS, 'You folders: the eight labels in order');
++
++// "More on OpenVibe": each home page leads to the next places in its own group, in the group's order.
++const ALIAS = { media: 'video', 'media-hub': 'video', stream: 'openre', restream: 'openre', mail: 'email', image: 'studio', lock: 'locker', homes: 'rent', bench: 'report' };
++const groupOf = (id) => MEMBERS.findIndex((list) => list.includes(ALIAS[id] || id));
++for (const list of MEMBERS) for (const id of list) assert.ok(showcase.RELATED[id], `${id}: has a "More on OpenVibe" list`);
++for (const list of MEMBERS) for (const id of list) {
++ const g = groupOf(id);
++ const expected = MEMBERS[g].filter((x) => x !== id).slice(0, 4);
++ assert.deepStrictEqual(showcase.RELATED[id].map((x) => x[0]), expected, `${id}: its group's next places, in group order`);
++}
++for (const [alias, id] of Object.entries(ALIAS)) {
++ assert.ok(showcase.RELATED[alias], `${alias}: an alias for ${id}`);
++ assert.ok(!showcase.RELATED[alias].some((x) => x[0] === id), `${alias}: never links to itself`);
++ assert.ok(showcase.RELATED[alias].every((x) => groupOf(x[0]) === groupOf(id)), `${alias}: only its own group`);
++}
++assert.deepStrictEqual(showcase.DEFAULT_RELATED.map((x) => x[0]), ['live', 'community', 'games', 'news'], 'a product without a list: one place from each of the first four groups, in order');
++
++// The footer's live column uses the Frame's own groups (network column), not a head-list of ranked sites.
++const footerSrc = fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8');
++assert.ok(/frame\.groups\.map\(g => \(\{ name: g\.name, url: groupHref\(g\.id\)/.test(footerSrc), 'the live footer reads the Frame groups');
++assert.ok(/const network = frame && Array\.isArray\(frame\.groups\)/.test(footerSrc), 'and falls back to the eight only when the Frame is away');
++console.log('site groups: footer, rooms, You folders and home pages show the same eight groups in the menu\'s order');
ddiff --git a/test/wallet-chrome.test.js b/test/wallet-chrome.test.js
iindex 8679de1..e540d33 100644
---- a/test/wallet-chrome.test.js
++++ b/test/wallet-chrome.test.js
@@@ -18,7 +18,9 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
await p.goto(base, { maxMs: 5000 });
assert(!p.state.urls.some((u) => u.includes('ov-wallet.js')), 'sheet is lazy');
await p.evaluate(`document.querySelector('[data-chip-id]').click()`); await wait(500);
-- const metrics = await p.evaluate(`(() => { const d=document.querySelector('.ov-wallet'); return {open:!!(d&&d.open), width:document.documentElement.scrollWidth, inner:d&&d.scrollWidth, box:d&&d.clientWidth, lock:document.documentElement.style.overflow, text:d&&d.innerText, buttons:[...d.querySelectorAll('button,a')].map(b=>b.getBoundingClientRect().height)} })()`);
++ // 4.202.0: the wallet opens in the one window, which grows in (.28 s): measure once it has settled.
++ { const end = Date.now() + 4000; while (Date.now() < end && !(await p.evaluate(`(() => { const w = document.querySelector('.ovw'); return !w || (w.getAnimations ? w.getAnimations().every((a) => a.playState !== 'running') : true); })()`))) await wait(50); }
++ const metrics = await p.evaluate(`(() => { const d=document.querySelector('.ov-wallet'); return {open:!!(d&&(d.open||d.closest('.ovw'))), width:document.documentElement.scrollWidth, inner:d&&d.scrollWidth, box:d&&d.clientWidth, lock:document.documentElement.style.overflow, text:d&&d.innerText, buttons:[...d.querySelectorAll('button,a')].map(b=>b.getBoundingClientRect().height)} })()`);
assert(metrics.open, 'first chip tap opens wallet'); assert.strictEqual(metrics.width, width); assert(metrics.inner <= metrics.box + 1);
assert.strictEqual(metrics.lock, 'hidden'); assert(metrics.text.includes('$0.00')); assert(metrics.text.includes('Buy Vibes')); assert(metrics.text.includes('Top up'));
assert(metrics.text.includes('for the quest Publish a video'), 'a money reason is a sentence, not a key');
ddiff --git a/theme-loader.js b/theme-loader.js
iindex 653d867..51696ab 100644
---- a/theme-loader.js
++++ b/theme-loader.js
@@@ -353,7 +353,7 @@
// ── Display settings, one record for every OpenVibe site: display.get() → DISPLAY_OPTS' values; display.set(patch)
// applies here and in other tabs and saves to the account. <html data-ov-*> and --ov-text-scale before first paint.
-- const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'full', 'reduced', 'off'], text: ['100', '112', '125'], seasonal: ['on', 'off'], scheme: ['manual', 'auto'], contrast: ['normal', 'more'], font: ['default', 'readable'], colors: ['default', 'cb-safe'], oled: ['off', 'on'], icons: ['auto', 'line', 'solid'] };
++ const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'full', 'reduced', 'off'], text: ['100', '112', '125'], seasonal: ['on', 'off'], scheme: ['manual', 'auto'], contrast: ['normal', 'more'], font: ['default', 'readable'], colors: ['default', 'cb-safe'], oled: ['off', 'on'], icons: ['auto', 'line', 'solid'], window: ['glass', 'solid', 'outline'] };
// Status names some sites use (--error, --error-text, --ok, --warn, --accent-text) that no theme sets: they resolve to the theme's
// readable status colours instead of each site's fallback literal (pale on light themes). :where() keeps them
// below any site's own value (4.86.0).
@@@ -371,7 +371,7 @@
+ 'html[data-ov-colors=cb-safe]{--success:#56b4e9!important;--success-text:#7cc6ef!important;--warning:#f0e442!important;--warning-text:#f0e442!important;--danger:#e8743b!important;--danger-text:#f0985f!important}'
+ 'html[data-ov-colors=cb-safe][data-theme-mode=light]{--success:#0072b2!important;--success-text:#005a8c!important;--warning:#e69f00!important;--warning-text:#8a6100!important;--danger:#d55e00!important;--danger-text:#a84400!important}'
+ 'html[data-ov-oled=on][data-theme-mode=dark]{--bg-primary:#000!important;--bg-secondary:#050608!important;--bg-tertiary:#0b0d12!important;--bg-card:#0a0c10!important;--bg-input:#0b0d12!important;--bg-hover:#12151c!important}';
-- const COMFORT_ATTRS = { contrast: ['data-ov-contrast', 'normal'], font: ['data-ov-font', 'default'], colors: ['data-ov-colors', 'default'], oled: ['data-ov-oled', 'off'], icons: ['data-ov-icons', 'auto'] };
++ const COMFORT_ATTRS = { contrast: ['data-ov-contrast', 'normal'], font: ['data-ov-font', 'default'], colors: ['data-ov-colors', 'default'], oled: ['data-ov-oled', 'off'], icons: ['data-ov-icons', 'auto'], window: ['data-ov-window', 'glass'] };
// ── Motion (4.183.0; the owner, 2026-11-10: "still have full motion but actually just reduce it instead of getting
// rid of it"). auto follows the device, full plays everything, reduced is Calm (every effect, smaller and slower),
// off alone stops motion. <html data-ov-motion=full|reduced|off> is the one signal; --ov-move is what a moving
@@@ -404,7 +404,7 @@
document.addEventListener('animationstart', function (e) { if (document.documentElement.getAttribute('data-ov-motion') === 'reduced') paceLoops(e.target); }, true);
}
function readDisplay() {
-- const out = { motion: 'auto', text: '100', seasonal: 'on', scheme: 'manual', contrast: 'normal', font: 'default', colors: 'default', oled: 'off', icons: 'auto' };
++ const out = { motion: 'auto', text: '100', seasonal: 'on', scheme: 'manual', contrast: 'normal', font: 'default', colors: 'default', oled: 'off', icons: 'auto', window: 'glass' };
try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null') || {}; for (const k in DISPLAY_OPTS) if (DISPLAY_OPTS[k].indexOf(String(d[k])) >= 0) out[k] = String(d[k]); } catch (e) { /* */ }
return out;
}
@@@ -546,7 +546,7 @@
// page, only colours pass; the next site keeps it when newer and drops it from the address before first paint.
const OV_HOST = /(^|\.)(openvibe\.[a-z]{2,12}|openre\.stream)$/i;
const PAINT_KEY = 'ov_paint';
-- const PAINT_ATTRS = ['data-theme', 'data-theme-mode', 'data-ov-season', 'data-ov-season-mark', 'data-ov-motion', 'data-ov-text', 'data-ov-contrast', 'data-ov-font', 'data-ov-colors', 'data-ov-oled', 'data-ov-icons'];
++ const PAINT_ATTRS = ['data-theme', 'data-theme-mode', 'data-ov-season', 'data-ov-season-mark', 'data-ov-motion', 'data-ov-text', 'data-ov-contrast', 'data-ov-font', 'data-ov-colors', 'data-ov-oled', 'data-ov-icons', 'data-ov-window'];
const SAFE_VALUE = /^(#[0-9a-f]{3,8}|rgba?\([0-9.,\s%]+\)|[0-9.,\s]+|light|dark|(-?[0-9.]+(px)?\s+){2,4}rgba?\([0-9.,\s%]+\))$/i;
const SAFE_ATTR = /^[a-z0-9-]{1,40}$/;
function seasonUntil(s) {
ddiff --git a/thread.css b/thread.css
iindex 68867f8..c0986f0 100644
---- a/thread.css
++++ b/thread.css
@@@ -355,6 +355,8 @@ html.ovt-sheet-open, html.ovt-sheet-open body { overflow: hidden; }
html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data-ov-motion=off] .ovc-mic { animation: none; }
.ovc-set { width: min(460px, calc(100vw - 24px)); max-height: min(640px, calc(100vh - 32px)); padding: 0; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .2)); border-radius: 20px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); box-shadow: 0 30px 80px rgba(0, 0, 0, .5); }
.ovc-set::backdrop { background: rgba(2, 6, 14, .55); }
++.ovc-set.ovc-set-in { width: auto; max-height: none; border: 0; border-radius: 0; background: none; box-shadow: none; }
++.ovc-set-in .ovc-set-f { padding: 0; }
.ovc-set-f { display: grid; gap: 14px; padding: 6px 18px 18px; }
.ovc-set-h { display: flex; align-items: center; justify-content: space-between; margin: 0 -8px 0 0; font-size: 17px; }
.ovc-set-g { margin: 0; padding: 0; border: 0; display: grid; gap: 6px; }