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)
Changes
@@@ -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 arerebuilt; 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@@@ -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 deployedconsumer 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` namesthe tag it pins.@@@ -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)",@@@ -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();@@@ -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;@@@ -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. */@@@ -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 { /* */ } }}@@@ -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;@@@ -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'); }@@@ -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(); } });});@@@ -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",@@@ -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": {@@@ -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; } }@@@ -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' });@@@ -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 ──────────────────────────────────────────────@@@ -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');@@@ -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 ──────────────────────────{@@@ -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 & 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 & 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');@@@ -223,7 +223,12 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, rooconst 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');nnew file mode 100644@@@ -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); });
@@ -1,5 +1,25 @@
@@ -1,5 +1,25 @@
# Changelog
# 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
## 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
- **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):
@@ -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
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.
rebuilt; existing code points are unchanged.
-## Unreleased
-
## 4.113.0 — 2026-11-06
## 4.113.0 — 2026-11-06
- **Bot API calls can be counted at ingest, not stored row by row.** `AnalyticsTrackerPg` takes
- **Bot API calls can be counted at ingest, not stored row by row.** `AnalyticsTrackerPg` takes
@@ -2,7 +2,7 @@
@@ -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.
> 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
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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.160.0",
+ "version": "4.161.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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)",
@@ -32,6 +32,9 @@
@@ -32,6 +32,9 @@
const W = [320, 720], H = [360, 900], DEF = { width: 420, height: 640 };
const W = [320, 720], H = [360, 900], DEF = { width: 420, height: 640 };
const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
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 = {
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"/>',
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"/>',
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 @@
@@ -43,7 +46,8 @@
back: '<path d="M14.5 6.5L9 12l5.5 5.5"/>',
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"/>',
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"/>',
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"/>',
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.
// Vibe first (L48 V2): the person's AI, on every site.
@@ -173,7 +177,20 @@
@@ -173,7 +177,20 @@
.ovdk-acts { display: flex; gap: 2px; margin-left: auto; }
.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 { 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: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;
.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; }
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; }
.ovdk-more[hidden] { display: none; }
@@ -316,7 +333,7 @@
@@ -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-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">
<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="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>
<div class="ovdk-more" role="menu" aria-label="More" hidden>
<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>
<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 @@
@@ -918,6 +935,8 @@
paintPop();
paintPop();
el.hidden = false;
el.hidden = false;
el.classList.remove('is-in'); void el.offsetWidth; el.classList.add('is-in');
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');
if (phone()) { el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open'); } else el.removeAttribute('aria-modal');
setTab(want, false);
setTab(want, false);
readUnread();
readUnread();
@@ -177,6 +177,66 @@
@@ -177,6 +177,66 @@
if (typeof ResizeObserver === 'function') new ResizeObserver(update).observe(track); else window.addEventListener('resize', update);
if (typeof ResizeObserver === 'function') new ResizeObserver(update).observe(track); else window.addEventListener('resize', update);
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.
// Share: the system sheet where there is one, else the link copied, said in the item itself.
all('[data-ovk-share]').forEach((a) => {
all('[data-ovk-share]').forEach((a) => {
if (!once(a, 'ovkShare')) return;
if (!once(a, 'ovkShare')) return;
@@ -154,6 +154,18 @@
@@ -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[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-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-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 { 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; }
.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. */
/* 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 @@
@@ -386,6 +398,14 @@
overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; scrollbar-width: thin;
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; } }
@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 {
.openvibe-navbar-dropdown-header {
padding: 12px 10px 12px 14px; border-bottom: 1px solid var(--border, #333340);
padding: 12px 10px 12px 14px; border-bottom: 1px solid var(--border, #333340);
@@ -447,6 +467,8 @@
@@ -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(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(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: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. */
/* 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; }
.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. */
/* 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 @@
@@ -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 { 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 { 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-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 .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 { margin: 0; font-size: 11px; font-weight: 700; color: inherit; }
.openvibe-navbar-dropdown-menu .ud-foot .ud-ib .ud-val:empty { display: none; }
.openvibe-navbar-dropdown-menu .ud-foot .ud-ib .ud-val:empty { display: none; }
@@ -525,16 +548,15 @@
@@ -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-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-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-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-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-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-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-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-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-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-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-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-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-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"/>', ''],
'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 @@
@@ -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.
// 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 = {
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)
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// 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
// 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
// (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">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -941,6 +963,20 @@
@@ -941,6 +963,20 @@
later();
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.
// A site's buttons: nav-slots.js draws them, loaded on first use; each render paints them again.
function paintSlots() {
function paintSlots() {
if (!_navEl || typeof document === 'undefined') return;
if (!_navEl || typeof document === 'undefined') return;
@@ -1212,6 +1248,7 @@
@@ -1212,6 +1248,7 @@
const box = a.parentElement && a.parentElement.parentElement && a.parentElement.parentElement.querySelector('.ud-look') || scope.querySelector('.ud-look'); if (!box) return;
const box = a.parentElement && a.parentElement.parentElement && a.parentElement.parentElement.querySelector('.ud-look') || scope.querySelector('.ud-look'); if (!box) return;
e.preventDefault(); e.stopPropagation();
e.preventDefault(); e.stopPropagation();
const open = box.hidden; box.hidden = !open; a.setAttribute('aria-expanded', String(open));
const open = box.hidden; box.hidden = !open; a.setAttribute('aria-expanded', String(open));
+ if (open) popFrom(box, a);
if (!open || box.__ovlk) return;
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; };
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();
if (root.OVLook) return go();
@@ -1271,24 +1308,17 @@
@@ -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('');
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) {
function bindDisplayControls(box) {
const L = root.OpenVibeThemeLoader; if (!box || !L || !L.display) return;
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()); }
if (typeof root.addEventListener === 'function') { root.addEventListener('ov:theme', paint, onDocument()); root.addEventListener('ov:display', paint, onDocument()); }
paint();
paint();
+ box.hidden = false;
}
}
// ── Notification bell (mounted by the navbar itself) ─────
// ── Notification bell (mounted by the navbar itself) ─────
@@ -2120,6 +2150,8 @@
@@ -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,
!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,
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,
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));
].filter(Boolean));
const ranked = byUse(places), grid = ranked.slice(0, 8), more = ranked.slice(8);
const ranked = byUse(places), grid = ranked.slice(0, 8), more = ranked.slice(8);
const others = otherAccounts.filter(a => switcher || !a.is_anon);
const others = otherAccounts.filter(a => switcher || !a.is_anon);
@@ -2149,9 +2181,7 @@
@@ -2149,9 +2181,7 @@
${sectionsHTML(menuCfg.sections)}
${sectionsHTML(menuCfg.sections)}
<div class="ud-foot">
<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>
<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>
<div class="ud-look" hidden></div>
<div class="ud-look" hidden></div>
</div>
</div>
@@ -2170,7 +2200,8 @@
@@ -2170,7 +2200,8 @@
const avatarBtn = nav.querySelector('#openvibe-avatar-btn'); let sel = null;
const avatarBtn = nav.querySelector('#openvibe-avatar-btn'); let sel = null;
avatarBtn.addEventListener('pointerdown', () => { sel = selectedText(); });
avatarBtn.addEventListener('pointerdown', () => { sel = selectedText(); });
avatarBtn.addEventListener('click', () => {
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;
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); }
if (dropdown.classList.contains('open')) { loadWallet(dropdown); dropdown.scrollTop = 0; loadOvIcons(dropdown); }
});
});
@@ -2607,6 +2638,8 @@
@@ -2607,6 +2638,8 @@
/** The Dock on a tab, signed in only; opts.task points at that step (see openDock above). */
/** 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); },
openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); },
+ pop: popFrom,
+ unpop,
askAgent(text) { askAgent(text); },
askAgent(text) { askAgent(text); },
/** Get the bell mount point for OpenVibeNotifications. */
/** Get the bell mount point for OpenVibeNotifications. */
@@ -50,7 +50,9 @@
@@ -50,7 +50,9 @@
.ovh-sec button.is-done { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
.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; }
.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: 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-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;
.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; }
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 @@
@@ -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);
.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); }
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; }
.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-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 { 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; }
.ovh-folder .ovh-ic > span { display: grid; place-items: center; width: 18px; height: 18px; }
@@ -70,7 +79,10 @@
@@ -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-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-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.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;
.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); }
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); } }
@keyframes ovh-wiggle { from { transform: rotate(-1.6deg); } to { transform: rotate(1.6deg); } }
@@ -83,7 +95,7 @@
@@ -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-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));
.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; }
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; }
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 { 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; }
.ovh-face img { width: 100%; height: 100%; object-fit: cover; }
@@ -97,6 +109,13 @@
@@ -97,6 +109,13 @@
.ovh-go { flex: none; color: var(--accent, #3b82f6); font-weight: 700; font-size: 13px; }
.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; }
.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; } }
@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-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);
.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)); }
box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); }
@@ -122,7 +141,7 @@
@@ -122,7 +141,7 @@
.ovh-lives { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.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 { 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); }
.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);
document.head.appendChild(st);
}
}
@@ -163,7 +182,7 @@
@@ -163,7 +182,7 @@
function folderDefs(o) {
function folderDefs(o) {
const apps = o.apps || [];
const apps = o.apps || [];
return [
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' },
{ id: 'build', name: 'Build', apps: apps.filter((a) => a.group === 'build').map((a) => a.id), glyph: 'code' },
].filter((f) => f.apps.length);
].filter((f) => f.apps.length);
}
}
@@ -188,12 +207,17 @@
@@ -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');
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;
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 } = {}) {
function appTile(o, a, { tag = 'a', size = 40, small = false } = {}) {
const el = h(tag, 'ovh-app');
const el = h(tag, 'ovh-app');
if (tag === 'a') el.href = safe(a.url) || '#'; else el.type = 'button';
if (tag === 'a') el.href = safe(a.url) || '#'; else el.type = 'button';
el.setAttribute('data-id', a.id);
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');
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));
el.append(ic, h('span', 'ovh-name', a.title));
if (tag === 'a') el.addEventListener('click', () => { if (o.onOpen) o.onOpen(a); });
if (tag === 'a') el.addEventListener('click', () => { if (o.onOpen) o.onOpen(a); });
return el;
return el;
@@ -204,7 +228,8 @@
@@ -204,7 +228,8 @@
el.setAttribute('data-id', `folder:${f.id}`);
el.setAttribute('data-id', `folder:${f.id}`);
el.setAttribute('aria-label', `${f.name} folder, ${f.apps.length} apps`);
el.setAttribute('aria-label', `${f.name} folder, ${f.apps.length} apps`);
const ic = h('span', 'ovh-ic');
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));
el.append(ic, h('span', 'ovh-name', f.name));
return el;
return el;
}
}
@@ -317,28 +342,49 @@
@@ -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);
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) {
function beginDrag(id, e) {
if (state.drag) return;
if (state.drag) return;
const ghost = h('div', 'ovh-ghost');
const ghost = h('div', 'ovh-ghost');
const src = el.querySelector(`.ovh-grid:not(.ovh-recent) [data-id="${CSS.escape(id)}"] .ovh-ic`);
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);
document.body.append(ghost);
state.drag = { id, ghost };
state.drag = { id, ghost };
const at = (m) => { ghost.style.left = `${m.clientX}px`; ghost.style.top = `${m.clientY}px`; };
const at = (m) => { ghost.style.left = `${m.clientX}px`; ghost.style.top = `${m.clientY}px`; };
at(e);
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'); };
const lift = () => { const n = el.querySelector(`.ovh-grid:not(.ovh-recent) [data-id="${CSS.escape(id)}"]`); if (n) n.classList.add('is-lifted'); };
lift();
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) => {
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);
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);
const to = L.home.indexOf(cell.getAttribute('data-id')), from = L.home.indexOf(id);
if (to < 0 || to === from) return;
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);
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)));
grid.textContent = ''; L.home.forEach((x, i) => grid.append(homeTile(x, i)));
icons(); lift();
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 = () => {
const end = () => {
window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', end); window.removeEventListener('pointercancel', end);
window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', end); window.removeEventListener('pointercancel', end);
@@ -475,13 +521,18 @@
@@ -475,13 +521,18 @@
// No Actor yet: one invitation, which the person can put away.
// No Actor yet: one invitation, which the person can put away.
try { if (localStorage.getItem(INVITE_KEY) === 'off') return; } catch { /* */ }
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 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');
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.'));
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 go = h('a', 'ovh-go', 'Set it up'); go.href = `${base}/you`;
const no = h('button', 'ovh-x', 'Not now'); no.type = 'button';
const no = h('button', 'ovh-x', 'Not now'); no.type = 'button';
no.addEventListener('click', () => { try { localStorage.setItem(INVITE_KEY, 'off'); } catch { /* */ } box.textContent = ''; });
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);
box.append(card);
if (typeof o.mountIcons === 'function') { try { o.mountIcons(box); } catch { /* */ } }
if (typeof o.mountIcons === 'function') { try { o.mountIcons(box); } catch { /* */ } }
}
}
@@ -1,5 +1,5 @@
@@ -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
* 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.
* the account menu, or Look in a guest's menu), so it costs nothing on first paint.
*
*
@@ -90,6 +90,20 @@
@@ -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; };
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)])]));
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 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 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'}`]);
const why = el('div', { class: 'ovlk-why' }, [`${named} \u00b7 ${signedIn ? 'saved to your account' : 'saved in this browser'}`]);
@@ -128,6 +142,10 @@
@@ -128,6 +142,10 @@
if (sh) { e.stopPropagation(); return share(sh); }
if (sh) { e.stopPropagation(); return share(sh); }
const ic = e.target.closest && e.target.closest('[data-look-icons]');
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); }
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]');
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); }
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;
const b = e.target.closest && e.target.closest('[data-look]'); if (!b) return;
@@ -186,8 +186,8 @@ html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:
@@ -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 ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}}
@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}
@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))}}
@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{cursor:progress;pointer-events:none}
.ov-busy-mark{display:inline-block;vertical-align:-.2em;margin-right:.45em;flex: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}
@@ -259,13 +259,14 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}
const svgEl = el.querySelector('svg');
const svgEl = el.querySelector('svg');
if (!el.isConnected || !svgEl) { forget(el); return; }
if (!el.isConnected || !svgEl) { forget(el); return; }
const now = Date.now();
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');
const s = el.getAttribute('data-state');
// Anything the mark is showing keeps it moving: a state, a page on its way, a known progress.
// 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 showing = (s && s !== 'idle') || el.hasAttribute('data-nav') || el.hasAttribute('data-progress');
const intro = now < st.introEnd;
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'); }
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).
// 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'); }
if ((may && intro) !== el.hasAttribute('data-ovm-intro') && (may || !intro)) { if (may && intro) el.setAttribute('data-ovm-intro', ''); else el.removeAttribute('data-ovm-intro'); }
@@ -333,6 +333,8 @@
@@ -333,6 +333,8 @@
function close() { if (d.open) { d.open = false; } }
function close() { if (d.open) { d.open = false; } }
d.addEventListener('toggle', function () {
d.addEventListener('toggle', function () {
lock(d.open);
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 }); }
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);
document.addEventListener('pointerdown', function (e) { if (d.open && !d.contains(e.target)) close(); }, true);
@@ -376,6 +378,7 @@
@@ -376,6 +378,7 @@
if (d.__ovt) return; d.__ovt = true;
if (d.__ovt) return; d.__ovt = true;
document.addEventListener('pointerdown', function (e) { if (d.open && !d.contains(e.target)) d.open = false; }, 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('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(); } });
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(); } });
});
});
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.160.0",
+ "version": "4.161.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.160.0",
+ "version": "4.161.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -241,3 +241,15 @@ html.ov-motion [data-ov-reveal].is-in{opacity:1;transform:none}
@@ -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}}
@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] .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}
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; } }
@@ -67,8 +67,15 @@
@@ -67,8 +67,15 @@
// of sites"). The first is labelled with this site's own name.
// of sites"). The first is labelled with this site's own name.
const TABS = [['here', ''], ['feed', 'Feed'], ['you', 'You']];
const TABS = [['here', ''], ['feed', 'Feed'], ['you', 'You']];
const TAB_KEY = 'ovs-tab';
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 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 = '';
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.
// 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 @@
@@ -207,6 +214,14 @@
.ovs-starttabs { gap: 6px; padding: 2px 12px 10px; }
.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 { 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 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-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 { 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; }
.ovs-grid.is-mini .ovs-tile { gap: 8px; min-height: 40px; padding: 4px 8px; border-radius: 10px; }
@@ -406,7 +421,7 @@
@@ -406,7 +421,7 @@
el.appendChild(orbEl);
el.appendChild(orbEl);
tabsEl.addEventListener('click', (e) => {
tabsEl.addEventListener('click', (e) => {
const b = e.target.closest && e.target.closest('[data-tab]'); if (!b) return;
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 { /* */ }
let touch = false; try { touch = root.matchMedia('(pointer: coarse)').matches; } catch { /* */ }
if (touch) b.focus(); else input.focus();
if (touch) b.focus(); else input.focus();
});
});
@@ -940,21 +955,52 @@
@@ -940,21 +955,52 @@
// The network's flagships, first in the full list (the owner, 11-07: "show our most coolest products front facing").
// 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'];
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() {
function paintTabs() {
tabsEl.hidden = false;
tabsEl.hidden = false;
tabsEl.setAttribute('role', 'tablist');
tabsEl.setAttribute('role', 'tablist');
tabsEl.setAttribute('aria-label', 'Menu');
tabsEl.setAttribute('aria-label', 'Menu');
tabsEl.textContent = '';
tabsEl.textContent = '';
const live = pulseData && Number(pulseData.live_now) > 0;
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) {
for (const [id, label] of TABS) {
const b = document.createElement('button');
const b = document.createElement('button');
b.type = 'button'; b.className = 'ovs-tab'; b.setAttribute('role', 'tab'); b.setAttribute('data-tab', id);
b.type = 'button'; b.className = 'ovs-tab'; b.setAttribute('role', 'tab'); b.setAttribute('data-tab', id);
b.setAttribute('aria-selected', tab === id ? 'true' : 'false');
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);
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); }
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);
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. */
/** What every tab needs: the sites, this one, and the person's ranking of them. */
@@ -1107,6 +1153,17 @@
@@ -1107,6 +1153,17 @@
return groups;
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. */
/** You: their workspace, their places, the pages they were on, tools, their searches, and the display controls. */
function youGroups() {
function youGroups() {
const { take, ranked } = basis();
const { take, ranked } = basis();
@@ -1114,8 +1171,7 @@
@@ -1114,8 +1171,7 @@
if (homeLib()) {
if (homeLib()) {
groups.push({ as: 'home', items: [], section: 'home' });
groups.push({ as: 'home', items: [], section: 'home' });
const seenH = new Set();
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' });
groups.push({ title: 'Jump back in', items: backH, section: 'back' });
if (typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });
if (typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });
return groups;
return groups;
@@ -1126,8 +1182,7 @@
@@ -1126,8 +1182,7 @@
const dockPool = yours ? ranked : ranked.filter((x) => x.group !== 'build' || useOf(x.id));
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' });
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 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' });
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);
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' });
groups.push({ title: 'Tools', as: 'chips', more: { label: 'All tools', href: 'https://openvibe.tools/' }, items: tools, section: 'tools' });
@@ -21,7 +21,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
@@ -21,7 +21,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// time something is sent, not on every page.
// 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
// 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.
// 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'];
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 ──────────────────────────────────────────────
// ── the budget ──────────────────────────────────────────────
@@ -141,7 +141,7 @@ const server = http.createServer((req, res) => {
@@ -141,7 +141,7 @@ const server = http.createServer((req, res) => {
// The Tools folder: tools with their own icons, and the count of all.
// 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`);
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 })`);
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.strictEqual(folder.n, 24, 'the folder shows two dozen tools');
assert.deepStrictEqual(folder.icons, ['pdf', 'image', 'qr', 'audio'], 'each tool wears its own icon');
assert.deepStrictEqual(folder.icons, ['pdf', 'image', 'qr', 'audio'], 'each tool wears its own icon');
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
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 ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{
@@ -265,7 +265,7 @@ function load(hostname, opts = {}) {
@@ -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
// 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.
// 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(/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 & 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 & 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(/<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('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');
assert.ok(!dd.innerHTML.includes('Across OpenVibe'), 'the other sites are the launcher\'s and the drawer\'s, not the account menu\'s');
@@ -223,7 +223,12 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
@@ -223,7 +223,12 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
const u = setup();
const u = setup();
u.io.fire(u.mark, true);
u.io.fire(u.mark, true);
u.document.documentElement.setAttribute('data-ov-motion', 'reduced'); await u.flush();
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();
u.document.documentElement.removeAttribute('data-ov-motion'); await u.flush();
assert.strictEqual(u.running(), true, 'back inside the intro');
assert.strictEqual(u.running(), true, 'back inside the intro');
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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); });