openvibers/openvibe.shared Commits

4.186.0: the mark works as a ring of dots and can think; the menu and the Dock are one sheet; bare controls wear the theme (#407)

OP OpenVibers committed 3 days ago · 1d713939ca · 17 files, +122 −26

* You: your home first, a shorter bar, most used as shortcuts; the Dock opens out of the orb

The owner, 2026-11-10, on the You tab: "Your home should be above Jump back in... the thing at the top a little more
compact... links to whatever the user uses the most". And on the Dock: "that close button should, instead of coming
from that menu, fade and have an animation effect from the top right, the menu button that already exists".

You: the bar with your name is 52px (was 68); your home comes right under it and the AI card; Most used keeps its bar
and its key is now the apps themselves (icon, name, how often: each a link and a full touch target); Jump back in
comes last and says what it is ("where you were last").

Dock on a phone: the sheet fades and grows from the navbar orb; its close orb starts on that orb as the nine dots and
settles into its x in the header while the navbar orb makes the same move underneath; closing runs it back before
the sheet goes. With motion off there is no move. A bar without the orb keeps the old rise from the bottom.

* Merge remote-tracking branch 'origin/main' into you/home-first

* 4.185.0: the changelog, and the lockfile says one version

* The Dock hand-off in fewer bytes; its budget says why it grew

dock.js is 19.80 KB brotli with the hand-off (18.85 before). The styles are tightened and the comments shortened
(the full account is in the CHANGELOG); the budget is 20 KB with a dated note. It runs as the sheet opens, so it
cannot be a sibling loaded later.

* The Dock check waits out the fold-back

On a phone the sheet folds back into the navbar orb before it goes, so "a later tap outside closes it" is true a
moment after the tap. The check polls for it instead of reading it after 150 ms.

* The mark: working is the O as dots going round; thinking is new

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 is loading... there should be one dedicated for thinking when your agent is
thinking or replying."

busy: the O breaks into eight dots (a dash of no length with a round cap) and three bright ones hop round it, dot
to dot, while the V bobs. think: three dots rise in turn inside the O, the ring quiets, and the V two strokes
glimmer one after the other.

* The Dock's sheet opens and closes as the menu's does and wears its close button; a ring leaves the orb

* Merge remote-tracking branch 'origin/main' into mark-working-dots

# Conflicts:
#	dock.js
#	navbar.js
#	test/dock-size.test.js

* 4.186.0: the changelog and release files

* The sheet swipe check waits for the sheet to be adopted

It slept 40 ms after opening the sheet and then swiped; on a slow macOS runner the surfaces layer had not locked the
page yet, so the swipe was ignored (two reds on 2026-11-10). It now waits until the sheet is open and the page is
locked.

* A Chrome that does not come up is started once more; the Dock check waits longer on its pills

macOS CI, 2026-11-10, three runs: "Chrome did not start" within the 45 s wait, and "timed out: the pinned pill" and
"the desktop pill" at the 5 s limit, on a loaded runner. The harness now starts Chrome a second time before giving
up, and the Dock check polls up to 15 s (a limit on a poll, not a sleep: a passing run is no slower).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 55e56f1..21b5ed5 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,38 @@
# Changelog
++## 4.186.0 — 2026-11-10
++
++**The mark at work is a ring of dots, and thinking is a state of its own.** The owner, 2026-11-10: "the progress
++animation… should make the O turn into a bunch of little circles, like how a normal loading spinner looks… a cooler
++animation that actually looks like it's loading or doing something. Also, there should be one dedicated for thinking
++when your agent is thinking or replying… easier to know that it's replying or outputting tokens."
++
++- **`data-state="busy"` (working):** the O breaks into eight dots (a dash of no length with a round cap) and three
++ bright ones go round the ring, dot to dot, while the V bobs. It was the O breathing. This replaces the old line
++ "nothing spins": a wait is the mark's own O going round, never a borrowed spinner.
++- **`data-state="think"` (new):** three dots rise in turn inside the O, the ring quiets, and the V's two strokes glimmer
++ one after the other. One sign, wherever an agent is thinking or tokens are on their way.
++- Both are shown at every size and stay readable with motion off (the dots are simply there).
++
++**The menu and the Dock are one kind of sheet on a phone.** The owner: "make the two UIs look match a little better,
++especially the close button, so it feels more seamless… make the animation effect for the menu opening and closing more
++similar to how the other ones are… and make it cooler."
++
++- **One close button:** the Dock's is the menu's now: the same round, ringed button, the same × of dots in the same
++ colours, at the same place on the screen (measured: both centred 36px from the right edge, 34px from the top).
++- **One move:** the Dock's sheet opens as a circle growing from the orb and folds back into it, with the menu's own
++ timing (380 ms in, 280 ms out); it faded and scaled in 4.185.0.
++- **A ring leaves the orb** as either sheet opens (smaller under Calm, by `--ov-move`).
++- The close orb is measured after the sheet's layout is set, so it starts exactly on the navbar's button.
++
++**A control nobody styled wears the theme.** The owner: "customize all the inputs and buttons and things that aren't
++styled." A `<button>`, a text field, a `<select>` or a `<textarea>` with **no class of its own** gets the theme's face
++from the loader: its colour, border, corner and font, and the accent on a tick box or a slider. Only the face, and at
++no specificity (`:where`), so any rule a site wrote still wins and no layout moves; a control's real shape is the
++kit's (`ovk-btn`, `ovk-input`). The loader grew about 1 KB raw (budget 67,600 characters).
++
++The Dock is 20.00 KB brotli (budget 20.25 KB, with the reason beside it).
++
## 4.185.0 — 2026-11-10
**The You tab: your home first, a shorter bar, Most used as shortcuts.** The owner, 2026-11-10: "Your home should be
ddiff --git a/README.md b/README.md
iindex 25d9eea..79ed98f 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.185.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.186.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 ab78ce1..e5af3d2 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,13 +4,14 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.185.0",
++ "version": "4.186.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.384.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.133.0",
"features": [
++ "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)",
"motion has four choices (Automatic, Full, Calm, Off) and one signal, html[data-ov-motion]: Calm keeps every effect and makes it smaller and slower (--ov-move, display.move(), endless loops at 0.6 of their speed), and only Off stops motion; no rule in the package stops an effect because the device asked for less (4.183.0)",
"every shape glyph has a solid drawing (203 of 278; the other 75 are line glyphs that thicken when small), glyphs.svg() draws a solid's line parts as lines, and every product id has its own site mark, so no product draws the bare V (4.183.0)",
ddiff --git a/browser-harness.js b/browser-harness.js
iindex 4c1a962..06e2867 100644
---- a/browser-harness.js
++++ b/browser-harness.js
@@@ -146,7 +146,7 @@ function connect(wsUrl, onClose) {
* { version, userAgent, send, newContext(), close() }. close() kills only the process it started and resolves once
* its temporary profile is removed.
*/
--async function launch({ bin, browserURL, tmpDir, args = [], sandbox = false, headless = true } = {}) {
++async function launch({ bin, browserURL, tmpDir, args = [], sandbox = false, headless = true, _again = false } = {}) {
let proc = null, profile = null, wsUrl;
if (browserURL) {
const v = await (await fetch(`${browserURL.replace(/\/$/, '')}/json/version`)).json();
@@@ -175,7 +175,13 @@ async function launch({ bin, browserURL, tmpDir, args = [], sandbox = false, hea
} catch { /* not yet */ }
if (!wsUrl) await sleep(100);
}
-- if (!wsUrl) { proc.kill('SIGKILL'); fs.rmSync(profile, { recursive: true, force: true }); throw new Error('Chrome did not start'); }
++ if (!wsUrl) {
++ proc.kill('SIGKILL'); fs.rmSync(profile, { recursive: true, force: true });
++ // Once more before giving up: on a loaded runner the first Chrome of a batch can take longer than the
++ // wait, and the second, with the binary warm, comes up in a second or two (macOS CI, 2026-11-10).
++ if (!_again) return launch({ bin, browserURL, tmpDir, args, sandbox, headless, _again: true });
++ throw new Error('Chrome did not start');
++ }
}
const conn = connect(wsUrl);
await conn.opened;
ddiff --git a/dock.js b/dock.js
iindex e2584ae..9fe84f9 100644
---- a/dock.js
++++ b/dock.js
@@@ -193,17 +193,24 @@
.ovdk-orbx:active .ovdk-ox { transform: rotate(270deg) scale(.7); }
@keyframes ovdk-ox-in { from { transform: rotate(45deg) scale(.8); } }
@keyframes ovdk-o1-out { from { opacity: 1; transform: scale(1); } }
-- /* A phone's sheet opens out of the navbar's orb, which becomes its close button (4.185.0; see CHANGELOG). */
-- .ovdk.from-orb { transform-origin: var(--ovdk-ox, 100%) var(--ovdk-oy, 0%) !important; }
-- .ovdk.from-orb.is-in { animation: ovdk-from-orb .3s cubic-bezier(.2,.9,.25,1) both; }
-- .ovdk.from-orb.is-out { animation: ovdk-to-orb .24s ease both; pointer-events: none; }
-- @keyframes ovdk-from-orb { from { opacity: 0; transform: scale(calc(1 - .06 * var(--ov-move, 1))); } }
-- @keyframes ovdk-to-orb { to { opacity: 0; transform: scale(calc(1 - .04 * var(--ov-move, 1))); } }
++ /* A phone's sheet opens out of the navbar's orb as the menu's does (4.186.0; see CHANGELOG): one move, one close button. */
++ .ovdk.from-orb.is-in { animation: ovdk-reveal .38s cubic-bezier(.2,.9,.25,1) both; }
++ .ovdk.from-orb.is-out { animation: ovdk-fold .28s cubic-bezier(.5,0,.75,.2) both; pointer-events: none; }
++ @keyframes ovdk-reveal { from { clip-path: circle(28px at var(--ovdk-ox) var(--ovdk-oy)); } to { clip-path: circle(150% at var(--ovdk-ox) var(--ovdk-oy)); } }
++ @keyframes ovdk-fold { from { clip-path: circle(150% at var(--ovdk-ox) var(--ovdk-oy)); } to { clip-path: circle(20px at var(--ovdk-ox) var(--ovdk-oy)); opacity: 0; } }
++ .ovdk.from-orb .ovdk-head { padding: 4px 14px 6px 8px; }
++ .from-orb .ovdk-acts .ovdk-orbx { position: relative; width: 40px; height: 40px; margin: 2px; border-radius: 50%; background: color-mix(in srgb, var(--accent, #3b82f6) 16%, var(--bg-primary, #0b0f19));
++ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-light, #60a5fa) 35%, transparent), 0 6px 18px -6px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); transition: background-color .3s, box-shadow .3s; }
++ .from-orb .ovdk-acts .ovdk-orbx.is-from { background: transparent; box-shadow: none; }
++ .from-orb.is-in .ovdk-orbx::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--accent-light, #60a5fa); pointer-events: none; animation: ovdk-ring .6s cubic-bezier(.2,.8,.2,1) .1s both; }
++ @keyframes ovdk-ring { from { opacity: .8; transform: scale(.9); } to { opacity: 0; transform: scale(calc(1 + 2.2 * var(--ov-move, 1))); } }
.from-orb.is-in .ovdk-ox, .from-orb.is-in .ovdk-o1 { animation: none !important; }
.from-orb .ovdk-orbx { color: var(--text-primary, #ececf1); }
.from-orb .ovdk-orbx svg { transition: transform .42s cubic-bezier(.2, 1.1, .3, 1); }
.from-orb .ovdk-ox circle { transition: fill .3s, opacity .3s, transform .35s cubic-bezier(.2, 1.4, .3, 1); }
-- .from-orb .ovdk-ox :is(.ovdk-o0, .ovdk-o2), .from-orb .is-from .ovdk-ox .ovdk-o0 { fill: var(--accent-light, #60a5fa); }
++ .from-orb .ovdk-ox { transform: rotate(180deg) scale(.95); }
++ .from-orb .ovdk-ox :is(.ovdk-o0, .ovdk-o2) { fill: var(--accent-light, #60a5fa); transform: scale(1.12); }
++ .from-orb .is-from .ovdk-ox .ovdk-o0 { fill: var(--accent-light, #60a5fa); }
.ovdk-orbx.is-set, .ovdk-orbx.is-set * { transition: none !important; }
.from-orb .is-from svg { transform: translate(var(--orb-dx, 0), var(--orb-dy, 0)) scale(var(--orb-s, 1)); }
.from-orb .is-from .ovdk-ox { transform: rotate(45deg) scale(.8); }
@@@ -977,13 +984,14 @@
unlinkOrb();
const nav = navOrb(), mine = el.querySelector('.ovdk-orbx'), mineSvg = mine && mine.querySelector('svg');
if (!nav || !mineSvg || reduced() || popped) return;
++ el.classList.add('from-orb'); // first: it sets the header's layout, and the close orb is measured where it will rest
const a = (nav.querySelector('svg') || nav).getBoundingClientRect(), b = mineSvg.getBoundingClientRect(), box = el.getBoundingClientRect();
-- if (!a.width || !b.width) return;
++ if (!a.width || !b.width) { el.classList.remove('from-orb'); return; }
const ax = a.left + a.width / 2, ay = a.top + a.height / 2, bx = b.left + b.width / 2, by = b.top + b.height / 2, s = a.width / b.width;
el.style.setProperty('--ovdk-ox', `${Math.round(ax - box.left)}px`); el.style.setProperty('--ovdk-oy', `${Math.round(ay - box.top)}px`);
mine.style.setProperty('--orb-dx', `${(ax - bx).toFixed(1)}px`); mine.style.setProperty('--orb-dy', `${(ay - by).toFixed(1)}px`); mine.style.setProperty('--orb-s', s.toFixed(3));
nav.style.setProperty('--dock-dx', `${(bx - ax).toFixed(1)}px`); nav.style.setProperty('--dock-dy', `${(by - ay).toFixed(1)}px`); nav.style.setProperty('--dock-s', (1 / s).toFixed(3));
-- el.classList.add('from-orb'); mine.classList.add('is-set', 'is-from');
++ mine.classList.add('is-set', 'is-from');
orbLink = { nav, mine };
void mine.offsetWidth; // the starting pose is put on, not animated into
mine.classList.remove('is-set');
@@@ -1039,7 +1047,7 @@
if (orbLink && !closing && !popped && !reduced() && el.classList.contains('from-orb')) {
el.classList.remove('is-in'); el.classList.add('is-out');
orbLink.mine.classList.add('is-from'); orbLink.nav.classList.remove('is-dock');
-- closing = setTimeout(() => { closing = 0; shut(); }, 240);
++ closing = setTimeout(() => { closing = 0; shut(); }, 280);
return;
}
shut();
ddiff --git a/navbar.js b/navbar.js
iindex 6398031..5525ade 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -567,7 +567,7 @@
const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': '8cc78bde2190', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'b170f6e994fa', 'dock.js': '4756db83581e', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'dc24929109b5', 'history.js': '1e6598f78ba4', 'site-switcher.js': 'd8980cf95157', 'appbar.js': '4e34b7d60372', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '6252acb43bd4', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };
++ const SIBLING_REV = { 'ov-mark.js': '4fb7af77a431', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'c6dbd7d46ae7', 'dock.js': '2c59b0f0e035', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'dc24929109b5', 'history.js': '1e6598f78ba4', 'site-switcher.js': 'd8980cf95157', 'appbar.js': '4e34b7d60372', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '6252acb43bd4', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
ddiff --git a/ov-mark.js b/ov-mark.js
iindex 74d198b..95ea2c5 100644
---- a/ov-mark.js
++++ b/ov-mark.js
@@@ -26,7 +26,11 @@
* - 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: the V bobs and the O breathes, in turn; ok: the V becomes a tick, green, and hops (the tick only ever
++ * - 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
++ * 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
* shakes its head; notify: a dot pops up by the V and the O bounces; live: the O is the record light, red, and
* ripples go out from it; sleep: the O closes like an eye and the V sinks.
@@@ -134,7 +138,19 @@ html[data-ov-season-mark] .ov-mark[data-ovm-intro] .sl{animation:ovmLeaf .9s cub
.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}
--.ov-mark[data-state=busy] .og{animation:ovmBreathe .9s ease-in-out .45s 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}
++.ov-mark[data-state=busy] .og{animation:ovmSettle .4s cubic-bezier(.2,1.3,.3,1) 1}
++/* 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}
++.ov-mark[data-state=think] .td2{animation-delay:.16s}
++.ov-mark[data-state=think] .td3{animation-delay:.32s}
++.ov-mark[data-state=think] .o{stroke-opacity:.5;transition:stroke-opacity .3s}
++.ov-mark[data-state=think] .va{animation:ovmSpeak 1.2s ease-in-out infinite}
++.ov-mark[data-state=think] .vb{animation:ovmSpeak 1.2s ease-in-out .6s infinite}
.ov-mark[data-state=ok]{--ovm-o:var(--success,#22c55e);--ovm-v:var(--success,#22c55e)}
.ov-mark[data-state=ok] .vg{animation:ovmHop .56s ease-out .14s 1 both}
.ov-mark[data-state=error]{--ovm-o:var(--danger,#ef4444);--ovm-v:var(--danger,#ef4444)}
@@@ -171,6 +187,10 @@ 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 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}}
@keyframes ovmPulse{0%,100%{transform:scale(1)}50%{transform:scale(1.06)}}
@keyframes ovmShake{0%,100%{transform:translateX(0)}15%{transform:translateX(-7px)}30%{transform:translateX(7px)}45%{transform:translateX(-6px)}60%{transform:translateX(6px)}80%{transform:translateX(-3px)}}
@keyframes ovmNo{0%,100%{transform:translateX(0)}20%{transform:translateX(-4px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)}}
@@@ -208,7 +228,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}"><path class="ot" d="${G.ring}" 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)}"/>
++<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 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/package-lock.json b/package-lock.json
iindex d48aef6..5e1f587 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.185.0",
++ "version": "4.186.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.185.0",
++ "version": "4.186.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 38878e2..25c7397 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.185.0",
++ "version": "4.186.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 8149b13..73a421b 100644
---- a/search.js
++++ b/search.js
@@@ -374,6 +374,9 @@
transform: none; transition: transform .42s cubic-bezier(.2, 1.1, .3, 1), background-color .3s, box-shadow .3s; }
.ovs.ovs-anchored .ovs-orb.is-from { transform: translate(var(--orb-dx, 0px), var(--orb-dy, 0px)) scale(.9); background: transparent; box-shadow: none; }
.ovs.ovs-anchored .ovs-orb::after { content: ""; position: absolute; inset: -4px; }
++ /* A ring leaves the orb as its sheet opens (4.186.0; the Dock's sheet does the same). */
++ .ovs.ovs-anchored .ovs-orb.is-x::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--ovo-a); pointer-events: none; animation: ovs-ring .6s cubic-bezier(.2,.8,.2,1) .1s both; }
++ @keyframes ovs-ring { from { opacity: .8; transform: scale(.9); } to { opacity: 0; transform: scale(calc(1 + 2.2 * var(--ov-move, 1))); } }
.ovs.ovs-anchored .ovs-orb:active { transform: scale(.9); }
.ovs.ovs-anchored .ovs-orb svg { display: block; overflow: visible; }
.ovs.is-closing .ovs-panel { animation: ovs-fold .28s cubic-bezier(.5, 0, .75, .2) both; }
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex 12ee9d3..a755f16 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -217,7 +217,7 @@ const server = http.createServer((req, res) => {
});
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
--async function until(p, expr, what, ms = 5000) {
++async function until(p, expr, what, ms = 15000) { // a limit on a poll: a loaded runner drew the pill after 5 s
const end = Date.now() + ms;
for (;;) {
const v = await p.evaluate(expr);
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex 96e4c2b..f4f1f11 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 = 20480; // 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 = 20736; // 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-runs.js', 'ai-marks-client.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js'];
// ── the budget ──────────────────────────────────────────────
ddiff --git a/test/motion-setting.test.js b/test/motion-setting.test.js
iindex b279c40..9e2f331 100644
---- a/test/motion-setting.test.js
++++ b/test/motion-setting.test.js
@@@ -74,6 +74,12 @@ assert.ok(/html\[data-ov-motion=off\] \*:not\(\[data-fx-demo\] \*\)[^{]*\{animat
assert.ok(/CALM_RATE = 0\.6/.test(SRC) && SRC.includes('t.iterations !== Infinity') && SRC.includes("addEventListener('animationstart'"), 'under Calm an endless loop is slowed as it starts; anything that ends plays as written');
assert.ok(SRC.includes("motionOf() === 'off') return;"), 'the theme crossfade is a colour fade: only Off skips it');
++// ── a control nobody styled wears the theme (4.186.0) ──
++assert.ok(/:where\(button,input:is\(\[type=submit\],\[type=button\],\[type=reset\]\)\):where\(:not\(\[class\]\)\)\{font-family:inherit;border:1px solid var\(--border-light,[^;]*;border-radius:10px;color:var\(--text-primary,inherit\);background:var\(--bg-hover,/.test(css), 'a bare button: the theme\'s face, at no specificity, readable even where no theme is set');
++assert.ok(/:where\(input:not\(\[type=checkbox\][^)]*\),select,textarea\):where\(:not\(\[class\]\)\)\{font-family:inherit;border:1px solid/.test(css), 'a bare field, select or text box too');
++const bare = css.match(/:where\((?:button|input)[^{]*\{[^}]*\}/g) || [];
++assert.ok(bare.length >= 3 && bare.every((r) => /^:where\([^{]*:where\(:not\(\[class\]\)\)\{/.test(r) && !/padding|height|width|margin|display/.test(r)), 'only controls with no class, and nothing that moves a layout');
++
// ── the package: nothing stops an effect because the device asked for less ──
const STOPS = /(animation|transition)\s*:\s*none/;
const offenders = [];
ddiff --git a/test/ov-mark.test.js b/test/ov-mark.test.js
iindex 2b7bf47..4a19eb3 100644
---- a/test/ov-mark.test.js
++++ b/test/ov-mark.test.js
@@@ -352,6 +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');
++ 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');
assert.match(css, /\.ov-mark\[data-tone\]::before,\.ov-mark\[data-state=sleep\]::before\{display:none\}/);
assert.ok(!css.includes('::before{transition:none!important}') && css.includes('html[data-ov-motion=off] .ov-mark:not([data-fx-demo] *) *{animation-iteration-count:1!important}'), 'the glow fades in under Calm too; only motion off holds the mark still (outside a demo scope)');
assert.match(css, /\.ov-mark:active \.m\{transform:scale\(\.9\)/, 'a press gives a little');
ddiff --git a/test/surfaces-chrome.test.js b/test/surfaces-chrome.test.js
iindex fdb77e1..bc4aa6a 100644
---- a/test/surfaces-chrome.test.js
++++ b/test/surfaces-chrome.test.js
@@@ -58,7 +58,12 @@ const server = http.createServer((req, res) => {
assert.strictEqual(await p.evaluate('Math.round(scrollY)'), await p.evaluate('Math.round(window.__before)'), 'close restores page position');
assert.strictEqual(await p.evaluate('document.activeElement.id'), 'open', 'focus returns to opener');
await p.evaluate(`(() => { const btn=document.querySelector('#open');btn.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true}));btn.click(); })()`);
-- await new Promise((ok) => setTimeout(ok, 40));
++ // The sheet takes the swipe only once the surfaces layer has adopted it (the page is locked): wait for that, not
++ // for a fixed 40 ms (a slow macOS runner had not got there yet, twice on 2026-11-10).
++ let adopted = false;
++ for (let i = 0; i < 60 && !adopted; i++) { adopted = await p.evaluate('document.querySelector("#sheet").open && document.body.style.position === "fixed"'); if (!adopted) await new Promise((ok) => setTimeout(ok, 50)); }
++ assert.strictEqual(adopted, true, 'the sheet opens again and locks the page');
++ await new Promise((ok) => setTimeout(ok, 60));
await p.evaluate(`(() => { const head=document.querySelector('[data-ov-sheet-head]');
const fire=(type,y)=>head.dispatchEvent(new PointerEvent(type,{bubbles:true,pointerId:5,pointerType:'touch',clientY:y}));
fire('pointerdown',200);fire('pointermove',330);fire('pointerup',330); })()`);
ddiff --git a/test/theme-parity.test.js b/test/theme-parity.test.js
iindex bcb271d..cbe3f29 100644
---- a/test/theme-parity.test.js
++++ b/test/theme-parity.test.js
@@@ -51,7 +51,8 @@ assert.ok(LT.arctic['--success'], 'loader entries carry the full token set');
// 62,000 since 4.64.0: the look travels between sites (?ov_look=) and every copy is dated; brotli 15.45 KB (was 15.41) (2026-10-31).
// 63,500 since 4.111.1 (2026-11-05): the FAVICON block draws the Pair (about 1.2 KB raw) instead of the retired ring.
// 66,500 since 4.183.0 (2026-11-10): motion has four choices, --ov-move, and Calm paces endless loops (about 2.9 KB raw).
--assert.ok(loader.length < 66500, `theme-loader.js stays small for first paint (${loader.length} characters)`);
++// 67,600 since 4.186.0 (2026-11-10): a control nobody styled wears the theme (three rules, about 0.9 KB raw with their note).
++assert.ok(loader.length < 67600, `theme-loader.js stays small for first paint (${loader.length} characters)`);
assert.ok(loader.includes('KNOWN_PROPS'), 'loader clears tokens the new theme does not set');
// 5. Derived tokens: text on the accent always reads, on every theme.
ddiff --git a/theme-loader.js b/theme-loader.js
iindex 972a476..c19d16d 100644
---- a/theme-loader.js
++++ b/theme-loader.js
@@@ -357,6 +357,12 @@
// 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).
++ // A control nobody styled wears the theme, not the browser's grey (4.186.0): only one with no class, only its face,
++ // at no specificity, so any rule a site wrote still wins and no layout moves. Its shape is the kit's job.
++ const BARE = ':where(:not([class]))', FACE = '{font-family:inherit;border:1px solid var(--border-light,var(--border,color-mix(in srgb,currentColor 30%,transparent)));border-radius:10px;color:var(--text-primary,inherit);background:';
++ const BARE_CSS = ':where(button,input:is([type=submit],[type=button],[type=reset]))' + BARE + FACE + 'var(--bg-hover,var(--bg-card,color-mix(in srgb,currentColor 8%,transparent)))}'
++ + ':where(input:not([type=checkbox],[type=radio],[type=range],[type=color],[type=file],[type=image],[type=submit],[type=button],[type=reset],[type=hidden]),select,textarea)' + BARE + FACE + 'var(--bg-input,var(--bg-primary,transparent))}'
++ + ':where(input:is([type=checkbox],[type=radio],[type=range]),progress)' + BARE + '{accent-color:var(--accent,auto)}';
const ALIAS_CSS = ':where(html){--error:var(--danger);--error-text:var(--danger-text);--ok:var(--success-text);--warn:var(--warning-text);--accent-text:var(--accent-strong)}';
// Comfort (4.45.0): one <html> attribute each, !important over the inline theme (Okabe-Ito colours, OLED black).
const COMFORT_CSS = 'html[data-ov-contrast=more]{--text-secondary:color-mix(in srgb,var(--text-primary) 88%,var(--bg-primary))!important;--text-muted:color-mix(in srgb,var(--text-primary) 76%,var(--bg-primary))!important;--border:color-mix(in srgb,var(--text-primary) 40%,var(--bg-primary))!important;--border-light:color-mix(in srgb,var(--text-primary) 55%,var(--bg-primary))!important}'
@@@ -411,7 +417,7 @@
for (const k in COMFORT_ATTRS) { const [attr, off] = COMFORT_ATTRS[k]; if (d[k] && d[k] !== off) el.setAttribute(attr, d[k]); else el.removeAttribute(attr); }
if (!document.getElementById('ov-display-css') && document.head) {
const st = document.createElement('style'); st.id = 'ov-display-css';
-- st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}' + MOTION_CSS + ALIAS_CSS + COMFORT_CSS;
++ st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}' + MOTION_CSS + BARE_CSS + ALIAS_CSS + COMFORT_CSS;
document.head.appendChild(st);
}
paceLoops();