openvibers/openvibe.shared Commits

4.236.0: You is yours on every site, the wait is the logo, the box says what a message answers (#519)

OP OpenVibers committed 2 h ago · 3ca56e7062 · 17 files, +326 −77

* 4.236.0: You is yours on every site, the wait is the logo, the box says what a message answers

* 4.236.0: the menu test finds Sign out in your card's action row

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 4e8d59b..e1f783d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,44 @@
# Changelog
++## 4.236.0 — 2026-11-13
++
++**You is yours on every site, the wait is the logo, and the box says what a message answers** (the owner's phone,
++11-13: "what happened to all the tabs … bring all that back", "what happened to the user avatar", "completely redesign
++this area", "in edit mode … you can't scroll up", "improve the O so it actually looks like the original", "improve the
++send button", "a replying to area or different content above").
++
++- **You, on a site with its own session (Run's).** The menu knew the person but had no token for the network's reads,
++ so You lost its tabs (Apps, Notifications, Recent) and the You tab its picture. Both follow the person now
++ (`known(o)` in ov-home.js, `me` in search.js's tabs). Without a token, Notifications links to the person's account.
++- **Your card, redesigned.**
++ - Your name and the gear on one line.
++ - Under it, one row of actions across the whole card in equal columns: this site's own (Channel, Admin), Display,
++ Switch, Sign out. Each is its glyph with its name under it and a tooltip; the row starts at the card's edge (it was
++ indented 52 px).
++ - Sign out's red only under a pointer; the gear turns under one.
++- **You's tabs look like tabs.**
++ - Apps, Alerts (Notifications) and History are one segmented track, each with its glyph and name; the chosen one is
++ lit.
++ - All apps and Edit are two glyph buttons at the end: All with its count on its corner, and Edit a pencil that turns
++ into the check, filled with the accent, while you arrange. That check is the one Done; the row is sticky, so it is
++ always in reach.
++- **Editing your home on a phone.** A quick swipe up or down scrolls the menu. A hold of a fifth of a second, or a
++ sideways move, takes the app (it lifts); only then does the page hold still.
++- **The menu's tabs nudge.** This site's tab rolls its glyph and name through the other places in its section, a beat
++ each, and back to its own: a little after the menu opens, then every 40 s while it is open and idle. Calm rolls once
++ per opening and Off never. Its accessible name stays its own; a press stops it.
++- **The wait (ov-mark busy).** The O stays the logo's ring at its full weight, a faint track, with a bright arc
++ running round it that grows as it goes and draws in behind. The V lifts 2.5 units in time (it bobbed 6, off the
++ O's line) and the glow breathes, one 1.25 s rhythm. It replaces eight thinner dots that sat inside the ring's line.
++- **The composer.**
++ - `bar({ icon, title, detail, kind, onClose })` puts any context above the words, inside the box, as reply and edit
++ do: a quote, or a file being answered.
++ - Bars carry a rail of the accent (editing in its own colour) and come in with a short rise.
++ - Send with nothing to send is a quiet outline round its arrow (it was a grey disc). With something to send it
++ fills with the accent on a spring, and the arrow lifts under a pointer and on a press.
++- Tests: `composer-bar-send` (new), `ov-mark` (the arc), `menu-home-screen-chrome` (the card in one aligned row with
++ names and tooltips; `OV_CAPTURE_YOU=<dir>` saves You and Edit at 390 px).
++
## 4.235.0 — 2026-11-13
**The layers: what floats folds into a small handle and grows to the window, and what opens from a button is anchored
ddiff --git a/README.md b/README.md
iindex 5865bfb..0250f0e 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.235.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.236.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 04aa8d6..f6cdcaf 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.235.0",
++ "version": "4.236.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex 01e5bca..b96b501 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -114,7 +114,7 @@
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
-- const VIBE_REV = { 'ov-composer.js': '53893bb875b6' };
++ const VIBE_REV = { 'ov-composer.js': '021d6850b659' };
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
ddiff --git a/dock.js b/dock.js
iindex 34c1a3e..858362f 100644
---- a/dock.js
++++ b/dock.js
@@@ -84,7 +84,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': '8cc4d7ded88f', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'ee1f6f21dbba', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
++ const DOCK_REV = { 'dock-vibe.js': '7746fc647b45', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'ee1f6f21dbba', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
ddiff --git a/navbar.js b/navbar.js
iindex 30e0992..bc2bdfd 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -609,7 +609,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': '2ee871bd8ffa', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'ef289c4550b1', 'dock.js': '337c77c239a5', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': '0cef1f895965', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': 'b11405559831', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '51840ff5c0b9', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': '91be6059adcf', 'ov-composer.js': '53893bb875b6' };
++ const SIBLING_REV = { 'ov-mark.js': 'f490466f57df', 'ov-glyphs.js': '2ee871bd8ffa', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '95fb20ad04f9', 'dock.js': 'ea0b3c128ed0', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': '0cef1f895965', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': 'e8768e165d42', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '51840ff5c0b9', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': '91be6059adcf', 'ov-composer.js': '021d6850b659' };
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.
ddiff --git a/ov-composer.js b/ov-composer.js
iindex cecb8f9..5489e29 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -252,7 +252,13 @@ html[data-ov-motion=off] .ovc-cam-shutter { transition: none; }
.ovc-answer-a { min-height: 36px; padding: 0 14px; border: 1px solid var(--border, rgba(148, 163, 184, .22)); border-radius: 999px; background: none; color: var(--text-primary, #e6edf7); font: 600 13.5px/1 inherit; cursor: pointer; }
.ovc-answer-a:first-child { background: var(--ovt-accent, var(--accent, #3b82f6)); border-color: transparent; color: #fff; }
@media (pointer: coarse) { .ovc-answer-a { min-height: 44px; } }
--.ovc-v2 .ovc-bar { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); font-size: 13px; line-height: 1.3; }
++.ovc-v2 .ovc-bar { position: relative; display: flex; align-items: center; gap: 8px; min-width: 0; padding: 3px 4px 3px 15px; border-radius: 12px; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 13%, transparent); font-size: 13px; line-height: 1.3;
++ animation: ovcBarIn .2s cubic-bezier(.2, .9, .25, 1); }
++/* 4.236.0: what the message answers, quotes or changes is a part of the box: a rail of the accent at its start. */
++.ovc-v2 .ovc-bar::before { content: ""; position: absolute; left: 6px; top: 7px; bottom: 7px; width: 3px; border-radius: 3px; background: var(--ovt-accent, var(--accent, #3b82f6)); }
++.ovc-v2 .ovc-bar[data-kind=edit]::before { background: var(--warning, #f59e0b); }
++@keyframes ovcBarIn { from { opacity: 0; transform: translateY(4px); } }
++html[data-ov-motion=off] .ovc-v2 .ovc-bar { animation: none; }
.ovc-v2 .ovc-bar > svg { flex: none; width: 15px; height: 15px; color: var(--ovt-accent, var(--accent-light, #60a5fa)); }
.ovc-v2 .ovc-bar > b { flex: none; font-weight: 700; color: var(--text-primary, #e6edf7); }
.ovc-v2 .ovc-bar > span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #93a0b4); }
@@@ -340,9 +346,17 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
.ovc textarea.ovc-ph-swap::placeholder, .ovt-composer textarea.ovc-ph-swap::placeholder { opacity: 0; }
.ovc-v2 .ovt-send--round { background: linear-gradient(150deg, color-mix(in srgb, var(--accent, #3b82f6) 85%, #fff), var(--accent-strong, var(--accent, #2563eb))); color: var(--on-accent-strong, #fff);
box-shadow: 0 6px 18px -6px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); transition: transform .2s cubic-bezier(.3, 1.4, .4, 1), opacity .2s, box-shadow .2s; }
--.ovc-v2 .ovt-send--round svg { width: 20px; height: 20px; stroke-width: 2.3; }
--.ovc-v2 .ovt-send--round:disabled { opacity: 1; background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); color: var(--text-muted, #7386a3); box-shadow: none; }
++.ovc-v2 .ovt-send--round svg { width: 20px; height: 20px; stroke-width: 2.4; transition: transform .18s cubic-bezier(.3, 1.4, .4, 1); }
++/* 4.236.0 (the owner, 11-13: "improve the send button … the icon's all screwed up"): with nothing to send it is a quiet
++ outline round its arrow, not a grey disc; the moment there is, it fills with the accent on a spring, and the arrow
++ lifts under a pointer and on a press. */
++.ovc-v2 .ovt-send--round:disabled { opacity: 1; background: transparent; color: var(--text-muted, #7386a3); box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--text-primary, #e6edf7) 16%, transparent); }
++.ovc-v2 .ovt-send--round:not(:disabled) { animation: ovcSendReady .32s cubic-bezier(.34, 1.56, .64, 1); }
++@keyframes ovcSendReady { from { transform: scale(.8); } }
++@media (hover: hover) and (pointer: fine) { .ovc-v2 .ovt-send--round:not(:disabled):hover svg { transform: translateY(-2px); } }
.ovc-v2 .ovt-send--round:not(:disabled):active { transform: scale(.9); }
++.ovc-v2 .ovt-send--round:not(:disabled):active svg { transform: translateY(-3px); }
++html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc-v2 .ovt-send--round svg { animation: none; transition: none; }
`;
function styles() { if (document.getElementById('ovc-styles')) return; const st = document.createElement('style'); st.id = 'ovc-styles'; st.textContent = CSS; document.head.appendChild(st); }
const mods = [];
@@@ -960,7 +974,8 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
// edit({ text, onSave(text) → false keeps editing, onCancel, label }) (the box takes your message's words under an
// "Editing your message" bar; Enter saves, Esc or × puts back what was in the box; the send is a check),
// reply({ name, text, data, onClear }) (a "Replying to …" bar; onSubmit's value carries reply: data),
-- // clearBar(), editing() }.
++ // bar({ icon, title, detail, kind, onClose }) (4.236.0: any context above the words, inside the box, with a rail of
++ // the accent: a quote, a file being answered; one bar at a time), clearBar(), editing() }.
// 4.218.0, Post · Ask · Do (research-2026-11-12/composer-post-ask-do.md): verbs: ['post', 'ask', 'do'] (a site
// opts in; the first is where the box starts) draws one switch at the start of the row. Ask and Do add three chips:
// Model (a sheet over the Frame's /choices?task=: Auto, then OpenVibe from Exchange's directory with prices, your
@@@ -1372,9 +1387,10 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
form.classList.remove('is-editing', 'is-replying');
if (f) f();
};
-- const showBar = (icon, title, detail, onClose) => {
++ const showBar = (icon, title, detail, onClose, kind) => {
clearBar();
bar = el('div', 'ovc-bar', svg(icon));
++ if (kind) bar.dataset.kind = kind;
const t = el('b'); t.textContent = title;
const d = el('span'); d.textContent = detail || '';
const x = el('button', 'ovc-bar-x', svg(IC.x)); x.type = 'button'; x.title = 'Cancel'; x.setAttribute('aria-label', `Cancel: ${title}`);
@@@ -1392,7 +1408,7 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
sendFaceOf();
if (was && !was.saved) { ta.value = was.draft; ta.dispatchEvent(new Event('input', { bubbles: true })); if (typeof was.onCancel === 'function') was.onCancel(); }
api.refresh();
-- });
++ }, 'edit');
editing = { draft, onSave: spec.onSave, onCancel: spec.onCancel, saved: false };
form.classList.add('is-editing');
ta.value = String(spec.text || '');
@@@ -1404,11 +1420,17 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
const reply = (spec = {}) => {
if (editing) clearBar();
const who = String(spec.name || 'someone');
-- showBar(IC.reply, `Replying to ${who}`, String(spec.text || '').slice(0, 140), () => { const was = replying; replying = null; if (was && typeof was.onClear === 'function') was.onClear(); });
++ showBar(IC.reply, `Replying to ${who}`, String(spec.text || '').slice(0, 140), () => { const was = replying; replying = null; if (was && typeof was.onClear === 'function') was.onClear(); }, 'reply');
replying = { data: spec.data !== undefined ? spec.data : { name: who, text: spec.text }, onClear: spec.onClear };
form.classList.add('is-replying');
ta.focus();
};
++ // 4.236.0: any context above the words, inside the box (a quote, a file being answered, a poll being set up):
++ // bar({ icon: an SVG path, title, detail, kind, onClose }). One bar at a time, as edit and reply.
++ const contextBar = (spec = {}) => {
++ if (editing || replying) clearBar();
++ showBar(PATH_OK.test(spec.icon || '') ? spec.icon : IC.reply, String(spec.title || ''), String(spec.detail || '').slice(0, 140), typeof spec.onClose === 'function' ? spec.onClose : null, String(spec.kind || 'context').slice(0, 20));
++ };
ta.addEventListener('keydown', (e) => { if (e.key === 'Escape' && bar) { e.preventDefault(); e.stopPropagation(); clearBar(); } });
// ── Post · Ask · Do ──
const postFace = send.innerHTML, postLabel = send.getAttribute('aria-label'), postRound = send.classList.contains('ovt-send--round');
@@@ -1609,7 +1631,7 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
api.refresh();
});
}
-- return Object.assign(api, { form, text: ta, send, note, setBusy, setRunning, values, edit, reply, clearBar, editing: () => !!editing,
++ return Object.assign(api, { form, text: ta, send, note, setBusy, setRunning, values, edit, reply, bar: contextBar, clearBar, editing: () => !!editing,
verb: () => verb, setVerb, model: () => model, sources: () => [...sources], answer });
}
ddiff --git a/ov-home.js b/ov-home.js
iindex 29cd061..238a9d0 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -24,6 +24,7 @@
const HOME_KEY = 'ovs-home';
const INVITE_KEY = 'ovs-actor-invite';
const LONG_MS = 450;
++ const ARM_MS = 200; // in Edit, a finger holds an app this long before it moves it (a quicker swipe scrolls)
const HOME_MAX = 40;
const ACTOR = 'https://openvibe.actor';
// The Feed, the wall and the composer go through Network's frame relay (/api/frame/feed, /wall, /status): every
@@@ -77,7 +78,7 @@
.ovh-sec .ovh-edit.is-done { display: inline-flex; gap: 6px; width: auto; padding: 0 14px 0 11px; border-radius: 999px; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
.ovh-sec .ovh-edit:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
@media (pointer: coarse) { .ovh-sec .ovh-edit { width: 44px; min-height: 44px; } .ovh-sec .ovh-edit.is-done { width: auto; } }
--.ovh.is-edit .ovh-grid:not(.ovh-recent) { touch-action: none; -webkit-user-select: none; user-select: none; }
++.ovh.is-edit .ovh-grid:not(.ovh-recent) { touch-action: pan-y; -webkit-user-select: none; user-select: none; }
.ovh-recent .ovh-app { min-height: 80px; }
.ovh-app { position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 0; min-height: 88px; padding: 8px 2px 6px; border: 0; border-radius: 16px;
background: transparent; color: inherit; font: inherit; text-decoration: none; cursor: pointer; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent; }
@@@ -101,10 +102,11 @@
background: var(--bg-tertiary, #2a3140); color: var(--text-primary, #e8ecf3); font: 700 16px/1 system-ui, sans-serif; box-shadow: 0 0 0 2px var(--bg-secondary, #141a24); }
.ovh-badge::after { content: ""; position: absolute; inset: -10px; }
.ovh-badge.is-add { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
--.ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-app { touch-action: none; }
++.ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-app { touch-action: pan-y; }
.ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { animation: ovh-wiggle .32s ease-in-out infinite alternate; }
.ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-app:nth-child(2n) .ovh-ic { animation-delay: -.16s; }
.ovh-app.is-lifted { opacity: .18; transform: scale(.92); }
++.ovh-app.is-armed .ovh-ic { transform: scale(1.1); box-shadow: 0 12px 26px -10px rgba(0, 0, 0, .7); transition: transform .18s cubic-bezier(.34, 1.56, .64, 1); }
.ovh-ghost .ovh-ic { width: 64px; height: 64px; border-radius: 19px; }
html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { animation-name: ovh-wiggle-calm !important; }
@keyframes ovh-wiggle-calm { from { transform: rotate(-.6deg); } to { transform: rotate(.6deg); } }
@@@ -296,6 +298,33 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
/* Your row: you, then your round buttons (this site's own first). On a narrow screen the buttons go under your name. */
.ovh-me { flex-wrap: wrap; row-gap: 0; padding: 6px 6px 6px 8px; min-height: 56px; box-sizing: border-box; }
.ovh-acts { display: flex; align-items: center; gap: 4px; flex: none; }
++/* 4.236.0, your card: your name and the gear on one line, then one row of actions across the card, each its glyph with
++ its name under it in equal columns (a tooltip says the whole of it); Sign out last, its red only under a pointer. */
++.ovh-mebox { margin: 2px 4px 8px; padding: 8px; border-radius: 18px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }
++.ovh-mebox > .ovh-me { margin: 0; padding: 0 0 0 4px; min-height: 48px; background: none; box-shadow: none; flex-wrap: nowrap; }
++.ovh-mebox .ovh-av { width: 42px; height: 42px; }
++.ovh-me-gear { margin-left: auto; color: var(--text-secondary, #a8b3c4); }
++.ovh-me-gear svg { transition: transform .5s cubic-bezier(.3, 1.4, .4, 1); }
++@media (hover: hover) and (pointer: fine) { .ovh-me-gear:hover svg { transform: rotate(60deg); } }
++.ovh-mebox > .ovh-acts { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 6px; padding: 8px 0 0; margin: 8px 0 0; border-top: 1px solid color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }
++.ovh-mebox > .ovh-acts > .ovh-act { margin: 0; }
++.ovh-mebox .ovh-act { align-self: stretch; min-width: 0; width: auto; height: auto; min-height: 56px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 6px 4px; border-radius: 14px;
++ background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); color: var(--text-primary, #e6edf7);
++ transition: background-color .18s, color .18s, transform .18s cubic-bezier(.3, 1.4, .4, 1); }
++.ovh-mebox .ovh-act svg { width: 20px; height: 20px; transition: transform .25s cubic-bezier(.3, 1.4, .4, 1); }
++.ovh-act-t { font-size: 11.5px; font-weight: 700; line-height: 1.1; color: var(--text-secondary, #a8b3c4); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
++.ovh-act-ab { font-size: 13px; font-weight: 800; line-height: 20px; }
++.ovh-mebox .ovh-act.is-own { color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 13%, transparent); }
++.ovh-mebox .ovh-act.is-own .ovh-act-t { color: inherit; }
++@media (hover: hover) and (pointer: fine) {
++ .ovh-mebox .ovh-act:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 10%, transparent); transform: translateY(-1px); }
++ .ovh-mebox .ovh-act:hover svg { transform: translateY(-1px) scale(1.06); }
++ .ovh-mebox .ovh-out:hover { color: var(--danger, #f87171); background: color-mix(in srgb, var(--danger, #ef4444) 12%, transparent); }
++}
++.ovh-mebox .ovh-act:active { transform: scale(.94); }
++.ovh-mebox .ovh-act[aria-expanded="true"] { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
++.ovh-mebox .ovh-act[aria-expanded="true"] .ovh-act-t { color: inherit; }
++html[data-ov-motion=off] .ovh-mebox .ovh-act, html[data-ov-motion=off] .ovh-mebox .ovh-act svg, html[data-ov-motion=off] .ovh-me-gear svg { transition: none; }
.ovh-ib.is-own { color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 13%, transparent); }
.ovh-ib.is-text { width: auto; padding: 0 12px; border-radius: 999px; font-size: 12.5px; font-weight: 750; white-space: nowrap; }
@container ovh (max-width: 520px) {
@@@ -307,18 +336,24 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
holds All and Edit; from 520 px every tab says its name. It stays at the top while the apps scroll. */
.ovh-tabrow { position: sticky; top: 0; z-index: 4; display: flex; align-items: center; gap: 8px; margin: 8px 0 6px; padding: 4px;
background: color-mix(in srgb, var(--bg-secondary, #121826) 90%, transparent); -webkit-backdrop-filter: blur(14px) saturate(1.3); backdrop-filter: blur(14px) saturate(1.3); border-radius: 16px; }
--.ovh-tabrow .ovh-tabs { flex: 1 1 auto; min-width: 0; display: flex; gap: 4px; margin: 0; padding: 0; background: none; }
--.ovh-tabrow .ovh-tab { flex: 0 0 auto; min-width: 44px; min-height: 42px; padding: 0 12px; border-radius: 12px; transition: background-color .18s, color .18s, flex-grow .25s; }
--.ovh-tabrow .ovh-tab[aria-selected="true"] { flex: 1 1 auto; }
++/* 4.236.0 (the owner, 11-13: "more apparent that these are tabs … apps, then notifications, then history … the edit
++ button twice … use the icon only"): the three tabs are one segmented track, each with its glyph and name, the chosen
++ one lit; All and Edit are two round glyphs at the end (Edit turns into the check while you arrange). */
++.ovh-tabrow .ovh-tabs { flex: 1 1 auto; min-width: 0; display: flex; gap: 2px; margin: 0; padding: 3px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }
++.ovh-tabrow .ovh-tab { flex: 1 1 0; min-width: 0; min-height: 40px; padding: 0 6px; gap: 6px; border-radius: 11px; justify-content: center; transition: background-color .18s, color .18s; }
++.ovh-tabrow .ovh-tab .ovh-tab-t { font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovh-tabrow .ovh-headtools { gap: 8px; }
++.ovh-headtools .ovh-edit.is-done { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); animation: ovhDoneIn .3s cubic-bezier(.34, 1.56, .64, 1); }
++@keyframes ovhDoneIn { from { transform: scale(.8) rotate(-20deg); } }
++html[data-ov-motion=off] .ovh-headtools .ovh-edit.is-done { animation: none; }
.ovh-tabrow .ovh-headtools { flex: none; display: flex; align-items: center; gap: 4px; }
@container ovh (max-width: 519px) {
-- .ovh-tabrow .ovh-tab:not([aria-selected="true"]) .ovh-tab-t { display: none; }
-- .ovh-tabrow .ovh-tab:not([aria-selected="true"]) { padding: 0 10px; }
++ .ovh-tabrow .ovh-tab { padding: 0 4px; gap: 5px; }
}
--@container ovh (min-width: 520px) { .ovh-tabrow .ovh-tab { flex: 0 1 auto; } .ovh-tabrow .ovh-tab[aria-selected="true"] { flex: 0 1 auto; } }
++@container ovh (max-width: 340px) { .ovh-tabrow .ovh-tab:not([aria-selected="true"]) .ovh-tab-t { display: none; } }
.ovh-tabrow .ovh-allbtn, .ovh-tabrow .ovh-edit { min-height: 42px; }
.ovh-tabrow .ovh-edit { width: 42px; }
--.ovh-tabrow .ovh-edit.is-done { width: auto; }
++.ovh-headtools .ovh-edit.is-done { width: auto; }
/* Section heads: a marked glyph, the name, its count; buttons at the end. */
.ovh-sec { gap: 8px; padding: 12px 6px 6px; }
.ovh-sec > span { flex: 0 1 auto; min-width: 0; }
@@@ -406,11 +441,12 @@ html[data-ov-motion=reduced] .ovh[data-enter] > :not(.ovh-top):not(.ovh-tabrow)
html[data-ov-motion=off] .ovh[data-enter] > :not(.ovh-top):not(.ovh-tabrow) { animation: none; }
.ovh-tabrow .ovh-allbtn, .ovh-tabrow .ovh-edit { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 42px; border-radius: 12px; cursor: pointer;
font-size: 13.5px; font-weight: 700; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent); transition: background-color .15s, color .15s, transform .12s; -webkit-tap-highlight-color: transparent; }
--.ovh-tabrow .ovh-allbtn { padding: 0 12px 0 10px; }
--.ovh-tabrow .ovh-edit { width: 42px; }
--.ovh-tabrow .ovh-edit.is-done { width: auto; padding: 0 14px 0 11px; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
++.ovh-tabrow .ovh-allbtn, .ovh-tabrow .ovh-edit { position: relative; width: 44px; min-width: 44px; height: 44px; padding: 0; }
++.ovh-headtools .ovh-edit.is-done { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
.ovh-tabrow .ovh-allbtn svg, .ovh-tabrow .ovh-edit svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
--.ovh-tabrow .ovh-allbtn b { font-weight: 800; font-variant-numeric: tabular-nums; color: var(--text-primary, #e8ecf3); }
++.ovh-headtools .ovh-allbtn b { font-weight: 800; font-variant-numeric: tabular-nums; color: var(--text-primary, #e8ecf3); }
++.ovh-tabrow .ovh-allbtn b { position: absolute; top: -5px; right: -5px; min-width: 18px; height: 18px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; display: grid; place-items: center; font-size: 10.5px; line-height: 1;
++ background: var(--bg-tertiary, #1f2937); box-shadow: 0 0 0 2px var(--bg-secondary, #141a24); }
.ovh-tabrow .ovh-allbtn:focus-visible, .ovh-tabrow .ovh-edit:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
.ovh-tabrow .ovh-allbtn:active, .ovh-tabrow .ovh-edit:active { transform: scale(.94); }
@media (hover: hover) and (pointer: fine) { .ovh-tabrow .ovh-allbtn:hover, .ovh-tabrow .ovh-edit:not(.is-done):hover { color: var(--text-primary, #e8ecf3); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 13%, transparent); } }
@@@ -472,6 +508,9 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
function writeLocal(l) { try { localStorage.setItem(HOME_KEY, JSON.stringify(l)); } catch { /* a private window keeps it for this opening */ } }
const homeUrl = (o) => `${o.apiBase || 'https://openvibe.network'}/api/v1/me/home-screen`;
const signedIn = (o) => !!(o.token && o.user && !o.user.is_anon);
++ // A person the bar knows, with or without a token for the network's reads (a site with its own session, Run's):
++ // You's tabs are theirs either way (4.236.0; the owner, 11-13: "what happened to all the tabs … bring all that back").
++ const known = (o) => !!(o.user && !o.user.is_anon);
let putTimer = 0;
function pushRemote(o, l) {
if (!signedIn(o) || typeof fetch !== 'function') return;
@@@ -685,8 +724,12 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
who.append(av, txt);
const look = h('a', 'ovh-ib'); look.append(svgIcon('look')); look.href = 'https://openvibe.design/look'; look.setAttribute('data-ov-look-open', '');
look.setAttribute('aria-expanded', 'false'); look.setAttribute('aria-label', 'Display: theme, text size, motion'); look.title = 'Display';
-- const gear = h('a', 'ovh-ib'); gear.append(svgIcon('gear')); gear.href = 'https://openvibe.network/my'; gear.setAttribute('aria-label', 'Account and settings'); gear.title = 'Account and settings';
-- bar.append(who);
++ const gear = h('a', 'ovh-ib ovh-me-gear'); gear.append(svgIcon('gear')); gear.href = 'https://openvibe.network/my'; gear.setAttribute('aria-label', 'Account and settings'); gear.setAttribute('data-ov-tip', 'Account and settings');
++ // 4.236.0 (the owner, 11-13: "completely redesign this area … confusing what these buttons even do … not
++ // perfectly aligned … tooltips … a waste of space in the corners"): your name and the gear on one line; under
++ // it one row of actions across the whole card, each its glyph with its name under it, in equal columns.
++ const named = (b, label, short) => { b.classList.add('ovh-act'); b.append(h('span', 'ovh-act-t', short || label)); b.setAttribute('aria-label', label); b.setAttribute('data-ov-tip', label); return b; };
++ bar.append(who, gear);
const m = o.me || {};
const acts = h('div', 'ovh-acts'); acts.setAttribute('role', 'group'); acts.setAttribute('aria-label', 'Yours');
// This site's own items (your channel, Admin, what a site adds) as round buttons beside Display and Settings:
@@@ -697,16 +740,16 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
const g = OWN_GLYPH[it.id] || (ICON[it.glyph] ? it.glyph : '');
const b = it.href ? h('a', `ovh-ib is-own${g ? '' : ' is-text'}`) : h('button', `ovh-ib is-own${g ? '' : ' is-text'}`);
if (it.href) b.href = it.href; else { b.type = 'button'; b.addEventListener('click', () => { try { it.onClick(); } catch { /* the item is the site's */ } }); }
-- if (g) b.append(svgIcon(g)); else b.textContent = it.short || it.label;
-- b.setAttribute('aria-label', it.label); b.setAttribute('data-ov-tip', it.label);
-- acts.append(b);
++ b.append(g ? svgIcon(g) : h('b', 'ovh-act-ab', String(it.short || it.label).slice(0, 2)));
++ acts.append(named(b, it.label, it.short || (it.id === 'ov-channel' ? 'Channel' : it.id === 'ov-admin' ? 'Admin' : '')));
});
-- if (m.add && safe(m.add)) { const add = h('a', 'ovh-ib'); add.append(svgIcon('plus')); add.href = m.add; add.setAttribute('aria-label', 'Add or switch account'); add.setAttribute('data-ov-tip', 'Add or switch account'); acts.append(add); }
-- acts.append(look, gear);
-- if (typeof m.out === 'function') { const out = h('button', 'ovh-ib ovh-out'); out.type = 'button'; out.append(svgIcon('out')); out.setAttribute('aria-label', 'Sign out'); out.setAttribute('data-ov-tip', 'Sign out'); out.addEventListener('click', () => { try { m.out(); } catch { /* the bar's own */ } }); acts.append(out); }
-- bar.append(acts);
++ look.classList.remove('ovh-ib'); look.classList.add('ovh-ib'); named(look, 'Display: theme, text size, motion', 'Display'); look.removeAttribute('title');
++ acts.append(look);
++ if (m.add && safe(m.add)) { const add = h('a', 'ovh-ib'); add.append(svgIcon('plus')); add.href = m.add; acts.append(named(add, 'Add or switch account', 'Switch')); }
++ if (typeof m.out === 'function') { const out = h('button', 'ovh-ib ovh-out'); out.type = 'button'; out.append(svgIcon('out')); named(out, 'Sign out'); out.addEventListener('click', () => { try { m.out(); } catch { /* the bar's own */ } }); acts.append(out); }
const panel = h('div', 'ud-look ovh-me-look'); panel.hidden = true;
-- meBox.append(bar, panel);
++ meBox.className = 'ovh-mebox';
++ meBox.append(bar, acts, panel);
return meBox;
}
@@@ -727,10 +770,10 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
document.addEventListener('openvibe-notifications-count', onCount);
}
let unread = 0; try { unread = typeof o.unread === 'function' ? Number(o.unread()) || 0 : 0; } catch { unread = 0; }
-- [['apps', 'Apps', 'grid'], ['notifications', 'Notifications', 'bell'], ['recent', 'Recent', 'history']].forEach(([id, label, ic]) => {
++ [['apps', 'Apps', 'grid'], ['notifications', 'Notifications', 'bell', 'Alerts'], ['recent', 'History', 'history']].forEach(([id, label, ic, short]) => {
const b = h('button', 'ovh-tab'); b.type = 'button'; b.setAttribute('role', 'tab'); b.dataset.tab = id;
b.setAttribute('aria-selected', String(state.tab === id));
-- b.append(svgIcon(ic), h('span', 'ovh-tab-t', label)); b.setAttribute('aria-label', label); b.setAttribute('data-ov-tip', label);
++ b.append(svgIcon(ic), h('span', 'ovh-tab-t', short || label)); b.setAttribute('aria-label', label); b.setAttribute('data-ov-tip', label);
if (id === 'notifications' && unread > 0) { b.append(h('b', 'ovh-tab-n', unread > 99 ? '99+' : String(unread))); b.setAttribute('aria-label', `Notifications, ${unread} new`); }
b.addEventListener('click', () => {
if (state.tab === id) return;
@@@ -766,6 +809,13 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
function notes() {
if (notesBox) return notesBox;
notesBox = h('div', 'ovh-notes');
++ // Without a token here (a site with its own session) the list is on the person's account page, one tap away.
++ if (!o.token) {
++ notesBox.append(h('p', 'ovh-hint', 'Your notifications are on your account.'));
++ const go = h('a', 'ovh-ib is-text', 'Open notifications'); go.href = 'https://openvibe.network/my#notifications';
++ notesBox.append(go);
++ return notesBox;
++ }
if (typeof o.notifications === 'function') { try { o.notifications(notesBox); } catch { notesBox.append(h('p', 'ovh-hint', 'Notifications did not load. Close the menu and open it again.')); } }
else notesBox.append(h('p', 'ovh-hint', 'Notifications are on openvibe.network/my.'));
return notesBox;
@@@ -894,9 +944,9 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
// 4.223.0 (the owner, 11-12: "at the top … the goosely Online bar. Under that is tabs … put the apps more front
// facing … the first tab"; then "compact … waste of space"): one row of tabs, and on Apps the All and Edit
// buttons at its end, so no head of its own. Edit stays pressable while the rest is quiet.
-- const tab = signedIn(o) ? state.tab : 'apps';
++ const tab = known(o) ? state.tab : 'apps';
const tools = tab === 'apps' ? homeTools() : null;
-- if (signedIn(o)) {
++ if (known(o)) {
const row = h('div', 'ovh-tabrow');
row.append(calm(youTabs()));
if (tools) row.append(tools);
@@@ -919,7 +969,9 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
const grid = h('div', 'ovh-grid'); grid.setAttribute('aria-label', 'Your home');
L.home.forEach((id, i) => grid.append(homeTile(id, i)));
el.append(grid);
-- if (state.edit) el.append(h('p', 'ovh-hint', 'Drag an app to move it, or use the arrow keys. − takes it off Home; All puts it back.'));
++ if (state.edit) {
++ el.append(h('p', 'ovh-hint', 'Hold an app, then drag it (a quick swipe scrolls), or use the arrow keys. − takes it off Home; All puts it back.'));
++ }
// Signed out there are no tabs: what you use most and where you were stay under the apps.
if (!signedIn(o)) { const u = usage(); if (u) el.append(calm(u)); const b = backRail(); if (b) el.append(calm(b)); }
icons();
@@@ -973,16 +1025,35 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
// the tile under the finger leaves the page and the grid's own touchmove guard no longer hears this touch;
// the browser scrolled the menu and cancelled the press. The guard rides on the pressed tile itself, which
// keeps getting the touch's events after it is replaced, until the finger lifts.
++ // 4.236.0 (the owner, 11-13: "in edit mode … trying to scroll up on mobile … you can't"): in Edit a touch
++ // only holds the page once it has taken an app (a hold of ARM_MS, or a sideways move); a quick swipe up or
++ // down scrolls the menu as anywhere else.
++ let armed = false, armTimer = 0;
if (e.pointerType === 'touch') {
-- const hold = (ev) => { if ((state.edit || state.drag) && ev.cancelable) ev.preventDefault(); };
++ const hold = (ev) => { if ((armed || state.drag) && ev.cancelable) ev.preventDefault(); };
const done = () => { t.removeEventListener('touchmove', hold); t.removeEventListener('touchend', done); t.removeEventListener('touchcancel', done); };
t.addEventListener('touchmove', hold, { passive: false });
t.addEventListener('touchend', done);
t.addEventListener('touchcancel', done);
}
let timer = 0;
-- const cancel = () => { clearTimeout(timer); window.removeEventListener('pointermove', early); window.removeEventListener('pointerup', cancel); window.removeEventListener('pointercancel', cancel); };
-- const early = (m) => { if (m.pointerId === start.id && Math.hypot(m.clientX - start.x, m.clientY - start.y) > 8) { cancel(); if (state.edit) beginDrag(id, m); } };
++ const cancel = () => { clearTimeout(timer); clearTimeout(armTimer); window.removeEventListener('pointermove', early); window.removeEventListener('pointerup', cancel); window.removeEventListener('pointercancel', cancel); };
++ const early = (m) => {
++ if (m.pointerId !== start.id) return;
++ const dx = m.clientX - start.x, dy = m.clientY - start.y;
++ if (Math.hypot(dx, dy) <= 8) return;
++ // A touch not yet holding an app that moves mostly up or down is a scroll: let it go.
++ if (e.pointerType === 'touch' && state.edit && !armed && Math.abs(dy) > Math.abs(dx)) { cancel(); return; }
++ cancel(); if (state.edit) beginDrag(id, m);
++ };
++ if (e.pointerType === 'touch' && state.edit) {
++ armTimer = setTimeout(() => {
++ armed = true;
++ try { if (navigator.vibrate) navigator.vibrate(8); } catch { /* */ }
++ const n = el.querySelector(`.ovh-grid:not(.ovh-recent) [data-id="${CSS.escape(id)}"]`); if (n) n.classList.add('is-armed');
++ window.addEventListener('pointerup', () => { const k = el.querySelector('.is-armed'); if (k) k.classList.remove('is-armed'); }, { once: true });
++ }, ARM_MS);
++ }
window.addEventListener('pointermove', early);
window.addEventListener('pointerup', cancel);
window.addEventListener('pointercancel', cancel);
@@@ -1005,7 +1076,7 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
// While editing, a finger on the grid never scrolls the page (11-09, the owner: holding an icon "scrolled up and
// down… even though I'm in edit mode").
-- if (!el.__ovhTouch) { el.__ovhTouch = true; el.addEventListener('touchmove', (e) => { if ((state.edit || state.drag) && e.target.closest && e.target.closest('.ovh-grid:not(.ovh-recent)') && e.cancelable) e.preventDefault(); }, { passive: false }); }
++ if (!el.__ovhTouch) { el.__ovhTouch = true; el.addEventListener('touchmove', (e) => { if (state.drag && e.target.closest && e.target.closest('.ovh-grid:not(.ovh-recent)') && e.cancelable) e.preventDefault(); }, { passive: false }); }
function beginDrag(id, e) {
if (state.drag) return;
const ghost = h('div', 'ovh-ghost');
@@@ -1063,17 +1134,16 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
const edit = h('button', state.edit ? 'ovh-edit is-done' : 'ovh-edit');
edit.type = 'button';
edit.append(svgIcon(state.edit ? 'check' : 'pencil'));
-- if (state.edit) edit.append(h('span', '', 'Done'));
edit.setAttribute('aria-label', state.edit ? 'Done arranging Home' : 'Edit Home');
-- edit.title = state.edit ? 'Done' : 'Edit Home: move apps, remove them, add folders';
++ edit.setAttribute('data-ov-tip', state.edit ? 'Done' : 'Edit Home: move apps, remove them, add folders');
edit.setAttribute('aria-pressed', state.edit ? 'true' : 'false');
edit.addEventListener('click', () => setEdit(!state.edit));
// All apps sits beside Edit, small, with its count (the owner, 2026-11-10: "all apps should just be in a
// button in the corner so it's more compact").
const total = L.byId.size;
const all = h('button', 'ovh-allbtn'); all.type = 'button';
-- all.append(svgIcon('grid'), h('span', '', 'All'), h('b', '', String(total)));
-- all.setAttribute('aria-label', `All apps (${total})`); all.title = `All apps (${total})`;
++ all.append(svgIcon('grid'), h('b', '', String(total)));
++ all.setAttribute('aria-label', `All apps (${total})`); all.setAttribute('data-ov-tip', `All apps (${total})`);
all.addEventListener('click', () => { state.view = 'drawer'; paint(); });
const tools = h('div', 'ovh-headtools'); tools.append(all, edit);
return tools;
ddiff --git a/ov-mark.js b/ov-mark.js
iindex aca4222..2fcff27 100644
---- a/ov-mark.js
++++ b/ov-mark.js
@@@ -26,9 +26,9 @@
* - a page on its way (navbar): the ring empties and draws itself round again as the next page loads, paced by how
* long this site's pages usually take in this browser (learned per site, boost moves and full loads apart); the
* V nods while it waits and hops when the page is here (window.OVMark.navStart/navEnd for single-page apps);
-- * - busy (working): the O breaks into eight dots and three bright ones go round it, dot to dot, while the V bobs
-- * (the owner, 2026-11-10: "make the O turn into a bunch of little circles… a spinner that has little dots around
-- * it… that actually looks like it's loading"); think (your agent is thinking or writing): three dots rise in turn
++ * - busy (working): the O stays the logo's own ring, a faint track, and a bright arc runs round it, growing as it
++ * goes and drawing in behind, while the V lifts in time and the glow breathes (4.236.0; it was eight thinner dots,
++ * 2026-11-10, which sat inside the ring's line: "improve the O so it actually looks like the original"); think (your agent is thinking or writing): three dots rise in turn
* inside the O and the V's two strokes glimmer one after the other, the one sign used wherever tokens are on
* their way; ok: the V becomes a tick, green, and hops (the tick only ever
* means "done"); error: the V becomes "!", red, and the mark shakes; no: the V becomes "×", grey, and the O
@@@ -140,12 +140,15 @@ html[data-ov-season-mark] .ov-mark[data-ovm-intro] .sl{animation:ovmLeaf .9s cub
.ov-mark[data-ovm-arrive] .vg{animation:ovmHop .5s ease-out 1}
.ov-mark[data-ovm-arrive] .og{animation:ovmPulse .5s ease-out 1}
/* The states. */
--.ov-mark[data-state=busy] .vg{animation:ovmBob .9s ease-in-out infinite}
--/* Working: the O as eight dots (a dash of no length with a round cap is a dot), three bright ones hopping round. */
--.ov-mark[data-state=busy] :is(.o,.ot){stroke-linecap:round;stroke-width:var(--ovm-dot-w)}
--.ov-mark[data-state=busy] .ot{opacity:.3;stroke-dasharray:0 12.5}
--.ov-mark[data-state=busy] .o{stroke-dasharray:0 12.5 0 12.5 0 75;animation:ovmDots .96s steps(8,end) infinite}
++/* Working (4.236.0; the owner, 11-13: "improve the O so it actually looks like the original, aligned properly … a better
++ cool animation"): the O stays the logo's own ring, its full weight a faint track, and a bright arc runs round it,
++ growing as it goes and drawing in behind (its head runs ahead of its tail). The V lifts a little in time with it and
++ the glow behind breathes; one rhythm, 1.25 s. The eight thinner dots it replaces sat inside the ring's line. */
++.ov-mark[data-state=busy] .ot{opacity:.24}
++.ov-mark[data-state=busy] .o{stroke-linecap:round;stroke-dasharray:6 94;animation:ovmArc 1.25s cubic-bezier(.5,.1,.3,1) infinite}
.ov-mark[data-state=busy] .og{animation:ovmSettle .4s cubic-bezier(.2,1.3,.3,1) 1}
++.ov-mark[data-state=busy] .vg{animation:ovmLift 1.25s ease-in-out infinite}
++.ov-mark[data-state=busy]::before{animation:ovmGlowBusy 1.25s ease-in-out infinite}
/* Thinking: three dots inside the O rise in turn, the ring quiets, the V's strokes glimmer one after the other. */
.ov-mark .td{display:none;fill:var(--ovm-o);transform-box:fill-box;transform-origin:center}
.ov-mark[data-state=think] .td{display:inline;animation:ovmThink 1.2s ease-in-out infinite}
@@@ -190,7 +193,9 @@ html[data-ov-season-mark] .ov-mark[data-ovm-intro] .sl{animation:ovmLeaf .9s cub
@keyframes ovmHop{0%,100%{transform:translateY(0) scaleY(1)}35%{transform:translateY(-9px) scaleY(1.04)}70%{transform:translateY(0) scaleY(.96)}}
@keyframes ovmBob{0%,100%{transform:translateY(0)}50%{transform:translateY(6px)}}
@keyframes ovmBreathe{0%,100%{transform:scale(1)}50%{transform:scale(.88)}}
--@keyframes ovmDots{to{stroke-dashoffset:-100}}
++@keyframes ovmArc{0%{stroke-dasharray:6 94;stroke-dashoffset:0}50%{stroke-dasharray:44 56;stroke-dashoffset:-20}100%{stroke-dasharray:6 94;stroke-dashoffset:-100}}
++@keyframes ovmLift{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.5px)}}
++@keyframes ovmGlowBusy{0%,100%{opacity:.16;transform:scale(.96)}50%{opacity:.4;transform:scale(1.08)}}
@keyframes ovmSettle{from{transform:scale(.86)}}
@keyframes ovmThink{0%,55%,100%{transform:translateY(0);opacity:.4}28%{transform:translateY(-2.6px);opacity:1}}
@keyframes ovmSpeak{0%,100%{opacity:1}50%{opacity:.5}}
@@@ -231,7 +236,7 @@ html[data-ov-motion=off] .ov-mark:not([data-fx-demo] *) *{animation-iteration-co
<linearGradient id="${id}gg"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".8"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>
<mask id="${id}s" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><g mask="url(#${id}h)"><g class="og" style="${oc}"><path d="${G.ring}" fill="none" stroke="#fff" stroke-width="${G.sw}"/></g></g><g class="vg" style="${vc}"><path class="ma" d="${seg(p, 0)}" fill="none" stroke="#fff" stroke-linecap="round" stroke-width="${G.vsw}"/><path class="mb" d="${seg(p, 4)}" fill="none" stroke="#fff" stroke-linecap="round" stroke-width="${G.vsw}"/></g></mask></defs>
<g class="m"><circle class="rp" cx="${G.cx}" cy="${G.cy}" r="${G.R}"/><circle class="rp rp2" cx="${G.cx}" cy="${G.cy}" r="${G.R}"/>
--<g mask="url(#${id}h)"><g class="og" style="${oc};--ovm-dot-w:${f(G.sw * 0.62)}"><path class="ot" d="${G.ring}" pathLength="100" stroke-width="${G.sw}"/><path class="o" d="${G.ring}" pathLength="100" stroke-width="${G.sw}" style="stroke:url(#${id}go)"/><circle class="cd" cx="${G.cx}" cy="${G.cy}" r="${f(ri * 0.62)}"/>${[-1, 0, 1].map((k, n) => `<circle class="td${n ? ` td${n + 1}` : ''}" cx="${f(G.cx + k * ri * 0.52)}" cy="${G.cy}" r="${f(ri * 0.21)}"/>`).join('')}
++<g mask="url(#${id}h)"><g class="og" style="${oc}"><path class="ot" d="${G.ring}" pathLength="100" stroke-width="${G.sw}"/><path class="o" d="${G.ring}" pathLength="100" stroke-width="${G.sw}" style="stroke:url(#${id}go)"/><circle class="cd" cx="${G.cx}" cy="${G.cy}" r="${f(ri * 0.62)}"/>${[-1, 0, 1].map((k, n) => `<circle class="td${n ? ` td${n + 1}` : ''}" cx="${f(G.cx + k * ri * 0.52)}" cy="${G.cy}" r="${f(ri * 0.21)}"/>`).join('')}
<g class="sw2"><path class="sc" d="M${c0[0]} ${c0[1]}A${capR} ${capR} 0 0 1 ${c1[0]} ${c1[1]}"/></g></g></g>
<g class="vg" style="${vc}"><path class="va" d="${seg(p, 0)}" stroke-width="${G.vsw}" style="stroke:url(#${id}gv)"/><path class="vb" d="${seg(p, 4)}" stroke-width="${G.vsw}" style="stroke:url(#${id}gv)"/>
<g class="sw2"><circle class="sd" cx="${G.A[0]}" cy="${f(G.A[1] - G.vsw * 0.18)}" r="${f(G.vsw * 0.36)}"/><circle class="sd" cx="${G.B[0]}" cy="${f(G.B[1] - G.vsw * 0.18)}" r="${f(G.vsw * 0.36)}"/></g></g>
ddiff --git a/ov-thread.js b/ov-thread.js
iindex b7d14f3..aed0bb9 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -24,7 +24,7 @@
// 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);
-- const THREAD_REV = { 'ov-composer.js': '53893bb875b6', 'ov-combo.js': '8001a3b19c2f' };
++ const THREAD_REV = { 'ov-composer.js': '021d6850b659', 'ov-combo.js': '8001a3b19c2f' };
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() {
ddiff --git a/package-lock.json b/package-lock.json
iindex d6bbe7e..4befc09 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.235.0",
++ "version": "4.236.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.235.0",
++ "version": "4.236.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 6cac864..8ce95c1 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.235.0",
++ "version": "4.236.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/search.js b/search.js
iindex e7a9a28..042ed78 100644
---- a/search.js
++++ b/search.js
@@@ -283,6 +283,11 @@
.ovs-starttabs .ovs-tab .ovs-tab-ic { position: relative; flex: none; display: grid; place-items: center; width: 22px; height: 22px; overflow: visible; }
.ovs-tab-ic > svg { width: 20px; height: 20px; }
.ovs-tab-ic .ov-icon { display: grid; place-items: center; }
++/* 4.236.0, the tab's nudge: this site's tab rolls its glyph and name through the places in its section, and back. */
++.ovs-tab .ovs-roll { animation: ovsRoll .38s cubic-bezier(.2, .9, .25, 1); }
++@keyframes ovsRoll { from { opacity: 0; transform: translateY(65%); } 60% { opacity: 1; } }
++.ovs-tab.is-rolling > span:not(.ovs-tab-ic) { color: var(--accent-light, var(--accent, #60a5fa)); transition: color .3s; }
++html[data-ov-motion=off] .ovs-tab .ovs-roll { animation: none; }
/* The site's tab wears its glyph like Feed and You (owner, 11-09): no disc and no ring, the glyph at their weight and in
the label's colour. The icon's box is drawn larger than the slot so the solid glyph itself is about 18 px. */
.ovs-tab-ic .ov-icon .ovi-bg, .ovs-tab-ic .ov-icon .ovi-ring, .ovs-tab-ic .ov-icon .ovi-comet, .ovs-tab-ic .ov-icon .ovi-prog { display: none; }
@@@ -1351,7 +1356,9 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
try { localStorage.setItem(FEED_COUNT, String(fresh)); } catch { /* */ }
paintFeedDot(fresh);
try { feedChannel?.postMessage({ type: 'count', count: fresh }); } catch { /* */ }
-- const me = ctx.token && ctx.user && !ctx.user.is_anon ? ctx.user : null;
++ // Your picture on You whenever the bar knows you, with or without a token for the network's reads (4.236.0; the
++ // owner, 11-13: "what happened to the user avatar": on a site with its own session it fell back to a glyph).
++ const me = ctx.user && !ctx.user.is_anon ? ctx.user : null;
let siteId = ctx.service || '';
try { const b0 = basis(); if (b0.mineSite) siteId = b0.mineSite.icon || b0.mineSite.id; } catch { /* the sites load later */ }
// One row of places (4.194.0): this site, the feed, your messages, AI (Vibe and your agents), you. Messages and
@@@ -1393,6 +1400,70 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
tabsEl.appendChild(b);
}
if (typeof ctx.icons === 'function') { try { ctx.icons(tabsEl); } catch { /* the glyphs come with ov-icons.js */ } }
++ if (!tabsEl.__ovsRollWired) { tabsEl.__ovsRollWired = true; tabsEl.addEventListener('pointerdown', rollStop, true); tabsEl.addEventListener('keydown', rollStop, true); }
++ rollSchedule();
++ }
++ /**
++ * The tab's nudge (4.236.0; the owner, 11-13: "every once in a while it swaps between all the options that exist in
++ * that section … scroll through all these icons and labels … nudges the user"): this site's tab rolls its glyph and
++ * name through the other places in its section, a beat each, and back to its own; once a little after the menu
++ * opens, then every 40 s while it is open and nobody is typing or pointing at the tabs. Calm rolls once an opening;
++ * Off never. Its accessible name stays its own, and a press stops it at once.
++ */
++ let rollTimer = 0, rollRun = 0, rolledOpen = false;
++ function rollSteps() {
++ try {
++ const b0 = basis();
++ const mineSite = b0.mineSite;
++ if (!mineSite) return [];
++ return b0.ranked.filter((x) => x.group === mineSite.group && x.id !== mineSite.id && x.name).slice(0, 6).map((x) => ({ label: x.name, icon: x.icon || x.id }));
++ } catch { return []; }
++ }
++ function rollStop() {
++ rollRun += 1;
++ const b = tabsEl && tabsEl.querySelector('.ovs-tab[data-tab="here"].is-rolling');
++ if (!b || !b.__ovsOwn) return;
++ const t = b.querySelector(':scope > span:not(.ovs-tab-ic)'), g = b.querySelector('.ovs-tab-ic .ov-icon');
++ if (t) t.textContent = b.__ovsOwn.label;
++ if (g && b.__ovsOwn.icon) { g.setAttribute('data-icon', b.__ovsOwn.icon); if (typeof ctx.icons === 'function') { try { ctx.icons(b); } catch { /* */ } } }
++ b.classList.remove('is-rolling');
++ }
++ function rollHere() {
++ if (document.documentElement.getAttribute('data-ov-motion') === 'off') return;
++ const b = tabsEl && tabsEl.querySelector('.ovs-tab[data-tab="here"]');
++ const steps = rollSteps();
++ if (!b || steps.length < 2 || b.classList.contains('is-rolling')) return;
++ const t = b.querySelector(':scope > span:not(.ovs-tab-ic)'), g = b.querySelector('.ovs-tab-ic .ov-icon');
++ if (!t) return;
++ b.__ovsOwn = { label: t.textContent, icon: g ? g.getAttribute('data-icon') : null };
++ b.setAttribute('aria-label', b.__ovsOwn.label);
++ const seq = steps.concat([b.__ovsOwn]);
++ const run = ++rollRun;
++ b.classList.add('is-rolling');
++ let i = 0;
++ const beat = () => {
++ if (run !== rollRun || !b.isConnected) return;
++ const st = seq[i++];
++ for (const x of [t, g]) if (x) x.classList.remove('ovs-roll');
++ void t.offsetWidth;
++ t.textContent = st.label;
++ if (g && st.icon) { g.setAttribute('data-icon', st.icon); if (typeof ctx.icons === 'function') { try { ctx.icons(b); } catch { /* */ } } }
++ for (const x of [t, g]) if (x) x.classList.add('ovs-roll');
++ if (i < seq.length) setTimeout(beat, 820); else setTimeout(() => { if (run === rollRun) b.classList.remove('is-rolling'); }, 420);
++ };
++ beat();
++ }
++ function rollSchedule() {
++ if (rolledOpen) return;
++ rolledOpen = true;
++ setTimeout(() => { if (el && el.classList.contains('is-open') && !input.value) rollHere(); }, 1400);
++ clearInterval(rollTimer);
++ rollTimer = setInterval(() => {
++ if (!el || !el.classList.contains('is-open')) { clearInterval(rollTimer); rolledOpen = false; return; }
++ if (document.hidden || input.value || tabsEl.matches(':hover, :focus-within')) return;
++ if (document.documentElement.getAttribute('data-ov-motion') === 'reduced') return;
++ rollHere();
++ }, 40000);
}
const bsvg = (d) => `<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
const BRIDGE_SVG = {
@@@ -2102,6 +2173,7 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
closing = setTimeout(() => { closing = 0; el.classList.remove('is-open', 'is-closing'); if (orbEl) orbEl.classList.remove('is-from', 'is-gone'); }, 280);
} else {
el.classList.remove('is-open');
++ rollStop(); clearInterval(rollTimer); rolledOpen = false;
if (orbEl) orbEl.classList.remove('is-x', 'is-from', 'is-pop', 'is-gone');
}
if (surface) { surface.destroy(); surface = null; }
ddiff --git a/test/composer-bar-send.test.js b/test/composer-bar-send.test.js
nnew file mode 100644
iindex 0000000..55fc455
---- /dev/null
++++ b/test/composer-bar-send.test.js
@@@ -0,0 +1,21 @@
++'use strict';
++// 4.236.0 (the owner's phone, 11-13: "support stuff like a replying to area or different content above … more connected
++// to the actual composer … improve the send button"): any context sits inside the box with a rail of the accent
++// (bar({ icon, title, detail, kind, onClose }), as reply and edit do), and Send with nothing to send is a quiet outline
++// that fills with the accent on a spring when there is.
++//
++// node test/composer-bar-send.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const src = fs.readFileSync(path.join(__dirname, '..', 'ov-composer.js'), 'utf8');
++
++assert.ok(/values, edit, reply, bar: contextBar, clearBar,/.test(src), 'the box answers bar()');
++assert.ok(/const contextBar = \(spec = \{\}\) => \{[\s\S]{0,400}showBar\(PATH_OK\.test\(spec\.icon/.test(src), 'a bar of any kind, its icon a checked path');
++assert.ok(/\}, 'edit'\);/.test(src) && /was\.onClear\(\); \}, 'reply'\);/.test(src), 'edit and reply say their kind');
++assert.ok(/\.ovc-v2 \.ovc-bar::before \{ content: ""; position: absolute; left: 6px;[^}]*background: var\(--ovt-accent/.test(src), 'a rail of the accent at its start');
++assert.ok(/\.ovc-v2 \.ovc-bar\[data-kind=edit\]::before \{ background: var\(--warning/.test(src), 'editing wears its own colour');
++assert.ok(/\.ovc-v2 \.ovt-send--round:disabled \{ opacity: 1; background: transparent;[^}]*box-shadow: inset 0 0 0 1\.5px/.test(src), 'nothing to send: a quiet outline, not a grey disc');
++assert.ok(/\.ovc-v2 \.ovt-send--round:not\(:disabled\) \{ animation: ovcSendReady/.test(src), 'ready: it fills on a spring');
++assert.ok(/html\[data-ov-motion=off\] \.ovc-v2 \.ovt-send--round/.test(src) && /html\[data-ov-motion=off\] \.ovc-v2 \.ovc-bar \{ animation: none; \}/.test(src), 'and nothing moves with motion off');
++console.log('composer-bar-send: a context bar of any kind inside the box, a quiet Send until there is something to send');
ddiff --git a/test/menu-home-screen-chrome.test.js b/test/menu-home-screen-chrome.test.js
iindex 3b6ae39..7ec6deb 100644
---- a/test/menu-home-screen-chrome.test.js
++++ b/test/menu-home-screen-chrome.test.js
@@@ -114,7 +114,7 @@ const server = http.createServer((req, res) => {
await eventually(async () => assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Popular on OpenVibe', 'until arranged, it says what it is'));
await eventually(async () => assert.notStrictEqual(await p.evaluate(`document.querySelector('.ovs [data-tab="here"]').textContent`), 'Home', 'a page titled Home never names the first tab'));
await eventually(async () => assert.ok(await p.evaluate(`!document.querySelector('[data-sec="recent"]') && ![...document.querySelectorAll('.ovs-group')].some((g) => /Recent searches/.test(g.textContent))`), 'no Recent searches'));
-- await eventually(async () => assert.ok(await p.evaluate(`(() => { const b = document.querySelector('.ovh-headtools .ovh-allbtn'); return !!b && /All apps \\(\\d+\\)/.test(b.getAttribute('aria-label')) && /^All\\d+$/.test(b.textContent.replace(/\\s/g, '')) && b.getBoundingClientRect().width < 120; })()`), 'All apps is a small button beside Edit, and says how many'));
++ await eventually(async () => assert.ok(await p.evaluate(`(() => { const b = document.querySelector('.ovh-headtools .ovh-allbtn'); return !!b && /All apps \\(\\d+\\)/.test(b.getAttribute('aria-label')) && /^\\d+$/.test(b.textContent.replace(/\\s/g, '')) && b.getBoundingClientRect().width < 120 && !!b.getAttribute('data-ov-tip'); })()`), 'All apps is a small glyph button with its count, and a tooltip (4.236.0)'));
await eventually(async () => assert.ok(await p.evaluate(`!document.querySelector('.ovh-actor')`), 'signed out: no Actor strip'));
// A long-press starts Edit, and the same press drags the first app onto the third's place.
@@@ -145,7 +145,7 @@ const server = http.createServer((req, res) => {
const removed = await order(p);
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-edit.is-done[aria-label="Done arranging Home"]').click(), true`);
await p.evaluate(`document.querySelector('.ovh-allbtn').click(), true`);
assert.ok(await until(p, `!!document.querySelector('.ovh-head-t b') && document.querySelector('.ovh-head-t b').textContent.startsWith('All apps')`), 'the drawer opened, named, with its count');
// 4.229.0: one search. No second box: the menu's own pill finds an app in All apps.
@@@ -231,13 +231,16 @@ const server = http.createServer((req, res) => {
// button (this site's own first, as icons), one AI notice, then one row of tabs with All and Edit at its end.
await eventually(async () => assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-me .ovh-who-txt b').textContent`), 'Ada', 'your name on top'));
await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovs .ovh-account, .ovs .openvibe-navbar-dropdown, .ovs .ud-grid')`), 'the old account block and its grid of tiles are not in the menu'));
-- await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-me .ovh-acts > .ovh-ib:not(.is-own)')].map((b) => b.getAttribute('aria-label'))`), ['Add or switch account', 'Display: theme, text size, motion', 'Account and settings', 'Sign out'], 'add or switch account, Display, Settings, Sign out, in that order'));
-- await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovh-own') && [...document.querySelectorAll('.ovh-me .ovh-acts > .is-own')].every((b) => !!b.getAttribute('aria-label'))`), 'this site\'s own items are buttons in your row, never a row of their own'));
-- await eventually(async () => assert.ok(await s.evaluate(`/[?&]add_account=1/.test(document.querySelector('.ovh-me a.ovh-ib[aria-label="Add or switch account"]').getAttribute('href')) && !!document.querySelector('.ovh-me [data-ov-look-open]') && !!document.querySelector('.ovh-me a[href="https://openvibe.network/my"]')`), 'the add-account address is the bar\'s; Display and Settings are in the row'));
++ // 4.236.0 (the owner, 11-13: "completely redesign this area … confusing what these buttons even do … not perfectly
++ // aligned … tooltips … a waste of space in the corners"): your name and the gear on one line, then one row of
++ // actions across the whole card, each its glyph with its name, in equal columns; this site's own first.
++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-mebox > .ovh-acts > .ovh-act:not(.is-own)')].map((b) => b.getAttribute('aria-label'))`), ['Display: theme, text size, motion', 'Add or switch account', 'Sign out'], 'Display, add or switch account, Sign out, in that order'));
++ await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-mebox .ovh-act')].every((b) => !!b.getAttribute('data-ov-tip') && !!b.querySelector('.ovh-act-t').textContent.trim())`), 'every action says its name and has a tooltip'));
++ await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovh-own') && [...document.querySelectorAll('.ovh-mebox .ovh-acts > .is-own')].every((b) => !!b.getAttribute('aria-label'))`), 'this site\'s own items are in your row, never a row of their own'));
++ await eventually(async () => assert.ok(await s.evaluate(`/[?&]add_account=1/.test(document.querySelector('.ovh-mebox a.ovh-act[aria-label="Add or switch account"]').getAttribute('href')) && !!document.querySelector('.ovh-mebox [data-ov-look-open]') && !!document.querySelector('.ovh-me a.ovh-me-gear[href="https://openvibe.network/my"]')`), 'switch, Display, and the gear beside your name'));
await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovs-displayrow')`), 'Display is not at the foot any more'));
-- await eventually(async () => assert.ok(await s.evaluate(`document.querySelector('.ovh-me').getBoundingClientRect().height <= 112`), 'your row is one card: you, then your buttons'));
-- await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-me .ovh-ib')].every((b) => { const r = b.getBoundingClientRect(); return r.height >= 44 && r.width >= 40; })`), 'and its buttons are full touch targets'));
-- await eventually(async () => assert.ok(await s.evaluate(`(() => { const m = document.querySelector('.ovh-me').getBoundingClientRect(); return m.right <= innerWidth && [...document.querySelectorAll('.ovh-me .ovh-ib')].every((x) => x.getBoundingClientRect().right <= m.right + 1); })()`), 'nothing in your row runs off the screen'));
++ await eventually(async () => assert.ok(await s.evaluate(`(() => { const acts = [...document.querySelectorAll('.ovh-mebox .ovh-act')].map((b) => b.getBoundingClientRect()); const box = document.querySelector('.ovh-mebox').getBoundingClientRect(); return acts.length > 0 && acts.every((r) => r.height >= 44 && r.width >= 44 && r.right <= box.right + 1 && r.left >= box.left - 1) && Math.abs(acts[0].left - (box.left + 8)) < 2; })()`), 'full touch targets, inside the card, the row starting at its edge (no indent)'));
++ await eventually(async () => { const t = await s.evaluate(`[...document.querySelectorAll('.ovh-mebox .ovh-act')].map((b) => { const r = b.getBoundingClientRect(); return [Math.round(r.top), Math.round(r.left), Math.round(r.width), b.className]; })`); assert.strictEqual(new Set(t.map((x) => x[0])).size, 1, `one row, aligned: ${JSON.stringify(t)}`); });
// The tab row: Apps first and chosen; All and Edit at its end; the tabs move with the arrow keys.
await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-tabrow .ovh-tab')].map((t) => t.dataset.tab + (t.getAttribute('aria-selected') === 'true' ? '*' : ''))`), ['apps*', 'notifications', 'recent'], 'Apps, Notifications, Recent; Apps first'));
await eventually(async () => assert.ok(await s.evaluate(`!!document.querySelector('.ovh-tabrow .ovh-headtools .ovh-allbtn') && !!document.querySelector('.ovh-tabrow .ovh-headtools .ovh-edit') && !document.querySelector('.ovh-sec .ovh-edit')`), 'All and Edit sit at the end of the tab row'));
@@@ -304,6 +307,20 @@ const server = http.createServer((req, res) => {
await s.evaluate(`window.OpenVibeDock.close(), true`);
assert.ok(await until(s, `!document.querySelector('.ovs.is-open')`), 'close() closes the menu');
await s.close();
++ // OV_CAPTURE_YOU=<dir>: You at a phone's width (your card, the tabs, the grid; and Edit with its Done), for a look.
++ if (process.env.OV_CAPTURE_YOU) {
++ const shotPage = await h.openPage(browser, context, { width: 390, height: 844, block: blocked });
++ await shotPage.goto(`${base}/?signed=1`, { maxMs: 6000 });
++ await openTab(shotPage, 'you');
++ assert.ok(await until(shotPage, `!!document.querySelector('.ovh-mebox .ovh-act') && !!document.querySelector('.ovh-tabrow .ovh-tab')`));
++ const you = await shotPage.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
++ fs.writeFileSync(path.join(process.env.OV_CAPTURE_YOU, 'you-390.png'), Buffer.from(you.data, 'base64'));
++ await shotPage.evaluate(`document.querySelector('.ovh-edit').click(), true`);
++ assert.ok(await until(shotPage, `!!document.querySelector('.ovh-tabrow .ovh-edit.is-done')`), 'Edit turns into the check');
++ const ed = await shotPage.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
++ fs.writeFileSync(path.join(process.env.OV_CAPTURE_YOU, 'you-edit-390.png'), Buffer.from(ed.data, 'base64'));
++ await shotPage.close();
++ }
if (process.env.OV_CAPTURE_MENU) {
for (const width of [390, 1280]) {
const shotPage = await h.openPage(browser, context, { width, height: width === 390 ? 844 : 900, block: blocked });
ddiff --git a/test/navbar-ask-vibe.test.js b/test/navbar-ask-vibe.test.js
iindex a202b47..f355e24 100644
---- a/test/navbar-ask-vibe.test.js
++++ b/test/navbar-ask-vibe.test.js
@@@ -96,7 +96,7 @@ const server = http.createServer((req, res) => {
assert.strictEqual(u.searchParams.get('from'), null, 'an http test page is not named');
// The picture opens the menu on You, with your own row in it (4.195.0: the old account block is not set there).
const until = async (expr, ms = 6000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await p.evaluate(expr)) return true; await new Promise((r) => setTimeout(r, 80)); } return false; };
-- assert.ok(await until(`!!document.querySelector('.ovs.is-open .ovh-me .ovh-out')`), "the picture opened the menu's You tab, with your row and Sign out in it");
++ assert.ok(await until(`!!document.querySelector('.ovs.is-open .ovh-mebox .ovh-out')`), "the picture opened the menu's You tab, with your row and Sign out in it");
assert.strictEqual(await p.evaluate(`!!document.querySelector('.ovs .openvibe-navbar-dropdown')`), false, 'and the old account block is not in the menu');
console.log('navbar-ask-vibe: browser ok');
} finally {
ddiff --git a/test/ov-mark.test.js b/test/ov-mark.test.js
iindex 4a19eb3..1e98e4d 100644
---- a/test/ov-mark.test.js
++++ b/test/ov-mark.test.js
@@@ -352,10 +352,13 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
assert.match(css, /\.ov-mark\[data-ovm-intro\] \.gr\{animation:ovmGlint/, 'a glint ends the intro');
assert.match(css, /\.ov-mark:hover \.gr\{animation:ovmGlint|\.ov-mark:hover \.gr,/, 'and crosses on hover');
assert.match(css, /\.ov-mark\[data-glow\]::before\{opacity:\.42/, 'data-glow keeps the glow on');
-- // Working and thinking (4.186.0): the O as eight dots with three bright ones going round, dot to dot; three dots
-- // rising in turn inside the O while the V's strokes glimmer. One sign each, wherever something works or writes.
-- assert.match(css, /\.ov-mark\[data-state=busy\] \.ot\{opacity:\.3;stroke-dasharray:0 12\.5\}/, 'working: the O is eight dots');
-- assert.match(css, /\.ov-mark\[data-state=busy\] \.o\{stroke-dasharray:0 12\.5 0 12\.5 0 75;animation:ovmDots \.96s steps\(8,end\) infinite\}/, 'three bright ones hop round it');
++ // Working (4.236.0): the O stays the logo's ring at its own weight, a faint track with a bright arc going round
++ // that grows and draws in; thinking (4.186.0): three dots rising in turn inside the O while the V's strokes glimmer.
++ assert.match(css, /\.ov-mark\[data-state=busy\] \.ot\{opacity:\.24\}/, 'working: the whole ring is the track, at its own weight');
++ assert.match(css, /\.ov-mark\[data-state=busy\] \.o\{stroke-linecap:round;stroke-dasharray:6 94;animation:ovmArc 1\.25s [^}]*infinite\}/, 'a bright arc runs round it');
++ assert.match(css, /@keyframes ovmArc\{0%\{stroke-dasharray:6 94;stroke-dashoffset:0\}50%\{stroke-dasharray:44 56;stroke-dashoffset:-20\}100%\{stroke-dasharray:6 94;stroke-dashoffset:-100\}\}/, 'growing and drawing in, once round');
++ assert.ok(!/ovm-dot-w|ovmDots/.test(css), 'no thinner dots inside the ring\'s line');
++ assert.match(css, /\.ov-mark\[data-state=busy\] \.vg\{animation:ovmLift 1\.25s/, 'the V lifts in time, level with the O');
assert.ok(!/\[data-state=busy\] \.og\{animation:ovmBreathe/.test(css), 'the O no longer just breathes while it works');
assert.match(css, /\.ov-mark\[data-state=think\] \.td\{display:inline;animation:ovmThink 1\.2s ease-in-out infinite\}/, 'thinking: three dots inside the O');
assert.match(css, /\.ov-mark\[data-state=think\] \.vb\{animation:ovmSpeak 1\.2s ease-in-out \.6s infinite\}/, 'and the V glimmers, one stroke after the other');