openvibers/openvibe.shared Commits

4.185.0: You tab (your home first, shorter bar, Most used as shortcuts); a phone Dock opens out of the orb (#406)

OP OpenVibers committed 3 days ago · 807d75c978 · 11 files, +185 −33

* 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.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 1e6f96f..55e56f1 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,39 @@
# Changelog
++## 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
++above Jump back in… the thing at the top with your username and online and the settings… a little more compact… most
++used is cool, but maybe we should actually have links to whatever the user uses the most… so it's just easier to jump
++to the app."
++
++- **The order** is you, your own AI, **Your home**, Most used, Jump back in (it was you, AI, Jump back in, Most used,
++ Your home). Nothing above the grid comes or goes while Home is being arranged, as before.
++- **The bar with your name** is 52px tall (was 68): a 36px picture, name and handle on two tight lines. Display and
++ Settings keep their 44px.
++- **Most used** keeps its one bar; its key is now the apps themselves, five across: each app's icon in its own hue, its
++ name, and how often with the bar's colour beside the number. Each is a link (`.ovh-key`, "Live, opened 183 times") and
++ a full touch target; it was a row of small legend entries.
++- **Jump back in** comes last and says what it is: "where you were last".
++
++**A phone's Dock opens out of the navbar's menu button, and that button is its close button.** The owner, 2026-11-10:
++"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… it's supposed to be nicer connected to this." Until now the sheet rose from the bottom
++and carried its own close button up with it.
++
++- On a phone (640px and under) with the orb at the bar's right, the sheet fades and grows from the orb's centre
++ (`.ovdk.from-orb`); its close orb starts exactly on the navbar's orb, as its nine dots, and settles into its x in the
++ header (a glide of a few pixels and the same turn the main menu's orb makes). The navbar's own orb makes the same
++ move underneath (`.ovnav-orb.is-dock`), so it is one button the whole way. The x wears the menu's colours.
++- **Closing runs it back:** the x turns into the dots and returns to the bar while the sheet fades (240 ms), then the
++ sheet is gone and the button is where it always was. `OpenVibeDock.close()` therefore hides a phone's sheet a moment
++ later than it is called; with motion off, in a window of its own, or on a bar without the orb, it hides at once as
++ before (and such a bar keeps the rise from the bottom).
++- Calm keeps the move (the grow is smaller, by `--ov-move`); only motion off skips it.
++
++`test/menu-home-screen-chrome.test.js` holds both: the order, the bar's height, the shortcuts' links and targets; the
++close orb starting on the navbar's orb, settling, folding back, and standing still with motion off.
++
## 4.184.0 — 2026-11-10
- **Server-rendered navbar links.** The shared shell emits the configured navbar links in the reserved `#navbar-mount`
ddiff --git a/README.md b/README.md
iindex 202c82d..25d9eea 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.184.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.185.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 e46fe57..ab78ce1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,13 +4,14 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.184.0",
++ "version": "4.185.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 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)",
"the Dock's agent view, timeline and Actor ask Network's /api/frame relay instead of openvibe.codes and openvibe.actor, so a signed-in page logs no cross-origin console errors; an explicit base still reaches the product (4.181.0)",
ddiff --git a/dock.js b/dock.js
iindex 7731ce3..e2584ae 100644
---- a/dock.js
++++ b/dock.js
@@@ -193,6 +193,27 @@
.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))); } }
++ .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); }
++ .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); }
++ .from-orb .is-from .ovdk-ox circle { fill: var(--text-secondary, #a8b3c4); opacity: 1; transform: none; }
++ .from-orb .is-from .ovdk-ox .ovdk-o2 { opacity: .6; }
++ .ovnav-orb.is-dock { transform: translate(var(--dock-dx, 0), var(--dock-dy, 0)) scale(var(--dock-s, 1)) !important; color: var(--text-primary, #e6edf7) !important; }
++ .ovnav-orb.is-dock .ovo-g { transform: rotate(180deg) scale(.86); transition-duration: .45s; }
++ .ovnav-orb.is-dock .ovo-d { animation: none; }
++ .ovnav-orb.is-dock .ovo-r1 { opacity: 0; transform: scale(0); }
++ .ovnav-orb.is-dock :is(.ovo-r0, .ovo-r2) { fill: var(--ovo-a); opacity: 1; }
.ovdk-more { position: absolute; z-index: 6; right: 8px; top: calc(100% - 4px); width: min(320px, calc(100% - 16px)); display: grid; gap: 2px; padding: 6px; border-radius: 16px;
border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); box-shadow: 0 18px 40px -12px rgba(0,0,0,.55); cursor: default; }
.ovdk-more[hidden] { display: none; }
@@@ -944,6 +965,39 @@
// Ask your agent (4.135.0): Vibe opens with the question in its box, unsent.
function ask({ text = '' } = {}, n = 0) { if (!n) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); }
function isOpen() { return !!(el && !el.hidden); }
++ // A phone's sheet and the navbar's orb are one button (.from-orb).
++ let orbLink = null, closing = 0;
++ function navOrb() {
++ if (!phone()) return null;
++ const list = document.querySelectorAll('.openvibe-navbar .ovnav-orb');
++ for (let i = 0; i < list.length; i++) { const r = list[i].getBoundingClientRect(); if (r.width && r.left > root.innerWidth / 2 && r.top < 120 && r.bottom > 0) return list[i]; }
++ return null;
++ }
++ function fromOrb() {
++ unlinkOrb();
++ const nav = navOrb(), mine = el.querySelector('.ovdk-orbx'), mineSvg = mine && mine.querySelector('svg');
++ if (!nav || !mineSvg || reduced() || popped) return;
++ const a = (nav.querySelector('svg') || nav).getBoundingClientRect(), b = mineSvg.getBoundingClientRect(), box = el.getBoundingClientRect();
++ if (!a.width || !b.width) 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');
++ orbLink = { nav, mine };
++ void mine.offsetWidth; // the starting pose is put on, not animated into
++ mine.classList.remove('is-set');
++ void mine.offsetWidth;
++ nav.style.transition = 'transform .42s cubic-bezier(.2, 1.1, .3, 1), color .3s';
++ root.requestAnimationFrame(() => { if (!orbLink || orbLink.mine !== mine) return; mine.classList.remove('is-from'); nav.classList.add('is-dock'); });
++ }
++ function unlinkOrb() {
++ if (closing) { clearTimeout(closing); closing = 0; }
++ if (el) el.classList.remove('from-orb', 'is-out');
++ if (!orbLink) return;
++ orbLink.nav.classList.remove('is-dock'); orbLink.nav.style.transition = ''; orbLink.mine.classList.remove('is-from', 'is-set');
++ orbLink = null;
++ }
function share({ text = '', title = '' } = {}) {
const t = String(text || '').trim().slice(0, 4000);
if (!t) return;
@@@ -965,8 +1019,10 @@
paintPin();
paintPop();
el.hidden = false;
-- el.classList.remove('is-in'); void el.offsetWidth; el.classList.add('is-in');
-- // On a computer it grows out of its pill (the navbar's shared motion); a phone's sheet rises as before.
++ el.classList.remove('is-in');
++ fromOrb();
++ void el.offsetWidth; el.classList.add('is-in');
++ // On a computer it grows out of its pill; a phone's sheet opens out of the navbar's orb (fromOrb), or rises.
if (!phone() && root.OpenVibeNavbar && root.OpenVibeNavbar.pop) root.OpenVibeNavbar.pop(el, document.getElementById('ovdk-pill'));
if (phone()) {
el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open');
@@@ -979,6 +1035,18 @@
}
function close() {
if (!isOpen()) return;
++ // Fold back into the navbar's orb first, then go.
++ 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);
++ return;
++ }
++ shut();
++ }
++ function shut() {
++ if (!isOpen()) return;
++ unlinkOrb();
const wasPopped = popped;
if (wasPopped) { popped = false; const w = pipWin; pipWin = null; pipClosing = true; try { if (w) w.close(); } catch { /* already gone */ } pipClosing = false; if (el.parentNode !== document.body) document.body.appendChild(el); }
const inside = el.contains(document.activeElement);
ddiff --git a/navbar.js b/navbar.js
iindex 7a88051..6398031 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': 'd45d3e6e7c39', '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': '093d5979ae12', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };
++ 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' };
// 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-home.js b/ov-home.js
iindex 7ecd43e..ceb84ff 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -85,7 +85,7 @@
box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); transition: transform .18s cubic-bezier(.2,1.4,.4,1); }
.ovh-recent .ovh-ic { width: 48px; height: 48px; border-radius: 15px; }
/* App icons: the ring goes, the glyph fills the tile, each app keeps its own hue (11-09). */
--.ovh-grid .ovh-ic, .ovh-recent .ovh-ic, .ovh-card .ovh-ic { background: linear-gradient(150deg, hsl(var(--ovh-h, 215) 72% 58% / .34), hsl(var(--ovh-h, 215) 70% 38% / .18)); box-shadow: inset 0 0 0 1px hsl(var(--ovh-h, 215) 70% 64% / .28), 0 4px 14px hsl(var(--ovh-h, 215) 70% 30% / .18); overflow: hidden; }
++.ovh-grid .ovh-ic, .ovh-recent .ovh-ic, .ovh-card .ovh-ic, .ovh-key .ovh-ic { background: linear-gradient(150deg, hsl(var(--ovh-h, 215) 72% 58% / .34), hsl(var(--ovh-h, 215) 70% 38% / .18)); box-shadow: inset 0 0 0 1px hsl(var(--ovh-h, 215) 70% 64% / .28), 0 4px 14px hsl(var(--ovh-h, 215) 70% 30% / .18); overflow: hidden; }
.ovh-ic .ovi-ring, .ovh-ic .ovi-bg, .ovh-ic .ovi-comet, .ovh-ic .ovi-prog { display: none; }
.ovh-ic .ov-icon { --ovi-glyph: hsl(var(--ovh-h, 215) 90% 82%); color: hsl(var(--ovh-h, 215) 90% 82%); }
:root[data-theme="light"] .ovh-ic .ov-icon { --ovi-glyph: hsl(var(--ovh-h, 215) 70% 32%); color: hsl(var(--ovh-h, 215) 70% 32%); }
@@@ -184,18 +184,20 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
.ovh-lives a { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px; border-radius: 999px; background: color-mix(in srgb, var(--live, #ef4444) 12%, transparent); color: inherit; text-decoration: none; font-size: 13px; font-weight: 650; }
.ovh-lives a::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--live, #ef4444); }
--/* You (11-09, the owner: "the U tab still sucks"): you on top, with Display and Settings beside your name; what you were
-- doing as a rail of cards; what you use most as one bar; then your home. */
--.ovh-me { display: flex; align-items: center; gap: 10px; margin: 4px 4px 8px; padding: 10px 10px 10px 12px; border-radius: 20px;
++/* You (11-09, the owner: "the U tab still sucks"; 11-10: "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"): you on top in one short bar, with Display and
++ Settings beside your name; then your home; then what you use most, as shortcuts under one bar; then where you were
++ last, as a rail of cards. */
++.ovh-me { display: flex; align-items: center; gap: 6px; margin: 2px 4px 6px; padding: 4px 4px 4px 8px; border-radius: 16px;
background: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--accent, #3b82f6) 20%, transparent), transparent 60%), color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 18%, var(--border, rgba(255,255,255,.08))); }
--.ovh-who { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; min-height: 48px; border-radius: 14px; color: inherit; text-decoration: none; }
--.ovh-av { position: relative; flex: none; display: grid; place-items: center; width: 48px; height: 48px; border-radius: 50%; background: linear-gradient(135deg, var(--accent, #3b82f6), color-mix(in srgb, var(--accent, #3b82f6) 50%, #a855f7)); color: #fff; font-size: 19px; font-weight: 800; }
++.ovh-who { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; min-height: 44px; border-radius: 12px; color: inherit; text-decoration: none; }
++.ovh-av { position: relative; flex: none; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; background: linear-gradient(135deg, var(--accent, #3b82f6), color-mix(in srgb, var(--accent, #3b82f6) 50%, #a855f7)); color: #fff; font-size: 15px; font-weight: 800; }
.ovh-av img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
--.ovh-av::after { content: ""; position: absolute; right: -1px; bottom: -1px; width: 12px; height: 12px; border-radius: 50%; background: var(--success, #22c55e); box-shadow: 0 0 0 3px var(--bg-secondary, #141a24); }
--.ovh-who-txt { min-width: 0; display: grid; gap: 2px; }
--.ovh-who-txt b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 16.5px; font-weight: 800; letter-spacing: -.01em; }
--.ovh-who-txt small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }
++.ovh-av::after { content: ""; position: absolute; right: -1px; bottom: -1px; width: 10px; height: 10px; border-radius: 50%; background: var(--success, #22c55e); box-shadow: 0 0 0 2px var(--bg-secondary, #141a24); }
++.ovh-who-txt { min-width: 0; display: grid; gap: 0; line-height: 1.25; }
++.ovh-who-txt b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 800; letter-spacing: -.01em; }
++.ovh-who-txt small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--text-secondary, #a8b3c4); }
.ovh-ib { flex: none; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%; cursor: pointer; text-decoration: none;
background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); color: var(--text-primary, #e8ecf3); transition: background .16s, color .16s, transform .16s cubic-bezier(.2,1.4,.4,1); }
.ovh-ib svg { display: block; width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
@@@ -221,14 +223,19 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
.ovh-live { flex: none; padding: 2px 6px; border-radius: 6px; background: var(--live, #ef4444); color: #fff; font-size: 10px; font-weight: 800; letter-spacing: .05em; line-height: 1.2; }
@media (hover: hover) { .ovh-card:hover { background: var(--bg-hover, rgba(255,255,255,.08)); } }
@media (min-width: 861px) { .ovh-card { width: 232px; } }
--.ovh-use { margin: 0 4px 8px; padding: 12px 12px 6px; border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }
++.ovh-use { margin: 0 4px 8px; padding: 12px 12px 4px; border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }
.ovh-use-bar { display: flex; gap: 3px; height: 12px; border-radius: 999px; overflow: hidden; }
.ovh-use-bar > span { flex: var(--n, 1) 1 0; min-width: 8px; background: var(--c, #3b82f6); transform-origin: left center; animation: ovh-grow .7s cubic-bezier(.2,.8,.2,1) both; animation-delay: calc(var(--i, 0) * 60ms); }
--.ovh-use-keys { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 0 4px; margin: 6px -6px 0; }
--.ovh-key { display: flex; align-items: center; gap: 7px; min-width: 0; min-height: 38px; padding: 0 8px; border-radius: 10px; color: inherit; text-decoration: none; font-size: 13px; font-weight: 650; white-space: nowrap; }
--.ovh-key::before { content: ""; flex: none; width: 10px; height: 10px; border-radius: 3px; background: var(--c, #3b82f6); }
--.ovh-key small { color: var(--text-muted, #93a0b4); font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
--@media (hover: hover) { .ovh-key:hover { background: var(--bg-hover, rgba(255,255,255,.07)); } }
++/* The bar's key is the apps themselves: each one its icon (a way in, not a legend), its name, and how often, with the
++ bar's own colour beside the number. Five across, as the home grid is. */
++.ovh-use-apps { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 2px; margin: 8px -8px 0; }
++.ovh-key { display: grid; justify-items: center; align-content: start; gap: 3px; min-width: 0; padding: 6px 0; border-radius: 14px; color: inherit; text-decoration: none; -webkit-tap-highlight-color: transparent; }
++.ovh-key .ovh-ic { width: 44px; height: 44px; border-radius: 14px; transition: transform .16s cubic-bezier(.2,1.4,.4,1); }
++.ovh-key b { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11.5px; font-weight: 650; letter-spacing: -.01em; color: var(--text-secondary, #a8b3c4); }
++.ovh-key small { display: inline-flex; align-items: center; gap: 4px; color: var(--text-muted, #93a0b4); font-size: 11.5px; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.2; }
++.ovh-key small::before { content: ""; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--c, #3b82f6); }
++.ovh-key:active .ovh-ic { transform: scale(calc(1 - .08 * var(--ov-move, 1))); }
++@media (hover: hover) { .ovh-key:hover { background: var(--bg-hover, rgba(255,255,255,.07)); } .ovh-key:hover b { color: var(--text-primary, #e8ecf3); } }
.ovh-sec small { flex: none; font-size: 11px; font-weight: 600; letter-spacing: 0; text-transform: none; color: var(--text-muted, #93a0b4); }
@keyframes ovh-rise { from { opacity: 0; transform: translateY(6px) scale(.98); } }
@keyframes ovh-grow { from { transform: scaleX(0); } }
@@@ -419,6 +426,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
if (!items.length) return null;
const wrap = h('div');
const head = sec('Jump back in');
++ head.append(h('small', '', 'where you were last'));
wrap.append(head);
const rail = h('div', 'ovh-rail'); rail.setAttribute('role', 'list'); rail.setAttribute('aria-label', 'Jump back in');
items.forEach((x, i) => {
@@@ -441,7 +449,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
return wrap;
}
-- /** Most used: the pages you opened, by site, as one bar and its key. */
++ /** Most used: the pages you opened, by site, as one bar; its key is the apps themselves, each a way in. */
function usage() {
const rows = (o.usage || []).map((u) => ({ a: L.byId.get(u.id), n: u.count })).filter((r) => r.a && r.n > 0).sort((x, y) => y.n - x.n).slice(0, 5);
if (rows.length < 2) return null;
@@@ -454,13 +462,16 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
const box = h('div', 'ovh-use');
const bar = h('div', 'ovh-use-bar'); bar.setAttribute('role', 'img');
bar.setAttribute('aria-label', rows.map((r) => `${r.a.title} ${r.n}`).join(', '));
-- const keys = h('div', 'ovh-use-keys');
++ const keys = h('div', 'ovh-use-apps'); keys.setAttribute('role', 'list');
const CHART = ['#3b82f6', '#a855f7', '#f59e0b', '#14b8a6', '#ec4899']; // five that tell apart in dark and light
rows.forEach((r, i) => {
const c = CHART[i % CHART.length];
const seg = h('span'); seg.style.setProperty('--n', String(r.n)); seg.style.setProperty('--c', c); seg.style.setProperty('--i', String(i)); bar.append(seg);
-- const k = h('a', 'ovh-key', r.a.title); k.href = safe(r.a.url) || '#'; k.style.setProperty('--c', c);
-- k.append(h('small', '', num(r.n)));
++ const k = h('a', 'ovh-key'); k.href = safe(r.a.url) || '#'; k.style.setProperty('--c', c); k.setAttribute('role', 'listitem');
++ k.setAttribute('aria-label', `${r.a.title}, opened ${num(r.n)} ${r.n === 1 ? 'time' : 'times'}`);
++ const ic = h('span', 'ovh-ic'); ic.style.setProperty('--ovh-h', String(hueOf(r.a.site || r.a.id))); ic.append(siteIcon(r.a.site, 44));
++ k.append(ic, h('b', '', r.a.title), h('small', '', num(r.n)));
++ k.addEventListener('click', () => { if (o.onOpen) o.onOpen(r.a); });
keys.append(k);
});
box.append(bar, keys);
@@@ -477,7 +488,6 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
const calm = (x) => { x.classList.add('ovh-calm'); x.inert = state.edit; return x; };
if (signedIn(o)) el.append(calm(me()));
if (signedIn(o)) { actorBox = actorBox || h('div'); el.append(actorBox); }
-- { const b = backRail(); if (b) el.append(calm(b)); const u = usage(); if (u) el.append(calm(u)); }
const head = sec(L.arranged || signedIn(o) ? 'Your home' : 'Popular on OpenVibe');
const edit = h('button', state.edit ? 'ovh-edit is-done' : 'ovh-edit');
edit.type = 'button';
@@@ -497,6 +507,8 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
const all = h('button', 'ovh-all', `All apps (${total})`); all.type = 'button';
all.addEventListener('click', () => { state.view = 'drawer'; paint(); });
el.append(all);
++ // Under your home: what you use most (shortcuts), then where you were last (history).
++ { const u = usage(); if (u) el.append(calm(u)); const b = backRail(); if (b) el.append(calm(b)); }
icons();
if (signedIn(o) && actorBox && !actorBox.childNodes.length) actor(actorBox, o);
}
ddiff --git a/package-lock.json b/package-lock.json
iindex 9e2ae7c..d48aef6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.183.0",
++ "version": "4.185.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.184.0",
++ "version": "4.185.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 9dd2faa..38878e2 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.184.0",
++ "version": "4.185.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex 745bf13..12ee9d3 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -428,8 +428,9 @@ async function drag(p, from, to) {
await wait(300);
assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'the opening tap does not close the sheet');
await p.evaluate("document.body.click()");
-- await wait(150);
-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), false, 'a later tap outside closes it');
++ // A phone's sheet folds back into the navbar's orb before it goes (4.185.0: 240 ms), so it is gone a moment
++ // after the tap, not in the same instant.
++ await until(p, "!document.querySelector('.ovdk:not([hidden])')", 'a later tap outside closes it');
await share();
await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar again');
// Cancel: back to the list, nothing waiting.
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex 0163c5b..96e4c2b 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 = 19456; // 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 = 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 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/menu-home-screen-chrome.test.js b/test/menu-home-screen-chrome.test.js
iindex ab6be5e..3026e6b 100644
---- a/test/menu-home-screen-chrome.test.js
++++ b/test/menu-home-screen-chrome.test.js
@@@ -179,7 +179,7 @@ const server = http.createServer((req, res) => {
await openTab(s, 'you');
assert.ok(await until(s, `!!document.querySelector('.ovh-actor')`), 'the Actor strip is on top');
assert.match(await s.evaluate(`document.querySelector('.ovh-actor').textContent`), /Juniper.*Working.*Reading job posts/, 'it names the Actor, its state and its step');
-- // You on top: the name, Display (the Look panel) and Settings beside it; Jump back in as cards; Most used as one bar.
++ // You on top: the name, Display (the Look panel) and Settings beside it; then your home, Most used and Jump back in.
assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-me .ovh-who-txt b').textContent`), 'Ada', 'your name on top');
assert.ok(await s.evaluate(`!!document.querySelector('.ovh-me [data-ov-look-open]') && !!document.querySelector('.ovh-me a[href="https://openvibe.network/my"]')`), 'Display and Settings beside it');
assert.ok(await s.evaluate(`!document.querySelector('.ovs-displayrow')`), 'Display is not at the foot any more');
@@@ -187,6 +187,16 @@ const server = http.createServer((req, res) => {
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-rail .ovh-card')].map((a) => a.getAttribute('href'))`), ['https://openvibe.live/ada', 'https://openvibe.pics/1']);
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => k.textContent)`), ['Live5', 'Pics2'], 'Most used: pages opened per site');
assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec a.ovh-link[href="https://openvibe.report/me"]') || {}).textContent`), 'Your stats', 'Most used links to your stats on Report');
++ // The owner, 2026-11-10: "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". Your home comes first, then what you use most, then where you were last;
++ // the bar with your name is short; each most-used app is a way in (its icon, a full touch target), not a legend.
++ assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-sec > span')].map((x) => x.textContent)`), ['Your home', 'Most used', 'Jump back in'], 'your home, then most used, then where you were');
++ assert.ok(await s.evaluate(`document.querySelector('.ovh-me').getBoundingClientRect().height <= 56`), 'the bar with your name is short');
++ assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-me .ovh-ib')].every((b) => b.getBoundingClientRect().height >= 44)`), 'and its two buttons are still full touch targets');
++ assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => [k.getAttribute('href'), k.getAttribute('aria-label'), !!k.querySelector('.ovh-ic > *')].join('|'))`),
++ ['https://openvibe.live/|Live, opened 5 times|true', 'https://openvibe.pics/|Pics, opened 2 times|true'], 'each most-used app is a link with its icon and says how often');
++ assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].every((k) => { const r = k.getBoundingClientRect(); return r.height >= 44 && r.width >= 44; })`), 'each a full touch target');
++ assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-rail').parentElement.querySelector('.ovh-sec small') || {}).textContent`), 'where you were last', 'Jump back in says what it is');
assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start');
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons');
await s.evaluate(`document.querySelector('.ovh-sec button').click(), true`);
@@@ -214,6 +224,32 @@ const server = http.createServer((req, res) => {
assert.deepStrictEqual(sent.map((c) => c.u), ['attachments', 'attachments', 'posts'], 'each picture, then the post');
assert.ok(sent.every((c) => c.auth === 'Bearer tok-1'), 'with the person\'s token');
assert.deepStrictEqual(JSON.parse(sent[2].body).pictures.map((x) => x.alt), ['A dark sky', 'The same sky']);
++ // ── A phone's Dock opens out of the navbar's menu button, and that button is its close button (the owner,
++ // 2026-11-10: "that close button should… fade and have an animation effect from the top right, the menu button
++ // that already exists"). The close orb starts on the navbar's orb and settles into its x; the navbar's orb makes
++ // the same move underneath; closing runs it back, then the sheet is gone. With motion off there is no move.
++ await s.evaluate(`(document.querySelector('.ovs.is-open [data-close], .ovs-orb') || { click() {} }).click(), true`);
++ await new Promise((ok) => setTimeout(ok, 700));
++ assert.ok(await until(s, `!!(window.OpenVibeDock && window.OpenVibeDock.open)`), 'the Dock is loaded beside the navbar');
++ const start = await s.evaluate(`(() => { const c = (e) => { const r = e.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; };
++ const nav = [...document.querySelectorAll('.openvibe-navbar .ovnav-orb')].find((b) => b.getBoundingClientRect().width > 0);
++ const before = c(nav.querySelector('svg'));
++ window.OpenVibeDock.open({ tab: 'activity' });
++ const dock = document.querySelector('.ovdk'), mine = dock.querySelector('.ovdk-orbx');
++ return { before, at: c(mine.querySelector('svg')), fromOrb: dock.classList.contains('from-orb'), isFrom: mine.classList.contains('is-from') }; })()`);
++ assert.ok(start.fromOrb && start.isFrom, 'the sheet opens from the orb');
++ assert.ok(Math.abs(start.at[0] - start.before[0]) <= 1.5 && Math.abs(start.at[1] - start.before[1]) <= 1.5, `the close orb starts on the navbar's orb (${JSON.stringify(start)})`);
++ await new Promise((ok) => setTimeout(ok, 900));
++ const open = await s.evaluate(`(() => { const dock = document.querySelector('.ovdk'), mine = dock.querySelector('.ovdk-orbx'), sv = mine.querySelector('svg'), r = sv.getBoundingClientRect(), b = mine.getBoundingClientRect();
++ return { settled: getComputedStyle(sv).transform === 'none' && !mine.classList.contains('is-from'), centred: Math.abs((r.left + r.width / 2) - (b.left + b.width / 2)) <= 1, navIsDock: !!document.querySelector('.openvibe-navbar .ovnav-orb.is-dock'), opacity: getComputedStyle(dock).opacity }; })()`);
++ assert.ok(open.settled && open.centred, `it settles into its place in the header (${JSON.stringify(open)})`);
++ assert.ok(open.navIsDock && open.opacity === '1', 'the navbar\'s orb made the same move underneath');
++ const closing = await s.evaluate(`(() => { window.OpenVibeDock.close(); const dock = document.querySelector('.ovdk'); return { hidden: dock.hidden, out: dock.classList.contains('is-out'), back: dock.querySelector('.ovdk-orbx').classList.contains('is-from'), navIsDock: !!document.querySelector('.ovnav-orb.is-dock') }; })()`);
++ assert.ok(!closing.hidden && closing.out && closing.back && !closing.navIsDock, `closing folds it back into the orb first (${JSON.stringify(closing)})`);
++ assert.ok(await until(s, `document.querySelector('.ovdk').hidden`), 'then the sheet is gone');
++ assert.ok(await s.evaluate(`!document.querySelector('.ovdk').classList.contains('from-orb') && !document.querySelector('.ovnav-orb.is-dock') && [...document.querySelectorAll('.openvibe-navbar .ovnav-orb')].every((b) => !b.style.transition)`), 'and the navbar\'s orb is as it was');
++ const still = await s.evaluate(`(() => { document.documentElement.setAttribute('data-ov-motion', 'off'); window.OpenVibeDock.open({ tab: 'activity' }); const dock = document.querySelector('.ovdk'); const a = dock.classList.contains('from-orb'); window.OpenVibeDock.close(); const out = { fromOrb: a, hidden: dock.hidden }; document.documentElement.removeAttribute('data-ov-motion'); return out; })()`);
++ assert.ok(!still.fromOrb && still.hidden, 'motion off: no move, and it closes at once');
await s.close();
console.log('menu-home-screen-chrome: home grid, long-press and drag, keys, remove and put back, folders with tool icons, the wall, today, the composer with pictures, Network sync ok');
} finally {