openvibers/openvibe.shared Commits

4.161.0: menus open from where you tapped; Calm is gentle; tabs, app icons, account menu, Dock close, strips (#370)

OP OpenVibers committed 4 days ago · 1b30f3c055 · 20 files, +406 −65

* The menu's tabs show what they are; sideways strips say they scroll

- Tabs: this site's own mark beside its name, Feed with a red dot while anyone is live or a count of what changed since
  you last looked, You with your picture and an online dot (the owner, 11-09: "these icons should be visually improved…
  your avatar… the feed content aware… each site its own icon").
- Every kit rail and [data-ovk-scroll]: edges fade where more is hidden, a thin position bar under it, one nudge on a
  phone when it first comes into view, and data-ovk-scroll="drift" drifts while in view until the first touch (the
  owner: "it's not very apparent you can scroll horizontally… a bounce, or auto scroll until you tap").

* Account menu: Wallet and Profile tiles, a tighter header and tiles

* Menus open from where you tapped; app icons; Display settings in one place; the Dock's orb close

- OpenVibeNavbar.pop(el, trigger) / .unpop(el, done): panels grow out of their trigger with a clip reveal and close
  quickly; the account menu, the Display panel, the composer's Options and Attach, and the Dock (from its pill on a
  computer) use it. Reduced motion fades.
- The account menu: Wallet and Profile tiles; its footer keeps light, dark and auto, and one Display button opens the
  Look panel, which now holds text size and motion with the theme and icons. The menu's Display row matches it.
- The Dock: Close is the menu orb in its x form, folding in as the Dock opens; More's dots sit on the centre line.
- You: app icons (no ring, the glyph fills a squircle tinted with each app's own hue), Quick tools named apart from the
  Tools site with each tool's own icon, a grid that fills a computer's width, editing that never scrolls the page and
  scrolls the list near its edges, the AI invite redesigned (whole sentence, bigger mark, two buttons).
- Jump back in: each site's own icon, titles without the site name glued on, Live now for a channel that is live.
- The menu remembers its tab per site: a new site opens on its own tab.

* Calm is gentle, not still: the mark still shows a state (loops play once); menus pop small and short

The owner, 11-09: "all the animations need to work even on calm mode". Under the Motion setting Calm, or a device
asking for less motion, the mark skips its intro, hover play and falling leaves but still animates a state change
(working, done, failed, live…), its loops playing once; the shared pop is a short, small scale-and-fade.

* 4.161.0: menus open from where you tapped; Calm is gentle; tabs, app icons, the account menu, the Dock's orb close, strips that say they scroll

* 4.161.0: apps slide when you drag; Display and theme is one row; the account menu reopens mid-close

- Dragging on You: the other apps slide to their new places (FLIP), a move needs the finger well inside the target, the
  lifted app is the whole tinted icon, and Calm keeps a small wiggle.
- The menu's Display is one row (Display and theme, with what is set) opening the Look panel, which now has Light,
  Dark and Auto first (the owner: "these settings at the bottom… should be in a menu").
- A press while the account menu is closing opens it again.
- The strips' fades and position bar are in room.css (kit.css stays the controls); ov-look.js stays ASCII.

* Rails test: a slow runner gets time to see the strip drift

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 101c801..d1682b9 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,25 @@
# Changelog
++## 4.161.0 — 2026-11-09
++
++- **Menus open from where you tapped** (the owner, 11-09: "custom animations… content aware… where you're clicking"):
++ `OpenVibeNavbar.pop(el, trigger)` / `.unpop(el, done)`, a clip reveal with a small spring from the trigger and a quick
++ close; the account menu, the Display panel, the composer's Options and Attach, and the Dock (from its pill on a
++ computer) use it.
++- **Calm is gentle, not still** (the owner: "the animations need to work even on calm mode"): the mark still animates a
++ state (its loops play once) and menus pop small and short; only intros, hover plays and the season's leaves rest.
++- **The menu's tabs show what they are**: this site's own mark, Feed with a live dot or a count of what changed since
++ you last looked, You with your picture and an online dot; the tab is remembered per site.
++- **You**: app icons (no ring; the glyph fills a squircle tinted with each app's own hue), Quick tools named apart from
++ the Tools site with each tool's own icon, a grid that fills a computer's width, editing that never scrolls the page
++ and scrolls the list near its edges, the AI invite redesigned; Jump back in with each site's icon, cleaner titles and
++ Live now.
++- **The account menu**: Wallet and Profile tiles; the footer keeps light, dark and auto with one Display button; the
++ Look panel holds text size and motion with the theme and icons; the menu's Display row matches.
++- **The Dock**: Close is the menu orb in its x form; More's dots sit on the centre line.
++- **Sideways strips say they scroll**: kit rails and `[data-ovk-scroll]` fade at the edges where more is hidden, show a
++ position bar, nudge once on a phone, and `drift` moves until the first touch.
++
## 4.160.0 — 2026-11-09
- **Feed v2: the menu's Feed is a real feed** (the owner, 11-09: "a real feed… you scroll through easy content… a filter
@@@ -1457,8 +1477,6 @@ From a phone sweep of every site's home (11-02):
Font Awesome Brands. ov-glyphs.js, the glyph font (22.3 KB woff2), its Font Awesome stylesheet and the sprite are
rebuilt; existing code points are unchanged.
--## Unreleased
--
## 4.113.0 — 2026-11-06
- **Bot API calls can be counted at ingest, not stored row by row.** `AnalyticsTrackerPg` takes
ddiff --git a/README.md b/README.md
iindex 0010d82..dcd92fa 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.160.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.161.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 bd6b794..e23620a 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.160.0",
++ "version": "4.161.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.js b/dock.js
iindex bf77404..326d606 100644
---- a/dock.js
++++ b/dock.js
@@@ -32,6 +32,9 @@
const W = [320, 720], H = [360, 900], DEF = { width: 420, height: 640 };
const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
++ // Close is the OpenVibe menu's orb in its × form (11-09, the owner: "replace this close button with our own… the
++ // corner button… its close form… nice animations"): nine dots that fold into an × as the Dock opens.
++ const ORBX = `<svg viewBox="0 0 24 24" aria-hidden="true"><g class="ovdk-ox">${[[12, 12, 0], [12, 5, 1], [19, 12, 1], [12, 19, 1], [5, 12, 1], [5, 5, 2], [19, 5, 2], [19, 19, 2], [5, 19, 2]].map(([x, y, r]) => `<circle class="ovdk-o${r}" cx="${x}" cy="${y}" r="${r ? 2.15 : 2.5}"/>`).join('')}</g></svg>`;
const G = {
spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',
bell: '<path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2h-14z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>',
@@@ -43,7 +46,8 @@
back: '<path d="M14.5 6.5L9 12l5.5 5.5"/>',
send: '<path d="M4.5 12h11M11 6.5l5.5 5.5-5.5 5.5"/>',
vibe: '<path d="M12 4.5a7.5 7.5 0 0 1 6.4 11.4L19.5 19.5l-3.6-1.1A7.5 7.5 0 1 1 12 4.5z"/><path d="M9 11.5h.01M12 11.5h.01M15 11.5h.01"/>',
-- more: '<path d="M6 12h.01M12 12h.01M18 12h.01" stroke-width="3"/>',
++ // Three filled dots on the exact centre line (a stroked h.01 path drew them a hair off centre: the owner, 11-09).
++ more: '<g fill="currentColor" stroke="none"><circle cx="5.5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.5" cy="12" r="1.7"/></g>',
clock: '<circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/>',
};
// Vibe first (L48 V2): the person's AI, on every site.
@@@ -173,7 +177,20 @@
.ovdk-acts { display: flex; gap: 2px; margin-left: auto; }
.ovdk-acts button { width: 40px; height: 40px; border: 0; border-radius: 12px; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; }
.ovdk-acts button:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); }
-- .ovdk-acts button svg { width: 19px; height: 19px; }
++ .ovdk-acts button svg { width: 19px; height: 19px; display: block; }
++ .ovdk-orbx svg { width: 22px !important; height: 22px !important; overflow: visible; }
++ .ovdk-ox { transform-origin: 12px 12px; transform: rotate(180deg) scale(.86); transition: transform .45s cubic-bezier(.2, 1.35, .3, 1); }
++ .ovdk-ox circle { fill: currentColor; transform-box: fill-box; transform-origin: center; transition: opacity .25s, transform .3s cubic-bezier(.2, 1.4, .3, 1), fill .2s; }
++ .ovdk-ox .ovdk-o1 { opacity: 0; transform: scale(0); }
++ .ovdk-ox .ovdk-o0 { fill: var(--accent, #3b82f6); }
++ .ovdk.is-in .ovdk-ox { animation: ovdk-ox-in .55s cubic-bezier(.2, 1.35, .3, 1) both; }
++ .ovdk.is-in .ovdk-ox .ovdk-o1 { animation: ovdk-o1-out .4s ease both; }
++ .ovdk-orbx:hover .ovdk-ox, .ovdk-orbx:focus-visible .ovdk-ox { transform: rotate(270deg) scale(.9); }
++ .ovdk-orbx:hover .ovdk-ox circle { fill: var(--accent-light, #60a5fa); }
++ .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); } }
++ @media (prefers-reduced-motion: reduce) { .ovdk-ox, .ovdk-ox circle { transition: none; animation: none !important; } }
.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; }
@@@ -316,7 +333,7 @@
<div class="ovdk-tabs" role="tablist" aria-label="Dock">${TABS.map(([id, label, g]) => `<button type="button" role="tab" id="ovdk-tab-${id}" aria-controls="ovdk-panel-${id}" data-tab="${id}" aria-selected="false" tabindex="-1">${svg(g)}<span>${label}</span><b class="n" data-n="${id}"></b></button>`).join('')}</div>
<div class="ovdk-acts">
<button type="button" data-act="more" aria-haspopup="true" aria-expanded="false" title="More" aria-label="More: a timer, the Dock's button">${svg(G.more)}</button>
-- <button type="button" data-act="close" title="Close" aria-label="Close the Dock">${svg(G.close)}</button>
++ <button type="button" data-act="close" class="ovdk-orbx" title="Close" aria-label="Close the Dock">${ORBX}</button>
</div>
<div class="ovdk-more" role="menu" aria-label="More" hidden>
<button type="button" role="menuitem" data-act="timer">${svg(G.clock)}<span><b>Start a timer</b><small>A countdown or a focus round, on the Dock's button</small></span></button>
@@@ -918,6 +935,8 @@
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.
++ 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'); } else el.removeAttribute('aria-modal');
setTab(want, false);
readUnread();
ddiff --git a/kit-web.js b/kit-web.js
iindex 583be67..e4ff226 100644
---- a/kit-web.js
++++ b/kit-web.js
@@@ -177,6 +177,66 @@
if (typeof ResizeObserver === 'function') new ResizeObserver(update).observe(track); else window.addEventListener('resize', update);
update();
});
++ // Every sideways strip says it scrolls (11-09, the owner: "it's not very apparent you can horizontal scroll… a little
++ // bounce… or auto scroll until you tap"): a kit rail's track, or any element marked data-ovk-scroll. Its edges fade
++ // where more is hidden (is-more-start / is-more-end); on a touch screen it nudges once when it first comes into view;
++ // data-ovk-scroll="drift" drifts slowly while in view (back and forth) and stops for good at the first touch,
++ // wheel or keyboard focus. Reduced motion: fades only.
++ all('.ovk-rail-track, [data-ovk-scroll]').forEach((track) => {
++ if (!once(track, 'ovkScroll')) return;
++ // A thin position bar under the strip (a thumb as wide as the share in view), shown while there is more.
++ let bar = null, thumb = null;
++ if (track.getAttribute('data-ovk-scroll-bar') !== 'off') {
++ bar = document.createElement('span'); bar.className = 'ovk-scroll-bar'; bar.setAttribute('aria-hidden', 'true'); bar.hidden = true;
++ thumb = document.createElement('i'); bar.appendChild(thumb);
++ track.insertAdjacentElement('afterend', bar);
++ }
++ const edges = () => {
++ const max = track.scrollWidth - track.clientWidth;
++ track.classList.toggle('is-more-start', max > 4 && track.scrollLeft > 4);
++ track.classList.toggle('is-more-end', max > 4 && track.scrollLeft < max - 4);
++ if (bar) {
++ bar.hidden = max <= 4;
++ if (max > 4) { const share = track.clientWidth / track.scrollWidth; thumb.style.width = `${Math.max(14, share * 100)}%`; thumb.style.transform = `translateX(${(track.scrollLeft / max) * ((1 / Math.max(share, 0.14)) - 1) * 100}%)`; }
++ }
++ };
++ track.addEventListener('scroll', () => requestAnimationFrame(edges), { passive: true });
++ if (typeof ResizeObserver === 'function') new ResizeObserver(edges).observe(track);
++ edges();
++ if (still() || typeof IntersectionObserver !== 'function') return;
++ const drift = track.getAttribute('data-ovk-scroll') === 'drift';
++ let touched = false, inView = false, raf = 0, dir = 1, last = 0, pos = -1;
++ const stop = () => { touched = true; cancelAnimationFrame(raf); raf = 0; };
++ ['pointerdown', 'wheel', 'focusin', 'touchstart'].forEach((ev) => track.addEventListener(ev, stop, { passive: true, once: true }));
++ const step = (t) => {
++ raf = 0;
++ if (touched || !inView || document.hidden) return;
++ const dt = last ? Math.min(64, t - last) : 16; last = t;
++ const max = track.scrollWidth - track.clientWidth;
++ if (max <= 4) return;
++ // A float of its own: a browser rounds scrollLeft, and under a pixel a frame would never move.
++ if (pos < 0 || Math.abs(pos - track.scrollLeft) > 2) pos = track.scrollLeft;
++ pos = Math.max(0, Math.min(max, pos + dir * dt * 0.028));
++ track.scrollLeft = pos;
++ if (pos >= max - 1) dir = -1; else if (pos <= 0) dir = 1;
++ raf = requestAnimationFrame(step);
++ };
++ let nudged = false;
++ new IntersectionObserver((entries) => {
++ for (const e of entries) {
++ inView = e.isIntersecting && e.intersectionRatio >= 0.6;
++ if (!inView || touched) continue;
++ const max = track.scrollWidth - track.clientWidth;
++ if (max <= 4) continue;
++ if (drift) { last = 0; if (!raf) raf = requestAnimationFrame(step); continue; }
++ // One nudge, phones only (a computer has the ‹ › buttons): 48 px along and back.
++ if (!nudged && !fine && track.scrollLeft < 4) {
++ nudged = true;
++ setTimeout(() => { if (touched) return; track.scrollBy({ left: 48, behavior: 'smooth' }); setTimeout(() => { if (!touched) track.scrollBy({ left: -48, behavior: 'smooth' }); }, 420); }, 350);
++ }
++ }
++ }, { threshold: [0, 0.6] }).observe(track);
++ });
// Share: the system sheet where there is one, else the link copied, said in the item itself.
all('[data-ovk-share]').forEach((a) => {
if (!once(a, 'ovkShare')) return;
ddiff --git a/navbar.js b/navbar.js
iindex d5605f8..e73e4e5 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -154,6 +154,18 @@
.ovnav-display .ovnav-seg button[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); }
.ovnav-display .ovnav-seg button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
.ovnav-drawer .ovnav-display a { display: inline; padding: 6px 4px; margin-left: auto; font-size: 12px; color: var(--accent-light, var(--accent, #60a5fa)); }
++ .ovnav-display a.ovnav-disp-row { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 52px; margin: 0; padding: 4px 6px; border-radius: 14px; color: var(--text-primary, #e6edf7); text-decoration: none; }
++ .ovnav-display a.ovnav-disp-row:hover { background: var(--bg-hover, rgba(255,255,255,.06)); }
++ .ovnav-disp-ic { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; background: linear-gradient(150deg, hsl(285 72% 58% / .34), hsl(285 70% 38% / .18)); color: hsl(285 90% 82%); }
++ .ovnav-disp-row > span:nth-child(2) { flex: 1; display: grid; gap: 1px; } .ovnav-disp-row b { font-size: 14px; } .ovnav-disp-row small { font-size: 12px; color: var(--text-muted, #7d8aa0); }
++ .ovnav-disp-row[aria-expanded="true"] > svg:last-child { transform: rotate(180deg); }
++ .ovnav-display .ovnav-disp { display: flex; align-items: center; gap: 8px; width: 100%; }
++ .ovnav-display .ovnav-disp .ovnav-dl { flex-basis: auto; margin-right: auto; }
++ .ovnav-display .ovnav-seg-ic button { display: inline-grid; place-items: center; width: 40px; height: 34px; padding: 0; }
++ .ovnav-display .ovnav-seg-ic svg { width: 17px; height: 17px; }
++ .ovnav-display a.ovnav-disp-more { display: inline-flex; align-items: center; gap: 6px; height: 34px; margin: 0; padding: 0 12px; border-radius: 9px; background: color-mix(in srgb, var(--accent, #3b82f6) 13%, transparent); color: var(--accent-light, #60a5fa); font-size: 12.5px; font-weight: 650; text-decoration: none; }
++ .ovnav-display a.ovnav-disp-more svg { width: 16px; height: 16px; }
++ .ovnav-display .ud-look { flex-basis: 100%; }
.ovnav-drawer .ovnav-drawer-net a.ovnav-net { gap: 9px; padding: 8px; font-size: 13.5px; min-width: 0; }
.ovnav-drawer .ovnav-drawer-net a.ovnav-net > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Icons have their size and a faint ring before ov-icons.js draws them: nothing moves or pops when they arrive. */
@@@ -386,6 +398,14 @@
overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; scrollbar-width: thin;
}
@keyframes openvibe-slide-down { from { transform: translateY(-8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
++ .ov-pop { transform-origin: var(--ov-pop-x, 50%) var(--ov-pop-y, 0); animation: ov-pop .34s cubic-bezier(.2, 1.1, .3, 1) both !important; }
++ .ov-pop-out { transform-origin: var(--ov-pop-x, 50%) var(--ov-pop-y, 0); animation: ov-pop-out .16s ease-in both !important; pointer-events: none; }
++ @keyframes ov-pop { 0% { opacity: 0; transform: scale(.88); clip-path: circle(0 at var(--ov-pop-x, 50%) var(--ov-pop-y, 0)); filter: blur(5px); } 55% { opacity: 1; filter: blur(0); } 100% { opacity: 1; transform: none; clip-path: circle(150% at var(--ov-pop-x, 50%) var(--ov-pop-y, 0)); } }
++ @keyframes ov-pop-out { to { opacity: 0; transform: scale(.94); filter: blur(3px); } }
++ @keyframes ov-fade { from { opacity: 0; } }
++ @keyframes ov-pop-calm { from { opacity: 0; transform: scale(.97); } }
++ @media (prefers-reduced-motion: reduce) { .ov-pop { animation-name: ov-pop-calm !important; animation-duration: .18s !important; } .ov-pop-out { animation-name: ov-fade !important; animation-duration: .12s !important; animation-direction: reverse !important; } }
++ html[data-ov-motion=reduced] .ov-pop { animation-name: ov-pop-calm !important; animation-duration: .18s !important; }
.openvibe-navbar-dropdown-header {
padding: 12px 10px 12px 14px; border-bottom: 1px solid var(--border, #333340);
@@@ -447,6 +467,8 @@
.openvibe-navbar-dropdown-menu .ud-grid:has(> .ud-tile:nth-child(3):last-child) { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.openvibe-navbar-dropdown-menu .ud-grid:has(> .ud-tile:nth-child(5):last-child) { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.openvibe-navbar-dropdown-menu .ud-grid:has(> .ud-tile:nth-child(6):last-child) { grid-template-columns: repeat(3, minmax(0, 1fr)); }
++ .openvibe-navbar-dropdown-menu .ud-grid .ud-tile { min-height: 60px; padding: 7px 2px 6px; gap: 5px; }
++ .openvibe-navbar-dropdown-header .ud-me img { width: 52px; height: 52px; }
/* The balances in the header: OpenCoins and Vibes side by side, each a coloured icon, a number and its name. */
.openvibe-navbar-dropdown-header .ud-wallet a small { font-weight: 600; opacity: .75; font-size: 10.5px; }
/* On a phone the menu is a sheet the width of the screen under the bar, not a narrow card off to the right. */
@@@ -462,6 +484,7 @@
.openvibe-navbar-dropdown-menu .ud-foot { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin: 4px -8px -6px; padding: 8px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); background: color-mix(in srgb, var(--text-primary, #e6edf7) 3%, transparent); }
.openvibe-navbar-dropdown-menu .ud-foot .ud-ib { width: auto; min-width: 34px; height: 34px; padding: 0 8px; justify-content: center; gap: 3px; border-radius: 10px; color: var(--text-secondary, #a8b3c4); }
.openvibe-navbar-dropdown-menu .ud-foot > .ud-ib { flex: 1 1 0; }
++ .openvibe-navbar-dropdown-menu .ud-foot > .ud-disp { flex: 0 0 auto; gap: 7px; padding: 0 12px; font-size: 13px; font-weight: 650; }
.openvibe-navbar-dropdown-menu .ud-foot .ud-ib .icon { width: 18px; color: inherit; }
.openvibe-navbar-dropdown-menu .ud-foot .ud-ib .ud-val { margin: 0; font-size: 11px; font-weight: 700; color: inherit; }
.openvibe-navbar-dropdown-menu .ud-foot .ud-ib .ud-val:empty { display: none; }
@@@ -525,16 +548,15 @@
'fa-bell': [0, '<path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15ZM10 20a2 2 0 0 0 4 0"/>', '<path d="M5.8 16.2V11a6.2 6.2 0 0 1 12.4 0v5.2l1.7 2.3H4.1ZM9.9 19.6h4.2a2.1 2.1 0 0 1-4.2 0Z"/>'],
'fa-clock-rotate-left': [0, '<path d="M4 12a8 8 0 1 0 2.4-5.7L4 8.6M4 4.5v4.1h4.1"/><path d="M12 8v4.2l3 1.8"/>', '<path d="M6.06 6.06A8.4 8.4 0 1 1 3.6 12h2.8a5.6 5.6 0 1 0 1.64-3.96ZM3 3.2v6.6h6.6ZM11 8.2h2v3.4l2.5 1.5-1 1.7-3.5-2.1Z"/>'],
'fa-coins': [0, '<circle cx="12" cy="12" r="8.5"/><path d="M14.5 9.2c-.5-.8-1.4-1.2-2.5-1.2-1.5 0-2.5.8-2.5 2s1 1.6 2.5 2 2.5.8 2.5 2-1 2-2.5 2c-1.1 0-2-.4-2.5-1.2M12 6.5v11"/>', '<path fill-rule="evenodd" d="M12 2.8a9.2 9.2 0 1 0 0 18.4 9.2 9.2 0 0 0 0-18.4ZM11 6h2v1.2c1.1.2 2 .8 2.5 1.7l-1.7 1c-.3-.6-1-1-1.8-1-.9 0-1.5.4-1.5 1s.5.8 1.8 1.1c1.8.4 3.4 1 3.4 3 0 1.4-1 2.4-2.7 2.7V18h-2v-1.3c-1.2-.2-2.2-.9-2.7-1.9l1.7-1c.4.7 1.1 1.1 2 1.1 1 0 1.6-.4 1.6-1s-.6-.8-1.9-1.1c-1.7-.4-3.3-1-3.3-2.9 0-1.3 1-2.3 2.6-2.6Z"/>'],
++ 'fa-id-card': [0, '<rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="11" r="2"/><path d="M6 16c.5-1.5 1.7-2.3 3-2.3s2.5.8 3 2.3M15 10h3M15 13.5h3"/>', '<path fill-rule="evenodd" d="M5 4.8h14a2.2 2.2 0 0 1 2.2 2.2v10a2.2 2.2 0 0 1-2.2 2.2H5A2.2 2.2 0 0 1 2.8 17V7A2.2 2.2 0 0 1 5 4.8Zm4 4.1a2.1 2.1 0 1 0 0 4.2 2.1 2.1 0 0 0 0-4.2Zm-3.1 7.5h6.2c-.6-1.7-1.8-2.6-3.1-2.6s-2.5.9-3.1 2.6Zm8.6-7v1.5h3.9V9.4Zm0 3.5v1.5h3.9v-1.5Z"/>'],
'fa-palette': [0, '<path d="M12 3a9 9 0 1 0 0 18c1.2 0 1.8-.8 1.8-1.7 0-1.2-1-1.6-1-2.6s.8-1.7 2-1.7H17a4 4 0 0 0 4-4C21 6.7 17 3 12 3Z"/><circle cx="7.5" cy="11" r="1.2"/><circle cx="10" cy="7.3" r="1.2"/><circle cx="14.5" cy="7.3" r="1.2"/>', '<path fill-rule="evenodd" d="M12 2.7a9.3 9.3 0 1 0 0 18.6c1.3 0 2-.9 2-1.9 0-1.2-1-1.5-1-2.5s.8-1.6 2-1.6H17a4.3 4.3 0 0 0 4.3-4.3C21.3 6.5 17.2 2.7 12 2.7ZM7.5 9.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm2.5-3.7a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm4.5 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Z"/>'],
'fa-people-group': [0, '<circle cx="9" cy="8" r="3.2"/><path d="M3 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5M16 4.8a3.2 3.2 0 0 1 0 6.4M18 14.8c1.9.6 3 2.4 3 5.2"/>', '<circle cx="9" cy="7.9" r="3.6"/><path d="M2.4 20.8c0-3.8 3-6.5 6.6-6.5s6.6 2.7 6.6 6.5Z"/><circle cx="16.6" cy="7.4" r="2.9"/><path d="M16.8 13.4c2.9.4 4.8 2.8 4.8 6.4V20.8h-4.4c0-2.9-1-5.2-2.8-6.7Z"/>'],
'fa-plus': [0, '<path d="M12 5v14M5 12h14"/>', ''],
'fa-right-from-bracket': [0, '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4M10 16l-4-4 4-4M6 12h10"/>', ''],
'fa-shield-halved': [0, '<path d="M12 3 5 6v6c0 4.4 3 7.6 7 9 4-1.4 7-4.6 7-9V6Z"/>', '<path d="M12 2.6 4.6 5.8V12c0 4.6 3.1 8 7.4 9.5 4.3-1.5 7.4-4.9 7.4-9.5V5.8Z"/>'],
-- 'fa-text-height': [0, '<path d="M5.5 20 11 4h2l5.5 16M7.6 14h8.8"/>', '<path fill-rule="evenodd" d="M10.3 3.4h3.4l6.3 17.2h-3.1l-1.5-4.3H8.6l-1.5 4.3H4ZM9.5 13.7h5L12 6.4Z"/>'],
'fa-tower-broadcast': [0, '<circle cx="12" cy="12" r="2.5"/><path d="M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14"/>', '<circle cx="12" cy="12" r="3.1"/><path class="ln" d="M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14"/>'],
'fa-user-secret': [0, '<circle cx="12" cy="8" r="3.5"/><path d="M5 20c0-3.6 3.1-6 7-6s7 2.4 7 6"/>', '<circle cx="12" cy="7.8" r="4.1"/><path d="M4.2 21c0-4.3 3.5-7.2 7.8-7.2s7.8 2.9 7.8 7.2Z"/>'],
'fa-wallet': [0, '<path d="M4 7.5A2.5 2.5 0 0 1 6.5 5H18v4M4 7.5V18a2 2 0 0 0 2 2h14V9H6.5A2.5 2.5 0 0 1 4 7.5Z"/><path d="M16 14.5h.01"/>', '<path fill-rule="evenodd" d="M6.5 4.6H18v4h1.4a.8.8 0 0 1 .8.8v10.8H6a2.2 2.2 0 0 1-2.2-2.2V7.3a2.7 2.7 0 0 1 2.7-2.7Zm0 1.8a.9.9 0 0 0 0 1.8h9.7V6.4ZM16 13.3a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Z"/>'],
-- 'fa-wand-magic-sparkles': [0, '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8ZM19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8Z"/>', '<path d="M12 2.6l2 5.6 5.6 2-5.6 2L12 17.8l-2-5.6-5.6-2 5.6-2ZM19 14.6l.9 2.5 2.5.9-2.5.9-.9 2.5-.9-2.5-2.5-.9 2.5-.9Z"/>'],
'fa-chevron-down': [0, '<path d="m6 9 6 6 6-6"/>', ''],
'fa-gear': [0, '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>', '<path fill-rule="evenodd" d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z M12 8.6a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8Z"/>'],
'fa-circle-half-stroke': [0, '<circle cx="12" cy="12" r="9"/><path d="M12 3v18M12 7.5h7M12 12h9M12 16.5h7"/>', ''],
@@@ -545,12 +567,12 @@
};
// Every other glyph in nav-icons.js, grouped by advance width (sizes its slot before the path arrives); 0 is an OpenVibe glyph.
const NAV_ICONS_LAZY = {
-- 0: 'fa-book fa-camera fa-comments fa-gamepad fa-gauge-high fa-hand-fist fa-house fa-id-card fa-link fa-list-check fa-network-wired fa-paste fa-people-arrows fa-scissors fa-screwdriver-wrench fa-shirt fa-user fa-video fa-arrow-up-right-from-square fa-chevron-right fa-bars fa-xmark fa-magnifying-glass fa-file-pdf fa-arrow-down fa-arrow-right fa-clock fa-envelope fa-star fa-heart fa-play fa-circle-info fa-triangle-exclamation fa-check fa-globe fa-code fa-robot fa-photo-film fa-tower-cell fa-diagram-project fa-toolbox fa-money-bill-wave fa-crown fa-server fa-newspaper fa-tag fa-ticket fa-chart-line fa-pen-nib fa-key fa-plug fa-bolt fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music fa-file-lines fa-font fa-location-dot fa-lock fa-wave-square fa-map fa-utensils fa-user-shield fa-gauge fa-pen fa-earth-americas fa-hashtag fa-inbox fa-folder fa-puzzle-piece fa-shield fa-display fa-microphone fa-infinity fa-list fa-flag-checkered fa-tv fa-compass',
++ 0: 'fa-book fa-camera fa-comments fa-gamepad fa-gauge-high fa-hand-fist fa-house fa-link fa-list-check fa-network-wired fa-paste fa-people-arrows fa-scissors fa-screwdriver-wrench fa-shirt fa-text-height fa-user fa-video fa-wand-magic-sparkles fa-arrow-up-right-from-square fa-chevron-right fa-bars fa-xmark fa-magnifying-glass fa-file-pdf fa-arrow-down fa-arrow-right fa-clock fa-envelope fa-star fa-heart fa-play fa-circle-info fa-triangle-exclamation fa-check fa-globe fa-code fa-robot fa-photo-film fa-tower-cell fa-diagram-project fa-toolbox fa-money-bill-wave fa-crown fa-server fa-newspaper fa-tag fa-ticket fa-chart-line fa-pen-nib fa-key fa-plug fa-bolt fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music fa-file-lines fa-font fa-location-dot fa-lock fa-wave-square fa-map fa-utensils fa-user-shield fa-gauge fa-pen fa-earth-americas fa-hashtag fa-inbox fa-folder fa-puzzle-piece fa-shield fa-display fa-microphone fa-infinity fa-list fa-flag-checkered fa-tv fa-compass',
};
const NAV_ICONS_REV = 'd9a4febbbad7'; // 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': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '6c1578a7dfdf', 'dock.js': '4d29a2d5ecb4', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '25960cf0c075', 'ov-home.js': '8229ff459796', 'nav-slots.js': 'a5af310d62e0' };
++ const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '45c4a9a66249', 'dock.js': 'e3a24221e4ef', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '25960cf0c075', 'ov-home.js': 'b85341c89bd9', 'nav-slots.js': 'a5af310d62e0' };
// 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">.
@@@ -941,6 +963,20 @@
later();
}
++ // Panels grow out of their trigger: OpenVibeNavbar.pop(el, trigger) / .unpop(el, done).
++ function popFrom(el, from) {
++ try {
++ const r = el.getBoundingClientRect(), f = from && from.getBoundingClientRect ? from.getBoundingClientRect() : null;
++ el.style.setProperty('--ov-pop-x', `${Math.round(f ? f.left + f.width / 2 - r.left : r.width / 2)}px`);
++ el.style.setProperty('--ov-pop-y', `${Math.round(f ? f.top + f.height / 2 - r.top : 0)}px`);
++ el.classList.remove('ov-pop', 'ov-pop-out'); void el.offsetWidth; el.classList.add('ov-pop');
++ } catch { /* */ }
++ }
++ function unpop(el, done) {
++ let ended = false; const end = () => { if (ended) return; ended = true; el.classList.remove('ov-pop-out'); if (done) done(); };
++ el.classList.remove('ov-pop'); el.classList.add('ov-pop-out');
++ el.addEventListener('animationend', end, { once: true }); setTimeout(end, 220);
++ }
// A site's buttons: nav-slots.js draws them, loaded on first use; each render paints them again.
function paintSlots() {
if (!_navEl || typeof document === 'undefined') return;
@@@ -1212,6 +1248,7 @@
const box = a.parentElement && a.parentElement.parentElement && a.parentElement.parentElement.querySelector('.ud-look') || scope.querySelector('.ud-look'); if (!box) return;
e.preventDefault(); e.stopPropagation();
const open = box.hidden; box.hidden = !open; a.setAttribute('aria-expanded', String(open));
++ if (open) popFrom(box, a);
if (!open || box.__ovlk) return;
const go = () => { if (root.OVLook) root.OVLook.mount(box, { signedIn: !!_config.user, season: !!_config.user }); else location.href = a.href; };
if (root.OVLook) return go();
@@@ -1271,24 +1308,17 @@
return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
}
-- /** Display settings for guests, at the foot of the phone menu (members have them in the account menu); theme-loader.js owns the state. */
++ /** The menu's Display row: light, dark or auto as icons, and Settings opening the Look panel (text size, motion, theme,
++ * icons) in place, the same as the account menu's (11-09, the owner: "moved to a settings menu… more compact"). */
function bindDisplayControls(box) {
const L = root.OpenVibeThemeLoader; if (!box || !L || !L.display) return;
-- const paint = () => {
-- const d = L.display.get();
-- const seg = (key, val, label, title) => `<button type="button" data-k="${key}" data-v="${val}" aria-pressed="${String(d[key]) === val}" title="${title}">${label}</button>`;
-- const sch = L.scheme ? L.scheme.get() : null;
-- const ss = (val, label, title) => `<button type="button" data-s="${val}" aria-pressed="${sch === val}" title="${title}">${label}</button>`;
-- box.innerHTML = `<span class="ovnav-dl">Display</span>${sch ? `<span class="ovnav-seg" role="group" aria-label="Light or dark">${ss('light', 'Light', 'Light theme')}${ss('dark', 'Dark', 'Dark theme')}${ss('auto', 'Auto', 'Follow this device')}</span>` : ''}<span class="ovnav-seg" role="group" aria-label="Text size">${seg('text', '100', 'A', 'Default text size')}${seg('text', '112', 'A+', 'Larger text')}${seg('text', '125', 'A++', 'Largest text')}</span>
-- <span class="ovnav-seg" role="group" aria-label="Motion">${seg('motion', 'auto', 'Motion', 'Animations on')}${seg('motion', 'reduced', 'Calm', 'Reduce animations')}</span>${seasonState() ? `<span class="ovnav-seg" role="group" aria-label="Seasonal themes">${seg('seasonal', 'on', seasonState().s.name, 'Seasonal themes on')}${seg('seasonal', 'off', 'Off', 'Seasonal themes off')}</span>` : ''}<a href="https://openvibe.design/look" data-ov-look-open aria-expanded="false">Look</a><div class="ud-look" hidden></div>`;
-- box.hidden = false;
-- };
-- box.addEventListener('click', (e) => {
-- const s2 = e.target.closest('button[data-s]'); if (s2 && L.scheme) { L.scheme.set(s2.dataset.s); paint(); return; }
-- const b = e.target.closest('button[data-k]'); if (!b) return; L.display.set({ [b.dataset.k]: b.dataset.v }); paint();
-- });
++ const say = () => { const sch = L.scheme ? L.scheme.get() : null; const d = L.display.get(); return [{ light: 'Light', dark: 'Dark', auto: 'Auto' }[sch] || '', d.motion === 'reduced' ? 'Calm' : '', String(d.text) !== '100' ? 'Large text' : ''].filter(Boolean).join(' · '); };
++ box.innerHTML = `<a class="ovnav-disp-row" href="https://openvibe.design/look" data-ov-look-open aria-expanded="false"><span class="ovnav-disp-ic">${navIcon('fa-palette')}</span><span><b>Display and theme</b><small data-disp-say></small></span>${navIcon('fa-chevron-down')}</a><div class="ud-look" hidden></div>`;
++ const paint = () => { const s = box.querySelector('[data-disp-say]'); if (s) s.textContent = say(); };
++ bindLook(box);
if (typeof root.addEventListener === 'function') { root.addEventListener('ov:theme', paint, onDocument()); root.addEventListener('ov:display', paint, onDocument()); }
paint();
++ box.hidden = false;
}
// ── Notification bell (mounted by the navbar itself) ─────
@@@ -2120,6 +2150,8 @@
!isAnon ? { id: 'ov-work', label: 'Work together', short: 'Work', drawn: navIcon('fa-people-group'), href: 'https://openvibe.chat/work', mark: 'data-ov-work' } : null,
bell && _config.dock !== false ? { id: 'ov-dock', label: 'The Dock: your agents and activity on every page', short: 'Dock', drawn: DOCK_GLYPH, mark: 'data-ov-dock' } : null,
admin ? { id: 'ov-admin', label: 'Admin', drawn: navIcon('fa-shield-halved'), href: 'https://openvibe.network/admin' } : null,
++ !isAnon ? { id: 'ov-wallet', label: 'Wallet', drawn: navIcon('fa-wallet'), href: 'https://openvibe.network/my#wallet' } : null,
++ !isAnon && u.username ? { id: 'ov-profile', label: 'Profile', drawn: navIcon('fa-id-card'), href: `https://openvibe.network/@${encodeURIComponent(u.username)}` } : null,
].filter(Boolean));
const ranked = byUse(places), grid = ranked.slice(0, 8), more = ranked.slice(8);
const others = otherAccounts.filter(a => switcher || !a.is_anon);
@@@ -2149,9 +2181,7 @@
${sectionsHTML(menuCfg.sections)}
<div class="ud-foot">
<div class="ud-scheme" role="group" aria-label="Light or dark" hidden>${iconBtn('', navIcon('fa-sun'), 'Light', ' data-ov-scheme="light" aria-pressed="false"')}${iconBtn('', navIcon('fa-moon'), 'Dark', ' data-ov-scheme="dark" aria-pressed="false"')}${iconBtn('', navIcon('fa-circle-half-stroke'), 'Auto: follow this device', ' data-ov-scheme="auto" aria-pressed="false"')}</div>
-- <button type="button" class="ud-ib ud-txt" data-ov-display="text" title="Text size" aria-label="Text size"><span class="icon">${navIcon('fa-text-height')}</span><b class="ud-val"></b></button>
-- <button type="button" class="ud-ib" data-ov-display="motion" title="Animations" aria-label="Animations"><span class="icon">${navIcon('fa-wand-magic-sparkles')}</span><b class="ud-val"></b></button>
-- <a class="ud-ib" href="https://openvibe.design/look" data-ov-look-open aria-expanded="false" title="Look: themes, the season, and more on openvibe.design" aria-label="Look"><span class="icon">${navIcon('fa-palette')}</span></a>
++ <a class="ud-ib ud-disp" href="https://openvibe.design/look" data-ov-look-open aria-expanded="false" title="Theme, text size, motion and icons" aria-label="Display settings"><span class="icon">${navIcon('fa-palette')}</span><span>Display</span></a>
</div>
<div class="ud-look" hidden></div>
</div>
@@@ -2170,7 +2200,8 @@
const avatarBtn = nav.querySelector('#openvibe-avatar-btn'); let sel = null;
avatarBtn.addEventListener('pointerdown', () => { sel = selectedText(); });
avatarBtn.addEventListener('click', () => {
-- dropdown.classList.toggle('open');
++ if (dropdown.classList.contains('open') && !dropdown.classList.contains('ov-pop-out')) { unpop(dropdown, () => { if (!dropdown.classList.contains('ov-pop')) dropdown.classList.remove('open'); }); return; }
++ dropdown.classList.add('open'); popFrom(dropdown, avatarBtn);
const ask = dropdown.querySelector('[data-ov-ask]'); if (ask) ask.href = askVibeHref(sel ?? selectedText()); sel = null;
if (dropdown.classList.contains('open')) { loadWallet(dropdown); dropdown.scrollTop = 0; loadOvIcons(dropdown); }
});
@@@ -2607,6 +2638,8 @@
/** The Dock on a tab, signed in only; opts.task points at that step (see openDock above). */
openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); },
++ pop: popFrom,
++ unpop,
askAgent(text) { askAgent(text); },
/** Get the bell mount point for OpenVibeNotifications. */
ddiff --git a/ov-home.js b/ov-home.js
iindex 40616e1..ac48f89 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -50,7 +50,9 @@
.ovh-sec button.is-done { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
.ovh-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px; padding: 2px; }
@media (min-width: 400px) { .ovh-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } }
--@media (min-width: 861px) { .ovh-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } }
++@media (min-width: 861px) { .ovh-grid { grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); } }
++.ovh-grid { position: relative; }
++.ovh.is-edit .ovh-grid:not(.ovh-recent) { touch-action: none; -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; }
@@@ -58,6 +60,13 @@
.ovh-ic { position: relative; display: grid; place-items: center; width: 56px; height: 56px; border-radius: 17px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent);
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 { 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%); }
++.ovh-folder .ovh-ic { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 7%, transparent); grid-template-columns: repeat(2, 22px); grid-auto-rows: 22px; gap: 3px; }
++.ovh-folder .ovh-ic > span { width: 22px; height: 22px; border-radius: 7px; overflow: hidden; background: hsl(var(--ovh-h, 215) 70% 50% / .3); }
.ovh-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }
.ovh-folder .ovh-ic { display: grid; grid-template-columns: repeat(2, 18px); grid-auto-rows: 18px; gap: 4px; place-content: center; }
.ovh-folder .ovh-ic > span { display: grid; place-items: center; width: 18px; height: 18px; }
@@@ -70,7 +79,10 @@
.ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-app { touch-action: none; }
.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: .25; }
++.ovh-app.is-lifted { opacity: .18; transform: scale(.92); }
++.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); } }
.ovh-ghost { position: fixed; z-index: 10040; pointer-events: none; width: 64px; height: 64px; margin: -32px 0 0 -32px; border-radius: 19px; display: grid; place-items: center;
background: var(--bg-secondary, #141a24); box-shadow: 0 12px 30px rgba(0,0,0,.35), inset 0 0 0 1px var(--border, rgba(255,255,255,.14)); transform: scale(1.08); }
@keyframes ovh-wiggle { from { transform: rotate(-1.6deg); } to { transform: rotate(1.6deg); } }
@@@ -83,7 +95,7 @@
.ovh-back { min-height: 44px; padding: 0 12px; border: 0; border-radius: 12px; background: transparent; color: var(--accent, #3b82f6); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
.ovh-filter { width: calc(100% - 8px); min-height: 44px; margin: 2px 4px 4px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.14));
background: var(--bg-primary, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 15px; }
--.ovh-actor { display: flex; align-items: center; gap: 12px; width: calc(100% - 8px); min-height: 64px; margin: 6px 4px 4px; padding: 10px 12px; border-radius: 16px; border: 0; text-align: left; cursor: pointer;
++.ovh-actor { box-sizing: border-box; display: flex; align-items: center; gap: 12px; width: calc(100% - 8px); min-height: 64px; margin: 6px 4px 4px; padding: 10px 12px; border-radius: 16px; border: 0; text-align: left; cursor: pointer;
background: color-mix(in srgb, var(--accent, #3b82f6) 11%, var(--bg-secondary, #141a24)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 28%, transparent); color: inherit; font: inherit; }
.ovh-face { flex: none; display: grid; place-items: center; width: 42px; height: 42px; border-radius: 50%; overflow: hidden; background: var(--accent, #3b82f6); color: #fff; font-weight: 800; font-size: 17px; }
.ovh-face img { width: 100%; height: 100%; object-fit: cover; }
@@@ -97,6 +109,13 @@
.ovh-go { flex: none; color: var(--accent, #3b82f6); font-weight: 700; font-size: 13px; }
.ovh-x { flex: none; min-width: 44px; min-height: 44px; border: 0; background: transparent; color: var(--text-muted, #93a0b4); font: inherit; font-size: 12.5px; cursor: pointer; }
@keyframes ovh-pulse { 50% { opacity: .35; } }
++.ovh-actor.is-invite { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 10px 12px; padding: 12px 14px; cursor: default; }
++.ovh-actor.is-invite .ovh-face { width: 48px; height: 48px; border-radius: 15px; background: linear-gradient(135deg, var(--accent, #3b82f6), color-mix(in srgb, var(--accent, #3b82f6) 45%, #a855f7)); box-shadow: 0 6px 18px color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
++.ovh-actor.is-invite .ovh-actor-txt small { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.4; }
++.ovh-actor.is-invite .ovh-acts { grid-column: 2; display: flex; gap: 8px; }
++.ovh-actor.is-invite .ovh-go { display: inline-flex; align-items: center; height: 36px; padding: 0 16px; border-radius: 999px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); text-decoration: none; font-size: 13.5px; }
++.ovh-actor.is-invite .ovh-x { min-height: 36px; height: 36px; padding: 0 14px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); color: var(--text-secondary, #a8b3c4); font-weight: 600; }
++@media (min-width: 560px) { .ovh-actor.is-invite { grid-template-columns: 48px minmax(0, 1fr) auto; } .ovh-actor.is-invite .ovh-acts { grid-column: auto; } }
.ovh-skel { display: block; height: 14px; margin: 10px 8px; border-radius: 7px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }
.ovh-compose { display: flex; gap: 10px; align-items: flex-start; margin: 6px 4px; padding: 10px; border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent);
box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); }
@@@ -122,7 +141,7 @@
.ovh-lives { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.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); }
--@media (prefers-reduced-motion: reduce) { .ovh.is-edit .ovh-grid .ovh-ic, .ovh-chip.is-working .ovh-dot { animation: none !important; } .ovh-ic { transition: none; } }
++@media (prefers-reduced-motion: reduce) { .ovh.is-edit .ovh-grid .ovh-ic { animation-name: ovh-wiggle-calm !important; } .ovh-chip.is-working .ovh-dot { animation: none !important; } }
`;
document.head.appendChild(st);
}
@@@ -163,7 +182,7 @@
function folderDefs(o) {
const apps = o.apps || [];
return [
-- { id: 'tools', name: 'Tools', apps: (o.tools || []).map((t) => t.id), glyph: 'tools', more: { label: `All ${o.toolCount || (o.tools || []).length} tools`, href: 'https://openvibe.tools/' } },
++ { id: 'tools', name: 'Quick tools', apps: (o.tools || []).map((t) => t.id), glyph: 'tools', more: { label: `All ${o.toolCount || (o.tools || []).length} tools`, href: 'https://openvibe.tools/' } },
{ id: 'build', name: 'Build', apps: apps.filter((a) => a.group === 'build').map((a) => a.id), glyph: 'code' },
].filter((f) => f.apps.length);
}
@@@ -188,12 +207,17 @@
const s = h('span', 'ov-icon'); s.setAttribute('data-icon', site || 'ov'); s.setAttribute('data-size', String(size)); s.setAttribute('data-fx', 'none');
return s;
}
++ /** A steady hue per site (the same on every page and device), so each app has its own colour. */
++ function hueOf(id) { let x = 7; const t = String(id || 'ov'); let i = 0; while (i < t.length) { x = (x * 31 + t.charCodeAt(i)) % 360; i++; } return x; }
function appTile(o, a, { tag = 'a', size = 40, small = false } = {}) {
const el = h(tag, 'ovh-app');
if (tag === 'a') el.href = safe(a.url) || '#'; else el.type = 'button';
el.setAttribute('data-id', a.id);
++ // An app icon, not a ring (11-09, the owner: "icons need major improvement, make use of the full space… more
++ // consistent"): the site's glyph large on a squircle tinted with its own steady hue.
const ic = h('span', 'ovh-ic');
-- ic.append(siteIcon(a.site, small ? 32 : size));
++ ic.style.setProperty('--ovh-h', String(hueOf(a.site || a.id)));
++ ic.append(siteIcon(a.site, small ? 44 : 60));
el.append(ic, h('span', 'ovh-name', a.title));
if (tag === 'a') el.addEventListener('click', () => { if (o.onOpen) o.onOpen(a); });
return el;
@@@ -204,7 +228,8 @@
el.setAttribute('data-id', `folder:${f.id}`);
el.setAttribute('aria-label', `${f.name} folder, ${f.apps.length} apps`);
const ic = h('span', 'ovh-ic');
-- f.apps.slice(0, 4).forEach((id) => { const a = byId.get(id) || (o.tools || []).find((t) => t.id === id); const cell = h('span'); cell.append(siteIcon(a ? a.site : f.glyph, 16)); ic.append(cell); });
++ // Each app's own icon (a tool's own glyph, not the folder's: four identical </> was the owner's complaint, 11-09).
++ f.apps.slice(0, 4).forEach((id) => { const a = byId.get(id) || (o.tools || []).find((t) => t.id === id); const cell = h('span'); const g = a ? (a.site || a.icon || f.glyph) : f.glyph; cell.style.setProperty('--ovh-h', String(hueOf(g + id))); cell.append(siteIcon(g, 26)); ic.append(cell); });
el.append(ic, h('span', 'ovh-name', f.name));
return el;
}
@@@ -317,28 +342,49 @@
t.addEventListener('click', (e) => { if (Date.now() < state.suppress && !(e.target.closest && e.target.closest('.ovh-badge'))) { e.preventDefault(); e.stopImmediatePropagation(); } }, true);
}
++ // 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 }); }
function beginDrag(id, e) {
if (state.drag) return;
const ghost = h('div', 'ovh-ghost');
const src = el.querySelector(`.ovh-grid:not(.ovh-recent) [data-id="${CSS.escape(id)}"] .ovh-ic`);
-- if (src) ghost.append(src.cloneNode(true).firstChild || h('span'));
++ if (src) { const c = src.cloneNode(true); c.style.animation = 'none'; ghost.append(c); }
document.body.append(ghost);
state.drag = { id, ghost };
const at = (m) => { ghost.style.left = `${m.clientX}px`; ghost.style.top = `${m.clientY}px`; };
at(e);
const lift = () => { const n = el.querySelector(`.ovh-grid:not(.ovh-recent) [data-id="${CSS.escape(id)}"]`); if (n) n.classList.add('is-lifted'); };
lift();
++ // Near the top or bottom of the menu's list, the list scrolls under the finger (one page of apps can be longer
++ // than the screen); nothing else scrolls while an app is held.
++ const scroller = (() => { let n = el.parentElement; while (n && n !== document.body) { const st = getComputedStyle(n); if (/(auto|scroll)/.test(st.overflowY) && n.scrollHeight > n.clientHeight) return n; n = n.parentElement; } return null; })();
const move = (m) => {
++ if (m.cancelable) m.preventDefault();
++ if (scroller) { const r = scroller.getBoundingClientRect(); if (m.clientY < r.top + 56) scroller.scrollTop -= 12; else if (m.clientY > r.bottom - 56) scroller.scrollTop += 12; }
at(m);
-- const under = document.elementFromPoint(m.clientX, m.clientY);
-- const cell = under && under.closest && under.closest('.ovh-grid:not(.ovh-recent) .ovh-app');
-- if (!cell || !el.contains(cell)) return;
++ // Which app's place the finger is over, by layout (offsets ignore the slide in progress), and only well
++ // inside it (the middle 60%), so the grid never flickers between two places.
++ const grid = el.querySelector('.ovh-grid:not(.ovh-recent)');
++ if (!grid) return;
++ const cell = [...grid.children].find((n) => { const r = n.getBoundingClientRect(); return m.clientX >= r.left && m.clientX < r.right && m.clientY >= r.top && m.clientY < r.bottom; });
++ if (!cell) return;
const to = L.home.indexOf(cell.getAttribute('data-id')), from = L.home.indexOf(id);
if (to < 0 || to === from) return;
++ const cr = cell.getBoundingClientRect();
++ if (Math.abs(m.clientX - (cr.left + cr.width / 2)) > cr.width * 0.35 || Math.abs(m.clientY - (cr.top + cr.height / 2)) > cr.height * 0.4) return;
++ const before = new Map([...grid.children].map((n) => [n.getAttribute('data-id'), n.getBoundingClientRect()]));
L.home.splice(from, 1); L.home.splice(to, 0, id);
-- const grid = el.querySelector('.ovh-grid:not(.ovh-recent)');
grid.textContent = ''; L.home.forEach((x, i) => grid.append(homeTile(x, i)));
icons(); lift();
++ // Each app slides from where it was to where it now is (FLIP), so the grid moves like a phone's.
++ [...grid.children].forEach((n) => {
++ const b = before.get(n.getAttribute('data-id')); if (!b) return;
++ const a = n.getBoundingClientRect(); const dx = b.left - a.left, dy = b.top - a.top;
++ if (!dx && !dy) return;
++ n.style.transition = 'none'; n.style.transform = `translate(${dx}px, ${dy}px)`;
++ requestAnimationFrame(() => { n.style.transition = 'transform .26s cubic-bezier(.2, .9, .3, 1)'; n.style.transform = ''; });
++ });
};
const end = () => {
window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', end); window.removeEventListener('pointercancel', end);
@@@ -475,13 +521,18 @@
// No Actor yet: one invitation, which the person can put away.
try { if (localStorage.getItem(INVITE_KEY) === 'off') return; } catch { /* */ }
const card = h('div', 'ovh-actor'); card.setAttribute('role', 'group'); card.setAttribute('aria-label', 'Your own AI');
-- const face = h('span', 'ovh-face'); face.append(siteIcon('actor', 28));
++ // 11-09, the owner: the line was cut off, the icon small, the buttons plain words: the whole sentence on two lines,
++ // a bigger mark, and two real buttons (beside the words on a wide menu, under them on a phone).
++ card.classList.add('is-invite');
++ const face = h('span', 'ovh-face'); face.append(siteIcon('actor', 30));
const txt = h('span', 'ovh-actor-txt');
txt.append(h('b', '', 'Your own AI'), h('small', '', 'It works for you while you watch: finds work, posts, runs your channel.'));
++ const acts = h('span', 'ovh-acts');
const go = h('a', 'ovh-go', 'Set it up'); go.href = `${base}/you`;
const no = h('button', 'ovh-x', 'Not now'); no.type = 'button';
no.addEventListener('click', () => { try { localStorage.setItem(INVITE_KEY, 'off'); } catch { /* */ } box.textContent = ''; });
-- card.append(face, txt, go, no);
++ acts.append(go, no);
++ card.append(face, txt, acts);
box.append(card);
if (typeof o.mountIcons === 'function') { try { o.mountIcons(box); } catch { /* */ } }
}
ddiff --git a/ov-look.js b/ov-look.js
iindex 12ef5ae..977e131 100644
---- a/ov-look.js
++++ b/ov-look.js
@@@ -1,5 +1,5 @@
/**
-- * openvibe-shared/ov-look.js — the Look panel (plan L42, 4.64.0): change your look from any OpenVibe site, signed in
++ * openvibe-shared/ov-look.js - the Look panel (plan L42, 4.64.0): change your look from any OpenVibe site, signed in
* or not, without leaving it. navbar.js loads it from beside itself the first time someone opens Look (the palette in
* the account menu, or Look in a guest's menu), so it costs nothing on first paint.
*
@@@ -90,6 +90,20 @@
const choice = (id, label, solid) => { const b = el('button', { type: 'button', role: 'radio', 'aria-checked': String(now === id), 'data-look-icons': id }, []); if (solid != null) b.append(sample(solid)); b.append(label); return b; };
panel.append(el('div', { class: 'ovlk-icons' }, [el('b', {}, ['Icons']), el('div', { class: 'ovlk-seg', role: 'radiogroup', 'aria-label': 'Icons' }, [choice('auto', 'Auto', null), choice('line', 'Line', false), choice('solid', 'Solid', true)])]));
}
++ // Text size and motion live here, with the theme (11-09, the owner: "the typography and the Calm button... should
++ // just be in the settings... keep day and night"): the account menu keeps only light, dark and auto.
++ if (L.display && L.display.get && L.display.options) {
++ const d = L.display.get();
++ const row = (key, label, items) => el('div', { class: 'ovlk-icons' }, [el('b', {}, [label]), el('div', { class: 'ovlk-seg', role: 'radiogroup', 'aria-label': label },
++ items.filter(([v]) => (L.display.options[key] || []).map(String).includes(String(v))).map(([v, t]) => el('button', { type: 'button', role: 'radio', 'aria-checked': String(String(d[key]) === String(v)), 'data-look-set': `${key}:${v}` }, [t])))]);
++ if (L.scheme && L.scheme.get) {
++ const sch = L.scheme.get();
++ panel.append(el('div', { class: 'ovlk-icons' }, [el('b', {}, ['Appearance']), el('div', { class: 'ovlk-seg', role: 'radiogroup', 'aria-label': 'Appearance' },
++ [['light', 'Light'], ['dark', 'Dark'], ['auto', 'Auto']].map(([v, t]) => el('button', { type: 'button', role: 'radio', 'aria-checked': String(sch === v), 'data-look-scheme': v }, [t])))]));
++ }
++ panel.append(row('text', 'Text size', [[100, 'Default'], [112, 'Large'], [125, 'Largest']]));
++ panel.append(row('motion', 'Motion', [['auto', 'Animated'], ['reduced', 'Calm']]));
++ }
const signedIn = opts.signedIn != null ? !!opts.signedIn : /(?:^|; )ov_sso_hint=account/.test(document.cookie || '') || !!(root.localStorage && (() => { try { return root.localStorage.getItem('ov_token'); } catch (e) { return null; } })());
const named = showing ? showing.name : ((PICKS.find(([id]) => id === cur) || [0, cur])[1]);
const why = el('div', { class: 'ovlk-why' }, [`${named} \u00b7 ${signedIn ? 'saved to your account' : 'saved in this browser'}`]);
@@@ -128,6 +142,10 @@
if (sh) { e.stopPropagation(); return share(sh); }
const ic = e.target.closest && e.target.closest('[data-look-icons]');
if (ic) { e.stopPropagation(); L.display.set({ icons: ic.getAttribute('data-look-icons') }); return draw(box, opts); }
++ const sc = e.target.closest && e.target.closest('[data-look-scheme]');
++ if (sc && L.scheme) { e.stopPropagation(); L.scheme.set(sc.getAttribute('data-look-scheme')); return draw(box, opts); }
++ const ds = e.target.closest && e.target.closest('[data-look-set]');
++ if (ds) { e.stopPropagation(); const [k, v] = ds.getAttribute('data-look-set').split(':'); L.display.set({ [k]: /^\d+$/.test(v) ? Number(v) : v }); return draw(box, opts); }
const s = e.target.closest && e.target.closest('[data-look-season]');
if (s) { e.stopPropagation(); L.display.set({ seasonal: s.getAttribute('aria-checked') === 'true' ? 'off' : 'on' }); return draw(box, opts); }
const b = e.target.closest && e.target.closest('[data-look]'); if (!b) return;
ddiff --git a/ov-mark.js b/ov-mark.js
iindex f41dc56..917b87e 100644
---- a/ov-mark.js
++++ b/ov-mark.js
@@@ -186,8 +186,8 @@ html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:
@keyframes ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}}
@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}
@keyframes ovmFall{0%{opacity:0;transform:none}12%{opacity:1}72%{opacity:1;transform:translate(var(--x),var(--y)) rotate(var(--r))}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}
--@media (prefers-reduced-motion:reduce){.ov-mark *{animation:none!important}.ov-mark,.ov-mark *,.ov-mark::before{transition:none!important}.ovm-fall{display:none}}
--html[data-ov-motion=reduced] .ov-mark *{animation:none!important}
++@media (prefers-reduced-motion:reduce){.ov-mark *{animation-iteration-count:1!important}.ov-mark::before{transition:none!important}.ovm-fall{display:none}}
++html[data-ov-motion=reduced] .ov-mark *{animation-iteration-count:1!important}
.ov-busy{cursor:progress;pointer-events:none}
.ov-busy-mark{display:inline-block;vertical-align:-.2em;margin-right:.45em;flex:none}
`;
@@@ -259,13 +259,14 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}
const svgEl = el.querySelector('svg');
if (!el.isConnected || !svgEl) { forget(el); return; }
const now = Date.now();
-- const may = !reduced() && !el.hasAttribute('data-static') && !document.hidden && st.onScreen === true;
-- if (may && !st.introEnd) { st.introEnd = introDeadline(now); if (season()) setTimeout(() => fallOnce(el), 900); }
++ // Calm (the Motion setting, or the device asking for less) is gentle, not still: a state still animates, once.
++ const may = !el.hasAttribute('data-static') && !document.hidden && st.onScreen === true;
++ if (may && !reduced() && !st.introEnd) { st.introEnd = introDeadline(now); if (season()) setTimeout(() => fallOnce(el), 900); }
const s = el.getAttribute('data-state');
// Anything the mark is showing keeps it moving: a state, a page on its way, a known progress.
const showing = (s && s !== 'idle') || el.hasAttribute('data-nav') || el.hasAttribute('data-progress');
const intro = now < st.introEnd;
-- const run = may && (intro || st.hover || st.focus || now < st.lingerEnd || showing);
++ const run = may && (showing || (!reduced() && (intro || st.hover || st.focus || now < st.lingerEnd)));
if (run !== el.hasAttribute('data-ovm-run')) { if (run) el.setAttribute('data-ovm-run', ''); else el.removeAttribute('data-ovm-run'); }
// The intro's pop and growth are on only inside the intro (paused with the rest while the mark cannot move).
if ((may && intro) !== el.hasAttribute('data-ovm-intro') && (may || !intro)) { if (may && intro) el.setAttribute('data-ovm-intro', ''); else el.removeAttribute('data-ovm-intro'); }
ddiff --git a/ov-thread.js b/ov-thread.js
iindex f1bee44..68879a9 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -333,6 +333,8 @@
function close() { if (d.open) { d.open = false; } }
d.addEventListener('toggle', function () {
lock(d.open);
++ // On a computer the panel grows out of its button (the navbar's shared motion); a phone's sheet rises.
++ if (d.open && !phone() && root.OpenVibeNavbar && root.OpenVibeNavbar.pop) { var pnl = q(d, '.ovt-opts-panel'); if (pnl) root.OpenVibeNavbar.pop(pnl, q(d, 'summary')); }
if (d.open) { var f = q(d, '.ovt-opts-body select, .ovt-opts-body input, .ovt-opts-body button'); if (f && !phone()) f.focus({ preventScroll: true }); }
});
document.addEventListener('pointerdown', function (e) { if (d.open && !d.contains(e.target)) close(); }, true);
@@@ -376,6 +378,7 @@
if (d.__ovt) return; d.__ovt = true;
document.addEventListener('pointerdown', function (e) { if (d.open && !d.contains(e.target)) d.open = false; }, true);
d.addEventListener('change', function () { d.open = false; });
++ d.addEventListener('toggle', function () { if (d.open && root.OpenVibeNavbar && root.OpenVibeNavbar.pop) { var pp = q(d, '.ovt-attach-pop'); if (pp) root.OpenVibeNavbar.pop(pp, q(d, 'summary')); } });
d.addEventListener('keydown', function (e) { if (e.key === 'Escape' && d.open) { e.stopPropagation(); d.open = false; var s = q(d, 'summary'); if (s) s.focus(); } });
});
ddiff --git a/package-lock.json b/package-lock.json
iindex b2415a3..f6c6bf4 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.160.0",
++ "version": "4.161.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.160.0",
++ "version": "4.161.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex ed13927..d9057e1 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.160.0",
++ "version": "4.161.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/room.css b/room.css
iindex a9a5614..3f88314 100644
---- a/room.css
++++ b/room.css
@@@ -241,3 +241,15 @@ html.ov-motion [data-ov-reveal].is-in{opacity:1;transform:none}
@media (prefers-reduced-motion:reduce){.sc-rm-glow,.sc-rm-orbit,.sc-room-live::before{animation:none}html.ov-motion [data-ov-reveal]{opacity:1;transform:none;transition:none}}
html[data-ov-motion=reduced] .sc-rm-glow,html[data-ov-motion=reduced] .sc-rm-orbit,html[data-ov-motion=reduced] .sc-room-live::before{animation:none}
html[data-ov-motion=reduced].ov-motion [data-ov-reveal]{opacity:1;transform:none;transition:none}
++
++/* Sideways strips say they scroll (kit-web.js sets the classes): the edge fades where more is hidden. */
++:where(.ovk-rail-track, [data-ovk-scroll]) { --ovk-fade: 28px; }
++:where(.ovk-rail-track, [data-ovk-scroll]).is-more-end { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - var(--ovk-fade)), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - var(--ovk-fade)), transparent); }
++:where(.ovk-rail-track, [data-ovk-scroll]).is-more-start { -webkit-mask-image: linear-gradient(90deg, transparent, #000 var(--ovk-fade)); mask-image: linear-gradient(90deg, transparent, #000 var(--ovk-fade)); }
++:where(.ovk-rail-track, [data-ovk-scroll]).is-more-start.is-more-end { -webkit-mask-image: linear-gradient(90deg, transparent, #000 var(--ovk-fade), #000 calc(100% - var(--ovk-fade)), transparent); mask-image: linear-gradient(90deg, transparent, #000 var(--ovk-fade), #000 calc(100% - var(--ovk-fade)), transparent); }
++[data-ovk-scroll] { overflow-x: auto; scrollbar-width: none; overscroll-behavior-x: contain; }
++[data-ovk-scroll]::-webkit-scrollbar { display: none; }
++.ovk-scroll-bar { display: block; width: 56px; height: 4px; margin: 10px auto 0; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 12%, transparent); overflow: hidden; }
++.ovk-scroll-bar[hidden] { display: none; }
++.ovk-scroll-bar > i { display: block; height: 100%; border-radius: 4px; background: var(--accent, #3b82f6); transition: transform .12s linear; }
++@media (prefers-reduced-motion: reduce) { .ovk-scroll-bar > i { transition: none; } }
ddiff --git a/search.js b/search.js
iindex 33e79ff..0fbe1c3 100644
---- a/search.js
++++ b/search.js
@@@ -67,8 +67,15 @@
// of sites"). The first is labelled with this site's own name.
const TABS = [['here', ''], ['feed', 'Feed'], ['you', 'You']];
const TAB_KEY = 'ovs-tab';
-- const savedTab = () => { try { const v = localStorage.getItem(TAB_KEY); return TABS.some(([id]) => id === v) ? v : null; } catch { return null; } };
-- const saveTab = (v) => { try { localStorage.setItem(TAB_KEY, v); } catch { /* private window */ } };
++ // The tab is remembered per site (11-09, the owner: on a new site the menu opened on You, "confusing"): another site
++ // opens on its own tab first; the same site opens where the person left it.
++ const savedTab = () => { try { const [v, host] = String(localStorage.getItem(TAB_KEY) || '').split('|'); if (host && host !== location.hostname) return null; return TABS.some(([id]) => id === v) ? v : null; } catch { return null; } };
++ const saveTab = (v) => { try { localStorage.setItem(TAB_KEY, `${v}|${location.hostname}`); } catch { /* private window */ } };
++ // When the Feed was last looked at, for its "new since" count (11-09, the owner: "if there's anything new in the feed
++ // since last time you've looked… a badge").
++ const FEED_SEEN = 'ovs-feed-seen';
++ const feedSeen = () => { try { return Number(localStorage.getItem(FEED_SEEN)) || 0; } catch { return 0; } };
++ const markFeedSeen = () => { try { localStorage.setItem(FEED_SEEN, String(Date.now())); } catch { /* */ } };
let el = null, input = null, ghost = null, listEl = null, tabsEl = null, ctx = {}, items = [], active = -1, inflight = null, timer = null, lastFocus = null;
let tab = 'here', mine = null, catalog = null, completion = '';
// The results view (4.29.0): Enter shows the Search Fabric's answer here, by scope, instead of leaving the page.
@@@ -207,6 +214,14 @@
.ovs-starttabs { gap: 6px; padding: 2px 12px 10px; }
.ovs-starttabs .ovs-tab { flex: 1 1 0; min-width: 0; display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 38px; padding: 0 10px; border-radius: 12px; font-size: 14px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); }
.ovs-starttabs .ovs-tab span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovs-starttabs .ovs-tab { height: 42px; gap: 8px; transition: background-color .18s, color .18s; }
++.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; }
++.ovs-tab-av { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; display: block; box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--text-primary, #e6edf7) 18%, transparent); }
++.ovs-tab[aria-selected="true"] .ovs-tab-av { box-shadow: 0 0 0 2px var(--accent, #3b82f6); }
++.ovs-tab-on { position: absolute; right: -2px; bottom: -2px; width: 8px; height: 8px; border-radius: 50%; background: var(--success, #22c55e); box-shadow: 0 0 0 2px var(--bg-secondary, #111826); }
++.ovs-tabnew { flex: none; min-width: 18px; height: 18px; padding: 0 5px; box-sizing: border-box; border-radius: 999px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: 700 11px/18px Inter, system-ui, sans-serif; text-align: center; }
.ovs-tabdot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--live-red, #ef4444); box-shadow: 0 0 0 3px color-mix(in srgb, var(--live-red, #ef4444) 22%, transparent); }
.ovs-grid.is-mini { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2px; padding: 0 2px 4px; }
.ovs-grid.is-mini .ovs-tile { gap: 8px; min-height: 40px; padding: 4px 8px; border-radius: 10px; }
@@@ -406,7 +421,7 @@
el.appendChild(orbEl);
tabsEl.addEventListener('click', (e) => {
const b = e.target.closest && e.target.closest('[data-tab]'); if (!b) return;
-- tab = b.getAttribute('data-tab'); saveTab(tab); render(''); listEl.scrollTop = 0;
++ tab = b.getAttribute('data-tab'); saveTab(tab); if (tab === 'feed') markFeedSeen(); render(''); listEl.scrollTop = 0;
let touch = false; try { touch = root.matchMedia('(pointer: coarse)').matches; } catch { /* */ }
if (touch) b.focus(); else input.focus();
});
@@@ -940,21 +955,52 @@
// The network's flagships, first in the full list (the owner, 11-07: "show our most coolest products front facing").
const FLAGSHIPS = ['live', 'media-hub', 'codes', 'design', 'space', 'games', 'chat', 'tools'];
-- /** The tab strip under the search field: this site's name, Feed (a red dot while anyone is live), You. */
++ /** What is new in the Feed since it was last looked at: the network's real changes after that moment (0 on a first look). */
++ function feedNew() {
++ const seen = feedSeen();
++ if (!seen || !Array.isArray(changesData)) return 0;
++ let n = 0;
++ for (const e of changesData) { const t = Date.parse(e.deployed_at || e.committed_at || ''); if (t > seen && !HOUSEKEEPING.test(String(e.subject || ''))) n++; }
++ return n;
++ }
++ const TAB_SVG = {
++ feed: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="4" width="16" height="7" rx="2"/><path d="M4 15.5h16M4 19.5h10"/></svg>',
++ you: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="8.5" r="3.6"/><path d="M5 19.5c1.2-3.3 3.9-5 7-5s5.8 1.7 7 5"/></svg>',
++ };
++ /**
++ * The tab strip under the search field (11-09, the owner: "these icons should be visually improved… your avatar…
++ * the feed content aware… each site its own icon"): this site's own mark and name, Feed with a red dot while anyone is
++ * live or a count of what is new since you last looked, and You with your picture and an online dot.
++ */
function paintTabs() {
tabsEl.hidden = false;
tabsEl.setAttribute('role', 'tablist');
tabsEl.setAttribute('aria-label', 'Menu');
tabsEl.textContent = '';
const live = pulseData && Number(pulseData.live_now) > 0;
++ const fresh = tab === 'feed' ? 0 : feedNew();
++ const me = ctx.token && 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 */ }
for (const [id, label] of TABS) {
const b = document.createElement('button');
b.type = 'button'; b.className = 'ovs-tab'; b.setAttribute('role', 'tab'); b.setAttribute('data-tab', id);
b.setAttribute('aria-selected', tab === id ? 'true' : 'false');
++ const ic = document.createElement('span'); ic.className = 'ovs-tab-ic';
++ if (id === 'here' && siteId) { const g = document.createElement('span'); g.className = 'ov-icon'; g.setAttribute('data-icon', siteId); g.setAttribute('data-size', '20'); g.setAttribute('data-fx', 'none'); ic.appendChild(g); }
++ else if (id === 'you' && me && me.avatar_url && /^https:\/\//.test(me.avatar_url)) {
++ const img = document.createElement('img'); img.src = me.avatar_url; img.alt = ''; img.width = 22; img.height = 22; img.decoding = 'async'; img.className = 'ovs-tab-av';
++ img.onerror = () => { ic.innerHTML = TAB_SVG.you; };
++ ic.appendChild(img);
++ const on = document.createElement('i'); on.className = 'ovs-tab-on'; on.setAttribute('aria-hidden', 'true'); ic.appendChild(on);
++ } else if (TAB_SVG[id]) ic.innerHTML = TAB_SVG[id];
++ if (ic.firstChild) b.appendChild(ic);
const t = document.createElement('span'); t.textContent = id === 'here' ? here() : label; b.appendChild(t);
if (id === 'feed' && live) { const d = document.createElement('i'); d.className = 'ovs-tabdot'; d.setAttribute('aria-label', 'live now'); b.appendChild(d); }
++ else if (id === 'feed' && fresh) { const n = document.createElement('b'); n.className = 'ovs-tabnew'; n.textContent = fresh > 9 ? '9+' : String(fresh); n.setAttribute('aria-label', `${fresh} new since you last looked`); b.appendChild(n); }
tabsEl.appendChild(b);
}
++ if (typeof ctx.icons === 'function') { try { ctx.icons(tabsEl); } catch { /* the glyphs come with ov-icons.js */ } }
}
/** What every tab needs: the sites, this one, and the person's ranking of them. */
@@@ -1107,6 +1153,17 @@
return groups;
}
++ /**
++ * A page they were on (11-09, the owner: "more like live data… smart and aware… better info, looks nicer"): the site's
++ * own icon, the title without the site's name glued on, when, and "Live now" for a channel that is live this minute.
++ */
++ function backRow(x) {
++ const title = String(x.title || '').replace(/\s*[·|–—-]\s*(?:OpenVibe\.\w+|open\.\w+)\s*$/i, '').replace(/^OpenVibe\.\w+:\s*/i, '').trim() || x.title;
++ const live = pulseData && Array.isArray(pulseData.live) && pulseData.live.some((c) => c && c.url && safeUrl(c.url) && hostOf(c.url) === hostOf(x.url) && String(c.url).replace(/\/$/, '').toLowerCase() === String(x.url).replace(/[?#].*$/, '').replace(/\/$/, '').toLowerCase());
++ const svc = x.service === 'media' ? 'media-hub' : x.service;
++ return { title: title.slice(0, 90), meta: [live ? 'Live now' : '', x.site, ago(x.at)].filter(Boolean).join(' · '), url: x.url, site: svc && /^[a-z0-9-]{1,30}$/.test(svc) ? svc : null, icon: 'back', kind: 'history', tag: live ? 'Live' : '' };
++ }
++
/** You: their workspace, their places, the pages they were on, tools, their searches, and the display controls. */
function youGroups() {
const { take, ranked } = basis();
@@@ -1114,8 +1171,7 @@
if (homeLib()) {
groups.push({ as: 'home', items: [], section: 'home' });
const seenH = new Set();
-- const backH = mine ? mine.items.filter((x) => { if (seenH.has(x.url)) return false; seenH.add(x.url); return true; }).slice(0, 5)
-- .map((x) => ({ title: x.title, meta: [x.site, ago(x.at)].filter(Boolean).join(' · '), url: x.url, icon: 'back', kind: 'history' })) : [];
++ const backH = mine ? mine.items.filter((x) => { if (seenH.has(x.url)) return false; seenH.add(x.url); return true; }).slice(0, 5).map(backRow) : [];
groups.push({ title: 'Jump back in', items: backH, section: 'back' });
if (typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });
return groups;
@@@ -1126,8 +1182,7 @@
const dockPool = yours ? ranked : ranked.filter((x) => x.group !== 'build' || useOf(x.id));
groups.push({ title: yours ? 'Your places' : 'Popular on OpenVibe', as: 'apps', items: take(dockPool, 8).map((x) => siteItem(x)), section: 'places' });
const seen = new Set();
-- const back = mine ? mine.items.filter((h) => { if (seen.has(h.url)) return false; seen.add(h.url); return true; }).slice(0, 5)
-- .map((h) => ({ title: h.title, meta: [h.site, ago(h.at)].filter(Boolean).join(' · '), url: h.url, icon: 'back', kind: 'history' })) : [];
++ const back = mine ? mine.items.filter((h) => { if (seen.has(h.url)) return false; seen.add(h.url); return true; }).slice(0, 5).map(backRow) : [];
groups.push({ title: 'Jump back in', items: back, section: 'back' });
const tools = yourTools().concat(quickTools()).filter((t, i, a) => a.findIndex((x) => x.url === t.url) === i).slice(0, 6);
groups.push({ title: 'Tools', as: 'chips', more: { label: 'All tools', href: 'https://openvibe.tools/' }, items: tools, section: 'tools' });
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex cf0008a..931d1e2 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 = 17920; // 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 = 18432; // 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-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 329bbfe..3f057fd 100644
---- a/test/menu-home-screen-chrome.test.js
++++ b/test/menu-home-screen-chrome.test.js
@@@ -141,7 +141,7 @@ const server = http.createServer((req, res) => {
// The Tools folder: tools with their own icons, and the count of all.
await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="folder:tools"]').click(), true`);
-- assert.ok(await until(p, `!!document.querySelector('.ovh-head b') && document.querySelector('.ovh-head b').textContent === 'Tools'`), 'the folder opened');
++ assert.ok(await until(p, `!!document.querySelector('.ovh-head b') && document.querySelector('.ovh-head b').textContent === 'Quick tools'`), 'the folder opened');
const folder = await p.evaluate(`({ n: document.querySelectorAll('.ovh-grid .ovh-app').length, icons: [...document.querySelectorAll('.ovh-grid .ovh-app .ov-icon')].slice(0, 4).map((i) => i.getAttribute('data-icon')), more: (document.querySelector('a.ovh-all') || {}).textContent })`);
assert.strictEqual(folder.n, 24, 'the folder shows two dozen tools');
assert.deepStrictEqual(folder.icons, ['pdf', 'image', 'qr', 'audio'], 'each tool wears its own icon');
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex 9041355..3376d3f 100644
---- a/test/nav-icons.test.js
++++ b/test/nav-icons.test.js
@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
--const NAVBAR_BROTLI_BUDGET = 47 * 1024; // 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
++const NAVBAR_BROTLI_BUDGET = 47.5 * 1024; // 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
// ── size budget and generated block ──────────────────────────
{
ddiff --git a/test/navbar-brand.test.js b/test/navbar-brand.test.js
iindex 8f0713c..1b9a9b6 100644
---- a/test/navbar-brand.test.js
++++ b/test/navbar-brand.test.js
@@@ -265,7 +265,7 @@ function load(hostname, opts = {}) {
// 4.23.0 (the owner: "a plus button… better use of icons… compactness"): + and the gear beside the name, your places
// as a grid of icons, and one row at the foot for light/dark, text size, motion and themes; Sign out by the gear.
assert.ok(/class="ud-hbtn add-account" id="openvibe-add-account"[^>]*aria-label="Add another account"/.test(dd.innerHTML), 'Add another account is a + beside the name');
-- assert.ok(dd.innerHTML.includes('aria-label="Account &amp; settings"') && dd.innerHTML.includes('aria-label="Look"') && dd.innerHTML.includes('data-ov-look-open') && /id="openvibe-logout-btn"[^>]*aria-label="Sign out"/.test(dd.innerHTML), 'the gear, Look (4.64.0: opens in place) and Sign out are icon buttons');
++ assert.ok(dd.innerHTML.includes('aria-label="Account &amp; settings"') && dd.innerHTML.includes('aria-label="Display settings"') && dd.innerHTML.includes('data-ov-look-open') && /id="openvibe-logout-btn"[^>]*aria-label="Sign out"/.test(dd.innerHTML), 'the gear, Look (4.64.0: opens in place) and Sign out are icon buttons');
assert.ok(/<div class="ud-grid">/.test(dd.innerHTML) && /class="ud-tile"[^>]*data-menu-id="mp"/.test(dd.innerHTML), 'the places are a grid of tiles, the site\'s own first');
assert.ok(!dd.innerHTML.includes('Recently used'), 'the places you went are the search menu\'s');
assert.ok(!dd.innerHTML.includes('Across OpenVibe'), 'the other sites are the launcher\'s and the drawer\'s, not the account menu\'s');
ddiff --git a/test/ov-mark.test.js b/test/ov-mark.test.js
iindex a761a33..ec94def 100644
---- a/test/ov-mark.test.js
++++ b/test/ov-mark.test.js
@@@ -223,7 +223,12 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
const u = setup();
u.io.fire(u.mark, true);
u.document.documentElement.setAttribute('data-ov-motion', 'reduced'); await u.flush();
-- assert.strictEqual(u.running(), false, 'html[data-ov-motion=reduced]');
++ assert.strictEqual(u.running(), false, 'html[data-ov-motion=reduced]: no intro, no hover play');
++ // Calm is gentle, not still (the owner, 11-09: "the animations need to work even on calm mode"): a state still
++ // animates (its loops play once, by CSS).
++ u.mark.setAttribute('data-state', 'busy'); await u.flush();
++ assert.strictEqual(u.running(), true, 'a state still animates under Calm');
++ u.mark.removeAttribute('data-state'); await u.flush();
u.document.documentElement.removeAttribute('data-ov-motion'); await u.flush();
assert.strictEqual(u.running(), true, 'back inside the intro');
ddiff --git a/test/rails-scroll-chrome.test.js b/test/rails-scroll-chrome.test.js
nnew file mode 100644
iindex 0000000..7c0ca83
---- /dev/null
++++ b/test/rails-scroll-chrome.test.js
@@@ -0,0 +1,66 @@
++'use strict';
++// Sideways strips say they scroll (11-09, the owner: "it's not very apparent you can horizontal scroll… a little bounce…
++// or auto scroll until you tap"), in a real headless Chrome: a kit rail's track and any [data-ovk-scroll] get edge fades
++// where more is hidden and a position bar under them; data-ovk-scroll="drift" moves on its own while in view and stops for
++// good at the first touch; a strip with nothing hidden shows neither. Skipped without Chrome.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('rails-scroll-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++const ROOT = path.join(__dirname, '..');
++const chips = (n) => Array.from({ length: n }, (_, i) => `<span style="flex:none;padding:10px 16px;border:1px solid #444;border-radius:999px">Chip number ${i + 1}</span>`).join('');
++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Rails</title><link rel="icon" href="data:,">
++<link rel="stylesheet" href="/kit.css"><link rel="stylesheet" href="/room.css"></head><body style="margin:0;padding:16px">
++<div id="plain" data-ovk-scroll style="display:flex;gap:8px">${chips(12)}</div>
++<div id="drift" data-ovk-scroll="drift" style="display:flex;gap:8px;margin-top:24px">${chips(12)}</div>
++<div id="short" data-ovk-scroll style="display:flex;gap:8px;margin-top:24px">${chips(1)}</div>
++<script src="/kit-web.js"></script></body></html>`;
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE); }
++ if (u.pathname === '/kit.css' || u.pathname === '/room.css' || u.pathname === '/kit-web.js') { res.writeHead(200, { 'content-type': u.pathname.endsWith('.css') ? 'text/css' : 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, u.pathname.slice(1)))); }
++ res.writeHead(404); return res.end();
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-rails-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ try {
++ const context = await browser.newContext();
++ const p = await h.openPage(browser, context, { width: 390, height: 844 });
++ await p.goto(`${base}/`, { maxMs: 5000 });
++ await wait(300);
++ const first = await p.evaluate(`({ endFade: document.getElementById('plain').classList.contains('is-more-end'), startFade: document.getElementById('plain').classList.contains('is-more-start'),
++ bar: !document.querySelector('#plain + .ovk-scroll-bar').hidden, shortBar: document.querySelector('#short + .ovk-scroll-bar').hidden, shortFade: document.getElementById('short').classList.contains('is-more-end') })`);
++ assert.ok(first.endFade && !first.startFade, `the far edge fades where more is hidden (${JSON.stringify(first)})`);
++ assert.ok(first.bar, 'a position bar under a strip with more');
++ assert.ok(first.shortBar && !first.shortFade, 'a strip with nothing hidden shows neither');
++ await p.evaluate("document.getElementById('plain').scrollLeft = 99999");
++ await wait(150);
++ const end = await p.evaluate(`({ endFade: document.getElementById('plain').classList.contains('is-more-end'), startFade: document.getElementById('plain').classList.contains('is-more-start'), thumb: document.querySelector('#plain + .ovk-scroll-bar > i').style.transform })`);
++ assert.ok(!end.endFade && end.startFade, 'at the end only the near edge fades');
++ assert.ok(/translateX\((?!0%)/.test(end.thumb), `the bar's thumb moved (${end.thumb})`);
++ // Drift: it moves on its own, and a touch stops it for good.
++ // A slow runner (CI) gets more time; a browser that asks for less motion is not asked to drift.
++ const calm = await p.evaluate("matchMedia('(prefers-reduced-motion: reduce)').matches");
++ const a = await p.evaluate("document.getElementById('drift').scrollLeft");
++ let b = a;
++ for (let i = 0; i < 30 && !calm && b <= a; i++) { await wait(100); b = await p.evaluate("document.getElementById('drift').scrollLeft"); }
++ assert.ok(calm || b > a, `a drifting strip moves by itself (${a} → ${b})`);
++ await p.evaluate("document.getElementById('drift').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))");
++ const c = await p.evaluate("document.getElementById('drift').scrollLeft");
++ await wait(700);
++ assert.strictEqual(await p.evaluate("document.getElementById('drift').scrollLeft"), c, 'and stops at the first touch');
++ console.log('rails-scroll-chrome: edge fades, the position bar, drift that stops at a touch, nothing on a short strip');
++ } finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
++})().catch((e) => { console.error(e); process.exit(1); });