openvibers/openvibe.shared Commits
openvibe-shared 4.223.0: You as tabs, icons that fill their squares, names in the brand face, one composer with a + for every tool (#498)
Changes
@@@ -1,5 +1,76 @@# Changelog++## 4.223.0 — 2026-11-12++++You redone as tabs, icons that fill their squares everywhere in the menu, names in the brand face, and one composer with++a + for every tool (the owner, 11-12, a run of phone and desktop screenshots: "the notifications area is ultra buggy …++move the notifications into a tab … put the apps more front facing"; "make the icons bigger so they actually take up++more of the square … fix the alignment"; "waste of space … compact"; "fix the mic to the left … the timer should be in++the plus thing … usable on all composers across all sites").++++- **You:**++ - **One top:** your row, then your AI. Side by side when the menu is wide. Your row holds every round button, with++ this site's own (your channel, Admin, what a site adds) first as icons. The lone "Channel" row is gone.++ - **Three tabs:** Apps (first) · Notifications · Recent. One row, with All and Edit at its end; no "Your home" head.++ - An ink slides to the chosen tab and the tab's content slides in from its side. Calm fades; Off stills.++ - On a phone, only the chosen tab says its name. The arrow keys move between tabs.++ - **Notifications** is a tab:++ - the whole list, with its own search, filters and Mark all read;++ - it loads `notification-ui.js` itself when the tab opens first (it said "unavailable");++ - its count follows the bell live.++ - **Recent** is the whole history:++ - newest first, in days (Today, Yesterday, This week, Earlier);++ - each place a card with its whole title, its site and when, and LIVE when it is live;++ - two columns when wide, Show more, and Full history to openvibe.my/history.++ - **Your AI:**++ - its face shows what it is doing (reading, writing, searching, coding, waiting for you…) as a glyph;++ - it breathes while working;++ - Open is a round arrow and Answer is a pill;++ - not yet set up, it is one slim row.++ - **All apps:** back is a round arrow, with the search beside it. Each group is marked with its glyph and its count.++- **App icons, everywhere in the menu** (You, the site list, search rows, the feed):++ - the site's hue on a rounded square, with the glyph at 58% of it;++ - drawn absolutely, so no hover can move it off centre;++ - a folder's four apps sit in an even 2×2 (they were offset and cut off);++ - a + to add an app sits on the icon's corner;++ - a mouse lifts an icon and lights it in its hue, and a press sinks it.++- **Names in the brand face:**++ - app, site, tab and section names are in OV Brand (Nunito 800, loaded by the navbar on every site); words you++ read stay in the UI face;++ - names wrap to two balanced lines and never end in "…";++ - section heads are names, not small capitals.++- **The site list** (a site's own tab) is a grid of app icons: four across on a phone, as many as fit beyond. Each++ section wears its glyph.++- **The feed:**++ - a recording's length reads as a time (5203 → 1:26:43);++ - the Rundown is its own card: "The Rundown · about Maticus", its mark, a ribbon on its picture, and a grade, so it++ never reads as the stream's card;++ - the kind glyph beside the time is 18 px.++- **One composer** (`ov-composer.js`, every box on every site):++ - **The row:** the mic first, then the + and the box's first tool on a phone; the + opens every tool of the box, big++ and named. It lists the row's own tools, the mods' tools and the extras.++ - **Timer:** a tool in every box's + (`OpenVibeComposer` TOOL `timer`, `extra: true`). Vibe's switch row no longer++ has a timer of its own.++ - **Examples:** an empty box shows what people do with it, in turn: `examples`, or a preset's own, or++ `data-ovc-examples`. Calm is slower; Off keeps the first.++ - **Send:** the round send is an arrow up, in the accent when there is something to send.++- **Vibe:**++ - its box is the same composer, with its hold-to-talk mic first and then the +;++ - a link in your own message reads (it was blue on blue);++ - Chat · Code · Agents is one segmented control with a sliding ink, at 15 px with 20 px glyphs.++- **The menu:**++ - Close is a round × on every screen; the phone's close orb is glass with a hairline edge, never a thick ring;++ - a slow light drifts behind the panel (Calm slows it; Off stops it).++ - `OpenVibeNavbar.openMenu(place)` opens the menu at a place: `notifications`, `recent`, `apps`, `you`, `feed`,++ `vibe`, `chats` or `agent`. A site's own links use it (openvibe.my's Notifications tile clicked a bell that is gone).++- **Times:** a time without a zone is UTC. Network's history sends "YYYY-MM-DD HH:MM:SS", and browsers read it as local++ time; west of UTC every visit said "just now".++- **The notification rows:**++ - one way to clear a notice (×); a tap reads it, and a swipe still offers Read or Clear;++ - the title and its ×N count are one run of text (the count overlapped the words).++- **Tests updated:** menu-home-screen, menu-places, menu-close, dock-chrome (the timer through the +), composer-v2,++ composer-camera, inline-composer.++## 4.222.0 — 2026-11-12The wallet sheet redesigned for phone and desktop, every phone page back to its true width, and whole site names in the@@@ -5,7 +5,7 @@**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt**Install and test:** `npm ci && npm test`--**Status:** alpha, v4.222.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.223.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.222.0",++ "version": "4.223.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)",@@@ -84,6 +84,8 @@.ovdk-send-tools .ovdk-attach, .ovdk-send-tools .ovdk-mic { width: 40px; height: 40px; border-radius: 12px; color: var(--accent-light, var(--accent, #60a5fa)); }.ovdk-send-tools .ovdk-attach:hover, .ovdk-send-tools .ovdk-mic:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }.ovdk-send-tools .ovdk-attach svg, .ovdk-send-tools .ovdk-mic svg { width: 20px; height: 20px; }++ .ovdk-msg.is-you .ovdk-text a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 55%, transparent); text-underline-offset: 2px; overflow-wrap: anywhere; }++ .ovdk-send-tools .ovdk-go:not(:disabled) { background: linear-gradient(150deg, color-mix(in srgb, var(--accent, #3b82f6) 85%, #fff), var(--accent-strong, var(--accent, #2563eb))); color: var(--on-accent-strong, #fff); box-shadow: 0 6px 18px -6px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); }.ovdk-send-tools .ovdk-go { margin-left: auto; width: 40px; height: 40px; border-radius: 50%; }.ovdk-send-tools .ovdk-go svg { width: 18px; height: 18px; stroke-width: 2.2; }.ovdk-msg.is-vibe .ovdk-text a { word-break: normal; overflow-wrap: anywhere; }@@@ -112,10 +114,11 @@// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';-- const VIBE_REV = { 'ov-composer.js': '203e770ec00f' };++ const VIBE_REV = { 'ov-composer.js': '2d3782ec0b52' };function joinComposer(form) {if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain boxform.setAttribute('data-ovc-no-files', '');++ if (!form.dataset.ovcExamples) form.dataset.ovcExamples = JSON.stringify(['Ask Vibe, or hold the mic', 'Find a live stream about cooking', 'Start a 10-minute timer', 'What did people talk about on stream today?', 'Summarize today’s Rundown', 'Write a title for my stream']);const go = () => { try { if (root.OpenVibeComposer && form.isConnected) root.OpenVibeComposer.enhance(form); } catch (e) { console.error(e); } };if (root.OpenVibeComposer) { go(); return; }const m = /^(https?:\/\/[^?#]*\/)dock-vibe(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);@@@ -295,13 +298,13 @@build.dispatchEvent(new CustomEvent('ov-dock:part', { bubbles: true, detail: { part: 'code', text: text.slice(0, 16000) } }));});form.classList.add('ovdk-send--two');-- const tools = h('div', 'ovdk-send-tools');++ const tools = h('div', 'ovdk-send-tools'); tools.setAttribute('data-ovc-tools', ''); form.setAttribute('data-ovc-contain', '');tools.append(attach, file, build);let mic = null;if (TALK) {mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));mic.setAttribute('aria-label', 'Talk to Vibe: hold, or press Alt+V'); mic.title = 'Hold to talk (Alt+V)'; mic.setAttribute('aria-pressed', 'false');-- tools.append(mic);++ tools.prepend(mic); form.setAttribute('data-ovc-no-voice', '');}go.textContent = ''; go.innerHTML = svg(V.send);tools.append(go);@@@ -80,7 +80,7 @@}// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.-- const DOCK_REV = { 'dock-vibe.js': 'def2148254a0', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };++ const DOCK_REV = { 'dock-vibe.js': 'e34bcde16fb8', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };function load(src, test) {return new Promise((ok) => {if (test()) return ok(true);@@@ -604,7 +604,7 @@* serves its own pinned copy at /shared/ gets its own), else the network's copy.*/// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'c96584376da1', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'cc406790c229', 'dock.js': '328a715e0ecd', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '1dd1ca512625', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'c96584376da1', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f1d4e50592b4', 'dock.js': '1fb16839cb0e', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '4086b813ffda', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '02d7b0204716', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;// ov-tip.js loads at the first hover over a tooltip's owner.@@@ -721,11 +721,7 @@if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(() => { formWatch = null; go(); }, { timeout: 2000 });else { const t = setTimeout(() => { formWatch = null; go(); }, 1200); if (t && typeof t.unref === 'function') t.unref(); }}-- // A box that scrolls (a code block, a wide table, a list held to a height) with nothing focusable inside it cannot be-- // scrolled from a keyboard (WCAG 2.1.1; axe scrollable-region-focusable flagged pages on six sites, 2026-11-02). Each-- // such box gets tabindex="0", so Tab reaches it and the arrow keys scroll it. A box with a link or a button inside is-- // reachable already and is left alone; a page's own tabindex always wins. Run when the page settles, again a little-- // later for content that loads after it, and after a resize changes what overflows.++ // A scrolling box with nothing focusable gets tabindex="0" so a keyboard can scroll it (WCAG 2.1.1); a page's own wins.const SCROLL_FOCUSABLE = 'a[href],button,input,select,textarea,summary,iframe,[tabindex],[contenteditable="true"]';function focusScrollers() {if (!document.body || !document.body.getElementsByTagName) return 0;@@@ -2787,6 +2783,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); },++ /** The menu at a place (4.223.0). */++ openMenu(p) { if (/^(vibe|chats|agent)$/.test(p)) return openMenuOn(p); const y = /^(notifications|recent|apps|you)$/.test(p); openSearch('', y ? 'you' : p === 'feed' ? 'feed' : 'here', null, y && p !== 'you' ? p : null); },pop: popFrom,unpop,askAgent(text) { askAgent(text); },@@@ -271,8 +271,10 @@.openvibe-notif-item .ovn-under span { display: inline-flex; align-items: center; gap: 6px; }.openvibe-notif-item .ovn-under svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }.openvibe-notif-item .content { min-width: 0; }-- .openvibe-notif-item .title { font-size: 13.5px; font-weight: 550; line-height: 1.32; display: flex; align-items: baseline; gap: 6px; }-- .openvibe-notif-item .title .t { min-width: 0; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }++ /* 4.223.0: the title and its count are one run of text (the count overlapped the words when the title was a box). */++ .openvibe-notif-item .title { font-size: 13.5px; font-weight: 550; line-height: 1.32; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }++ .openvibe-notif-item .title .t { display: inline; }++ .openvibe-notif-item .title .count { display: inline-block; margin-left: 6px; vertical-align: 1px; }.openvibe-notif-item.unread .title { font-weight: 700; }.openvibe-notif-item .title .count { flex: none; font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 50%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 15%, transparent); }.openvibe-notif-item .msg { margin-top: 2px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); line-height: 1.42; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }@@@ -1076,7 +1078,7 @@${p.message ? `<div class="msg">${esc(p.message)}</div>` : ''}${chipsHtml({ ...p, chips })}${cmd ? `<button type="button" class="copy" data-row="copy" data-cmd="${esc(cmd)}">Copy fix command</button>` : ''}-- <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${when}</span>${label}<div class="row-actions">${unread ? `<button type="button" data-row="read" title="Mark as read" aria-label="Mark as read">${svg(ICON.read)}</button>` : ''}<button type="button" data-row="dismiss" title="Clear" aria-label="Clear">${svg(ICON.close)}</button></div></div>++ <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${when}</span>${label}<div class="row-actions"><button type="button" data-row="dismiss" title="Clear this notice" aria-label="Clear this notice">${svg(ICON.close)}</button></div></div></div>${thumb ? `<img class="thumb" src="${esc(thumb)}" alt="" loading="lazy">` : ''}</div>@@@ -283,6 +283,37 @@ html[data-ov-motion=off] .ovc-cam-shutter { transition: none; }.ovc-emoji button:hover, .ovc-emoji button:focus-visible { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); }@media (max-width: 480px) { .ovc-v2.ovt-composer { padding: 12px 12px 8px; } .ovc-emoji { grid-template-columns: repeat(7, 38px); } .ovc-v2 .ovt-send { padding: 0 16px; } }html[data-ov-motion=off] .ovc-emoji { animation: none; }++++/* 4.223.0: the + sheet, the mic first, the examples' fade, the round send. */++.ovc-v2, form.ovc[data-ovc-contain] { container: ovc / inline-size; }++.ovc-more[aria-expanded="true"] { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 18%, transparent); }++.ovc-more svg { transition: transform .25s cubic-bezier(.3, 1.4, .4, 1); }++.ovc-more[aria-expanded="true"] svg { transform: rotate(45deg); }++@container ovc (max-width: 460px) {++ .ovt-c-tools > .ovt-btn:not(.ovc-mic):not(.ovc-more) ~ .ovt-btn:not(.ovc-mic):not(.ovc-more):not([aria-pressed="true"]) { display: none; }++}++.ovc-tray { grid-column: 1 / -1; flex: 1 0 100%; width: 100%; box-sizing: border-box; margin-top: 8px; padding: 8px 0 2px; border-top: 1px solid var(--border, rgba(148,163,184,.14)); animation: ovc-tray-in .22s cubic-bezier(.2, .9, .3, 1.15) both; }++.ovc-tray-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 4px; }++.ovc-tray-i { all: unset; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 7px; padding: 10px 4px 8px; border-radius: 14px; cursor: pointer; text-align: center; -webkit-tap-highlight-color: transparent; }++.ovc-tray-ic { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 15px; color: var(--ovt-accent, var(--accent-light, var(--accent, #60a5fa)));++ background: linear-gradient(150deg, color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 30%, transparent), color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 12%, transparent));++ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 30%, transparent); transition: transform .2s cubic-bezier(.3, 1.4, .4, 1); }++.ovc-tray-ic svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }++.ovc-tray-t { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-size: 13px; font-weight: 800; line-height: 1.2; color: var(--text-primary, #e6edf7); text-wrap: balance; }++.ovc-tray-i:active .ovc-tray-ic { transform: scale(.92); }++.ovc-tray-i:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++@media (hover: hover) and (pointer: fine) { .ovc-tray-i:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); } .ovc-tray-i:hover .ovc-tray-ic { transform: translateY(-2px); } }++@keyframes ovc-tray-in { from { opacity: 0; transform: translateY(8px) scale(.98); } }++html[data-ov-motion=reduced] .ovc-tray { animation-name: ovc-fade-calm; }++@keyframes ovc-fade-calm { from { opacity: 0; } }++html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { animation: none; transition: none; }++.ovc textarea::placeholder, .ovt-composer textarea::placeholder { transition: opacity .25s ease; }++.ovc textarea.ovc-ph-swap::placeholder, .ovt-composer textarea.ovc-ph-swap::placeholder { opacity: 0; }++.ovc-v2 .ovt-send--round { background: linear-gradient(150deg, color-mix(in srgb, var(--accent, #3b82f6) 85%, #fff), var(--accent-strong, var(--accent, #2563eb))); color: var(--on-accent-strong, #fff);++ box-shadow: 0 6px 18px -6px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); transition: transform .2s cubic-bezier(.3, 1.4, .4, 1), opacity .2s, box-shadow .2s; }++.ovc-v2 .ovt-send--round svg { width: 20px; height: 20px; stroke-width: 2.3; }++.ovc-v2 .ovt-send--round:disabled { opacity: 1; background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); color: var(--text-muted, #7386a3); box-shadow: none; }++.ovc-v2 .ovt-send--round:not(:disabled):active { transform: scale(.9); }`;function styles() { if (document.getElementById('ovc-styles')) return; const st = document.createElement('style'); st.id = 'ovc-styles'; st.textContent = CSS; document.head.appendChild(st); }const mods = [];@@@ -337,6 +368,9 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');const IC = {mic: 'M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3zM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',++ plus: 'M12 5v14M5 12h14',++ timer: 'M12 4.5a8.2 8.2 0 1 1 0 16.4 8.2 8.2 0 0 1 0-16.4zM12 8.6v4.2l2.7 1.7M9.6 2.6h4.8',++ up: 'M12 19V5.5M6.2 11.2L12 5.4l5.8 5.8',x: 'M7 7l10 10M17 7L7 17',file: 'M7 3h7l5 5v12a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM14 3v5h5',slash: 'M15 4L9 20',@@@ -412,7 +446,8 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }const text = form.querySelector('[data-ovt-text]') || form.querySelector('textarea');if (!text) return null;const row = form.querySelector('.ovt-c-row');-- const tools = form.querySelector('.ovt-c-tools');++ // A box drawn by its own page marks its row with data-ovc-tools (Vibe's); else the composer's own row.++ const tools = form.querySelector('.ovt-c-tools') || form.querySelector('[data-ovc-tools]');const note = form.querySelector('[data-ovt-note]');const send = form.querySelector('[data-ovt-send]') || form.querySelector('[type="submit"]');const inputs = () => [...form.querySelectorAll('input[type="file"]')];@@@ -722,6 +757,63 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }// Voice: the browser listens, the words go in at the caret as they come.let mic = null, rec = null;++ // 4.223.0, the +: every tool of this box in one sheet (its row's own, the mods', and the extras such as Timer), big++ // and named. On a narrow box the row keeps the mic, the + and the first tool; the sheet has the rest.++ let more = null, sheet = null;++ function closeSheet() { if (sheet) { sheet.remove(); sheet = null; } if (more) more.setAttribute('aria-expanded', 'false'); }++ function openSheet(ev) {++ if (sheet) { closeSheet(); return; }++ sheet = el('div', 'ovc-tray'); sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-label', 'Tools');++ const grid = el('div', 'ovc-tray-grid');++ const add = (label, path, act, drawn) => {++ const b = el('button', 'ovc-tray-i', `<span class="ovc-tray-ic">${drawn ? '' : svg(PATH_OK.test(path || '') ? path : IC.slash)}</span><span class="ovc-tray-t"></span>`);++ if (drawn) { const c = drawn.cloneNode(true); c.removeAttribute('width'); c.removeAttribute('height'); c.removeAttribute('class'); b.firstChild.appendChild(c); } // the button's own whole drawing++ b.type = 'button'; b.querySelector('.ovc-tray-t').textContent = label;++ b.addEventListener('click', () => { closeSheet(); try { act(); } catch (e) { console.error(e); } });++ grid.appendChild(b);++ };++ const seen = new Set();++ [...tools.children].forEach((t) => {++ if (t === mic || t === more || !t.matches('.ovt-btn, button, label') || t.matches('[type="submit"], [data-ovt-send], .ovc-mic, .ovdk-mic, .ovdk-go, [hidden]')) return;++ const label = t.getAttribute('aria-label') || t.title || ''; if (!label || seen.has(label)) return; seen.add(label);++ const input = t.querySelector('input[type=file]');++ add(label, '', () => { if (input) input.click(); else t.click(); }, t.querySelector('svg'));++ });++ Object.keys(TOOL).forEach((id) => {++ const t = TOOL[id]; if (!t || !t.extra || seen.has(t.label) || form.hasAttribute(`data-ovc-no-${id}`)) return; seen.add(t.label);++ add(t.label, t.path, () => t.run(ctx, more, form));++ });++ sheet.appendChild(grid);++ form.appendChild(sheet); // inside the box, under its row: nothing can clip it++ more.setAttribute('aria-expanded', 'true');++ // From the keyboard (a click with no pointer) focus moves into the sheet; a tap leaves it on the +.++ if (ev && ev.detail === 0) { const first = sheet.querySelector('button'); if (first) first.focus(); }++ sheet.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeSheet(); more.focus(); } });++ }++ if (tools && !form.hasAttribute('data-ovc-no-more')) {++ more = el('button', 'ovt-btn ovc-more', svg(IC.plus));++ more.type = 'button'; more.setAttribute('aria-label', 'More: attach, timer and tools'); more.title = 'More';++ more.setAttribute('aria-haspopup', 'dialog'); more.setAttribute('aria-expanded', 'false');++ more.addEventListener('click', openSheet);++ const lead = tools.querySelector(':scope > .ovdk-mic'); // a page's own mic stays first++ tools.insertBefore(more, lead ? lead.nextSibling : tools.firstChild);++ document.addEventListener('pointerdown', (e) => { if (sheet && !sheet.contains(e.target) && !more.contains(e.target)) closeSheet(); });++ }++ // What people do with this box, in turn while it is empty and you are elsewhere (Calm: slower; Off: the first stays).++ {++ let ex = null; try { ex = JSON.parse(form.dataset.ovcExamples || 'null'); } catch { ex = null; }++ if (Array.isArray(ex) && ex.length > 1 && typeof setInterval === 'function') {++ let k = 0;++ const motion = () => (document.documentElement.getAttribute('data-ov-motion') || '');++ const tick = setInterval(() => {++ if (!form.isConnected) { clearInterval(tick); return; }++ if (motion() === 'off' || text.value || document.activeElement === text) return;++ k = (k + 1) % ex.length;++ text.classList.add('ovc-ph-swap');++ setTimeout(() => { text.placeholder = String(ex[k]).slice(0, 80); text.classList.remove('ovc-ph-swap'); }, motion() === 'reduced' ? 120 : 260);++ }, motion() === 'reduced' ? 8000 : 4200);++ }++ }function micButton() {if (!Speech || !tools) return;if (!prefs.voice || form.hasAttribute('data-ovc-no-voice')) { if (mic) { mic.remove(); mic = null; } return; }@@@ -740,7 +832,8 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }rec.onerror = (ev) => { if (ev.error === 'not-allowed') say('The microphone is blocked for this site.'); };try { rec.start(); mic.setAttribute('aria-pressed', 'true'); mic.title = 'Stop listening'; } catch { rec = null; }});-- tools.appendChild(mic);++ tools.insertBefore(mic, tools.firstChild);++ if (more && more.parentNode === tools) tools.insertBefore(more, mic.nextSibling);}// The mods' tools: buttons in the row, in the order the mods came.@@@ -753,7 +846,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }const b = el('button', 'ovt-btn ovc-tool', svg(PATH_OK.test(t.path || '') ? t.path : IC.slash));b.type = 'button'; b.setAttribute('aria-label', t.label); b.title = t.label;b.addEventListener('click', () => { try { t.run(ctx); } catch (e) { console.error(e); } });-- tools.insertBefore(b, mic && mic.parentNode === tools ? mic : null);++ tools.appendChild(b);modTools.push(b);}}@@@ -764,7 +857,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }add, files: () => files.map((f) => f.file), clearFiles: () => { [...files].forEach(remove); },uploads: () => files.map((f) => f.id).filter(Boolean),clear() { files.slice().forEach(remove); text.value = ''; if (draftKey) store.set(draftKey, null); fire(); },-- destroy() { live.delete(api); pop.remove(); strip.remove(); if (mic) mic.remove(); modTools.forEach((b) => b.remove()); form.__ovc = null; },++ destroy() { live.delete(api); pop.remove(); strip.remove(); if (mic) mic.remove(); if (more) more.remove(); closeSheet(); modTools.forEach((b) => b.remove()); form.__ovc = null; },};form.__ovc = api;form.addEventListener('reset', () => setTimeout(() => api.clear(), 0));@@@ -802,6 +895,10 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }// value = { verb, text, files, uploads, model, sources, depth, context, chips }.// A tool is a module: OpenVibeComposer.tool(id, { label, path, accept? | run(ctx, button, form) }) adds or replaces// one for every box; a preset or a site names the ones it shows. Mods (mod()) still add their own buttons.++ const EXAMPLES = {++ agent: ['Ask for anything', 'Find a live stream about cooking', 'Start a 10-minute timer', 'Summarize today’s Rundown', 'Write a title for my stream', 'What did people post today?'],++ post: ["What's new?", 'Share a photo from today', 'Ask your followers a question', 'Post a clip you made'],++ };const PRESET = {post: { tools: ['photo', 'camera', 'file', 'emoji', 'voice'], send: 'Post', avatar: true, rows: 2, max: 2000, ring: true, resize: 2560 },chat: { tools: ['attach', 'emoji', 'voice'], send: '', avatar: false, rows: 1, max: 4000, ring: false, resize: 2560 },@@@ -811,6 +908,17 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }const EMOJI = '😀 😂 🥹 😊 😍 🥰 😘 😎 🤩 🥳 😇 🙂 😉 😌 🤔 🤨 😐 🙄 😏 😬 😮 😱 😭 😤 😡 🤯 🥶 🥵 😴 🤤 🤗 🫡 🤝 👍 👎 👏 🙌 🙏 💪 👀 🫶 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ✨ 🔥 💯 🎉 🎊 🏆 🎮 🎧 🎵 📸 🎬 🍕 🍔 🍿 ☕ 🍻 🌈 🌙 ⭐ ⚡ 💥 💀 🤖 👻 🐐 🦆 🐸 🚀 ✅ ❌ ⚠️ ❓ 👋 🫠'.split(' ');const TOOL = {photo: { label: 'Photos and video', path: IC.photo, accept: 'image/*,video/*' },++ // 4.223.0: in every box's + (extra: never in a row of its own). Your timers are Vibe's: one list on every site.++ timer: {++ label: 'Timer', path: IC.timer, extra: true,++ run(ctx, button) {++ const D = root.OpenVibeDock;++ if (D && typeof D.timer === 'function') { try { D.timer(button); return; } catch (e) { console.error(e); } }++ const N = root.OpenVibeNavbar;++ if (N && typeof N.openMenuOn === 'function') { try { N.openMenuOn('vibe'); ctx.note('Say “a timer for 10 minutes” to Vibe.'); return; } catch { /* below */ } }++ ctx.note('Sign in to set timers: they ring on every OpenVibe site.');++ },++ },file: { label: 'Files', path: IC.file, accept: '' },attach: { label: 'Attach', path: IC.clip, accept: '' },emoji: {@@@ -959,6 +1067,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }ta.name = o.name || 'text'; ta.rows = p.rows; ta.maxLength = o.maxLength || p.max;ta.placeholder = o.placeholder || (name === 'post' ? "What's new?" : name === 'agent' ? 'Ask for anything' : name === 'comment' ? 'Write a reply' : 'Write a message');ta.setAttribute('aria-label', o.label || ta.placeholder);++ { const ex = Array.isArray(o.examples) ? o.examples : (o.placeholder ? null : EXAMPLES[name]); if (ex && ex.length > 1) form.dataset.ovcExamples = JSON.stringify(ex.slice(0, 8)); }ta.setAttribute('data-ovt-text', '');if (o.required !== false) ta.required = true;const row = el('div', 'ovt-c-row'), tools = el('div', 'ovt-c-tools'), chips = el('div', 'ovc-chips');@@@ -1007,7 +1116,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }ring.innerHTML = '<svg viewBox="0 0 26 26" width="26" height="26" aria-hidden="true"><circle class="ovc-ring-bg" cx="13" cy="13" r="10"/><circle class="ovc-ring-fg" cx="13" cy="13" r="10" stroke-dasharray="62.83" stroke-dashoffset="62.83" transform="rotate(-90 13 13)"/></svg>';ring.hidden = true;}-- const send = el('button', p.send ? 'ovt-send' : 'ovt-send ovt-send--round', p.send ? '' : svg(IC.send));++ const send = el('button', p.send ? 'ovt-send' : 'ovt-send ovt-send--round', p.send ? '' : svg(IC.up));if (p.send) send.textContent = o.submitLabel || p.send;send.type = 'submit'; send.disabled = true; send.setAttribute('data-ovt-send', '');send.setAttribute('aria-label', o.submitLabel || p.send || 'Send');@@@ -36,7 +36,8 @@const safe = (u) => { if (u == null || String(u).trim() === '') return null; try { const x = new URL(String(u), location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } };const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };const ago = (iso) => {-- const t = Date.parse(iso || ''); if (!t) return '';++ // A time without a zone is UTC (see search.js utcOf).++ const v = String(iso || ''); const t = Date.parse(/^\d{4}-\d\d-\d\d[ T]\d\d:\d\d(:\d\d(\.\d+)?)?$/.test(v) ? `${v.replace(' ', 'T')}Z` : v); if (!t) return '';const s = Math.max(1, Math.round((Date.now() - t) / 1000));if (s < 60) return 'now'; if (s < 3600) return `${Math.round(s / 60)} min ago`; if (s < 86400) return `${Math.round(s / 3600)} h ago`;const d = Math.round(s / 86400); return d === 1 ? 'yesterday' : `${d} days ago`;@@@ -247,6 +248,210 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur.ovh-actor.is-invite .ovh-go { grid-column: 3; grid-row: 1; }.ovh-actor.is-invite .ovh-x { grid-column: 4; grid-row: 1; display: grid; place-items: center; width: 36px; min-width: 36px; padding: 0; border-radius: 50%; }.ovh-actor.is-invite .ovh-x svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; }++++/* 4.223.0: You's three tabs, the one notice above them, and an empty tab said with its mark (openvibe.design rule 141). */++.ovh-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; margin: 10px 0 12px; padding: 4px; border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); }++.ovh-tab { all: unset; box-sizing: border-box; position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 44px; padding: 0 8px; border-radius: 12px; cursor: pointer; font-size: 14px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }++.ovh-tab svg { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }++.ovh-tab:hover { color: var(--text-primary, #e6edf7); }++.ovh-tab:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #60a5fa)); outline-offset: -2px; }++.ovh-tab[aria-selected="true"] { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #121826); box-shadow: 0 1px 2px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }++.ovh-tab[aria-selected="true"] svg { color: var(--accent-light, var(--accent, #60a5fa)); }++.ovh-tab-n { min-width: 18px; height: 18px; padding: 0 5px; box-sizing: border-box; border-radius: 999px; background: var(--danger, #ef4444); color: #fff; font: 750 11px/18px inherit; text-align: center; }++@media (max-width: 380px) { .ovh-tab { gap: 5px; font-size: 13px; } .ovh-tab svg { width: 16px; height: 16px; } }++.ovh-notes { min-height: 120px; }++.ovh-empty { display: flex; align-items: center; gap: 14px; margin: 6px 0; padding: 14px 16px; border-radius: 14px; border: 1px dashed color-mix(in srgb, var(--text-muted, #93a0b4) 45%, transparent); }++.ovh-empty > svg { width: 22px; height: 22px; flex: none; fill: none; stroke: var(--accent-light, var(--accent, #60a5fa)); stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }++.ovh-empty > div { display: grid; gap: 2px; } .ovh-empty b { font-size: 14.5px; } .ovh-empty span { font-size: 13px; color: var(--text-muted, #93a0b4); }++/* Your own AI is one notice under your row: a slim line, not a card (the owner: "make that a notification … up there"). */++.ovh-notice .ovh-actor.is-invite { margin: 8px 0 0; padding: 8px 8px 8px 10px; gap: 10px; min-height: 0; border-radius: 14px; }++.ovh-notice .ovh-actor.is-invite .ovh-face { width: 32px; height: 32px; border-radius: 10px; }++.ovh-notice .ovh-actor.is-invite .ovh-actor-txt small { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }++.ovh-notice .ovh-actor.is-invite .ovh-acts { flex-direction: row; align-items: center; }++@media (max-width: 480px) { .ovh-notice .ovh-actor.is-invite { flex-wrap: nowrap; } .ovh-notice .ovh-actor.is-invite .ovh-actor-txt small { -webkit-line-clamp: 2; } }++++/* ── 4.223.0: You, redone (the owner, 11-12) ──────────────────────────────────────────────────────────────────────────++ One top (you, and your AI beside you when there is room), one tab row (Apps · Notifications · Recent, with All and++ Edit at its end), then the tab. App icons draw the glyph at 58% of the tile, placed absolutely so a hover can never++ pull it off centre; a folder's four sit in an even 2×2 with their glyphs at two thirds. Names wrap to two balanced++ lines and never end in "…". */++.ovh { container: ovh / inline-size; }++.ovh-top { display: grid; gap: 6px; margin: 2px 4px 0; }++.ovh-top > * { min-width: 0; }++.ovh-top .ovh-me, .ovh-top .ovh-notice .ovh-actor { margin: 0; width: 100%; }++@container ovh (min-width: 600px) {++ .ovh-top:has(.ovh-notice > *) { grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); align-items: stretch; }++ .ovh-top .ovh-notice > .ovh-actor { height: 100%; }++}++.ovh-notice:empty { display: none; }++.ovh-notice .ovh-actor { box-sizing: border-box; min-height: 56px; padding: 8px 12px; border-radius: 16px; }++.ovh-notice .ovh-actor .ovh-face { width: 36px; height: 36px; font-size: 15px; }++/* Your row: you, then your round buttons (this site's own first). On a narrow screen the buttons go under your name. */++.ovh-me { flex-wrap: wrap; row-gap: 0; padding: 6px 6px 6px 8px; min-height: 56px; box-sizing: border-box; }++.ovh-acts { display: flex; align-items: center; gap: 4px; flex: none; }++.ovh-ib.is-own { color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 13%, transparent); }++.ovh-ib.is-text { width: auto; padding: 0 12px; border-radius: 999px; font-size: 12.5px; font-weight: 750; white-space: nowrap; }++@container ovh (max-width: 520px) {++ .ovh-me { padding: 8px 8px 6px 10px; }++ .ovh-acts { flex: 1 1 100%; flex-wrap: wrap; gap: 6px; padding: 6px 0 0 52px; }++ .ovh-acts .ovh-out { margin-left: auto; }++}++/* The tab row: on a phone only the chosen tab says its name (the others are their glyph and count), so the row also++ holds All and Edit; from 520 px every tab says its name. It stays at the top while the apps scroll. */++.ovh-tabrow { position: sticky; top: 0; z-index: 4; display: flex; align-items: center; gap: 8px; margin: 8px 0 6px; padding: 4px;++ background: color-mix(in srgb, var(--bg-secondary, #121826) 90%, transparent); -webkit-backdrop-filter: blur(14px) saturate(1.3); backdrop-filter: blur(14px) saturate(1.3); border-radius: 16px; }++.ovh-tabrow .ovh-tabs { flex: 1 1 auto; min-width: 0; display: flex; gap: 4px; margin: 0; padding: 0; background: none; }++.ovh-tabrow .ovh-tab { flex: 0 0 auto; min-width: 44px; min-height: 42px; padding: 0 12px; border-radius: 12px; transition: background-color .18s, color .18s, flex-grow .25s; }++.ovh-tabrow .ovh-tab[aria-selected="true"] { flex: 1 1 auto; }++.ovh-tabrow .ovh-headtools { flex: none; display: flex; align-items: center; gap: 4px; }++@container ovh (max-width: 519px) {++ .ovh-tabrow .ovh-tab:not([aria-selected="true"]) .ovh-tab-t { display: none; }++ .ovh-tabrow .ovh-tab:not([aria-selected="true"]) { padding: 0 10px; }++}++@container ovh (min-width: 520px) { .ovh-tabrow .ovh-tab { flex: 0 1 auto; } .ovh-tabrow .ovh-tab[aria-selected="true"] { flex: 0 1 auto; } }++.ovh-tabrow .ovh-allbtn, .ovh-tabrow .ovh-edit { min-height: 42px; }++.ovh-tabrow .ovh-edit { width: 42px; }++.ovh-tabrow .ovh-edit.is-done { width: auto; }++/* Section heads: a marked glyph, the name, its count; buttons at the end. */++.ovh-sec { gap: 8px; padding: 12px 6px 6px; }++.ovh-sec > span { flex: 0 1 auto; min-width: 0; }++.ovh-sec-sp { flex: 1; }++.ovh-sec-ic { flex: none; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 8px; background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }++.ovh-sec-ic svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2.1; stroke-linecap: round; stroke-linejoin: round; }++.ovh-sec-n { flex: none; min-width: 20px; height: 20px; padding: 0 6px; box-sizing: border-box; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 9%, transparent); color: var(--text-secondary, #a8b3c4); font-size: 11px; font-weight: 750; line-height: 20px; letter-spacing: 0; text-align: center; }++/* Back is a round arrow; in All apps the search sits beside it. */++.ovh-head { gap: 8px; padding: 6px 4px; }++.ovh-back { flex: none; display: inline-grid; place-items: center; width: 44px; height: 44px; min-height: 44px; padding: 0; border-radius: 50%; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); color: var(--text-primary, #e8ecf3); transition: background-color .15s, transform .12s; }++.ovh-back svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2.1; stroke-linecap: round; stroke-linejoin: round; }++.ovh-back:active { transform: scale(.92); }++.ovh-back:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++@media (hover: hover) { .ovh-back:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 15%, transparent); } }++.ovh-head .ovh-filter { flex: 1; min-width: 0; width: auto; margin: 0; }++/* The grid: four across on a phone, as many as fit from 560 px. */++.ovh-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }++@media (min-width: 560px) { .ovh-grid { grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); } }++/* App icons: the glyph at 58% of the tile. */++.ovh-ic > .ov-icon { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }++.ovh-ic > .ov-icon svg { position: absolute; left: -13%; top: -13%; width: 126%; height: 126%; margin: 0; transform: none !important; }++.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: auto; gap: 9%; padding: 14%; box-sizing: border-box; place-content: center stretch; align-content: center; }++.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic > span { position: relative; display: block; width: 100%; height: auto; aspect-ratio: 1; align-self: center; border-radius: 30%; overflow: hidden;++ background: linear-gradient(150deg, hsl(var(--ovh-h, 215) 72% 58% / .55), hsl(var(--ovh-h, 215) 70% 38% / .35)); box-shadow: inset 0 0 0 1px hsl(var(--ovh-h, 215) 70% 66% / .3); }++.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic > span > .ov-icon { position: absolute; inset: 0; width: 100%; height: 100%; display: block; --ovi-glyph: hsl(var(--ovh-h, 215) 92% 86%); color: hsl(var(--ovh-h, 215) 92% 86%); }++.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic > span > .ov-icon svg { position: absolute; left: -23%; top: -23%; width: 146%; height: 146%; margin: 0; transform: none !important; }++:root[data-theme="light"] .ovh-grid .ovh-folder .ovh-ic > span > .ov-icon { --ovi-glyph: hsl(var(--ovh-h, 215) 70% 28%); color: hsl(var(--ovh-h, 215) 70% 28%); }++/* Names: two balanced lines, centred, never "…". */++.ovh-name { white-space: normal; text-overflow: clip; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: center; line-height: 1.2; text-wrap: balance; overflow-wrap: normal; word-break: normal; }++.ovh-grid:not(.ovh-recent) .ovh-name { font-size: 12px; letter-spacing: -.005em; }++/* A + to put an app on Home sits on its icon's corner, not in the cell's. */++.ovh-grid .ovh-app > .ovh-badge { top: 0; left: calc(50% - min(50% - 6px, 36px) - 8px); width: 24px; height: 24px; font-size: 15px; }++/* Touch, pen and mouse: a press sinks the icon at once; a mouse lifts it and lights it in its own hue. */++.ovh-ic { transition: transform .22s cubic-bezier(.2, 1.4, .4, 1), box-shadow .22s; }++.ovh-app:active .ovh-ic { transform: scale(.93); transition-duration: .08s; }++@media (hover: hover) and (pointer: fine) {++ .ovh-app:hover .ovh-ic { transform: translateY(-3px) scale(1.04); box-shadow: inset 0 0 0 1px hsl(var(--ovh-h, 215) 75% 70% / .5), 0 12px 26px -8px hsl(var(--ovh-h, 215) 75% 45% / .6); }++ .ovh-app:hover .ovh-name { opacity: 1; }++}++html[data-ov-motion=off] .ovh-ic { transition: none; }++/* Recent: where you were, in days, as cards with the whole title; two columns when there is room. */++.ovh-hist-list { display: grid; gap: 8px; padding: 0 4px; }++@container ovh (min-width: 600px) { .ovh-hist-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } }++.ovh-hist-day { grid-column: 1 / -1; margin: 8px 4px 0; font-size: 12px; font-weight: 800; letter-spacing: .02em; color: var(--text-muted, #93a0b4); }++.ovh-hist-day:first-child { margin-top: 0; }++.ovh-hrow { box-sizing: border-box; display: flex; align-items: center; gap: 12px; min-width: 0; min-height: 70px; padding: 10px 12px; border-radius: 16px; color: inherit; text-decoration: none;++ background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.08));++ transition: background-color .15s, transform .15s; animation: ovh-rise .34s cubic-bezier(.2, .8, .2, 1) both; animation-delay: calc(var(--i, 0) * 22ms); }++.ovh-hrow .ovh-ic { flex: none; width: 48px; height: 48px; border-radius: 15px; }++.ovh-htxt { min-width: 0; display: grid; gap: 3px; }++.ovh-htxt b { font-size: 14.5px; font-weight: 700; line-height: 1.28; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }++.ovh-htxt small { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }++.ovh-hrow.is-live { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--live-red, #ef4444) 45%, transparent); }++.ovh-hrow:active { transform: scale(.985); }++.ovh-hrow:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++@media (hover: hover) and (pointer: fine) { .ovh-hrow:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); transform: translateY(-1px); } }++.ovh-hist-more[hidden] { display: none; }++@keyframes ovh-rise { from { opacity: 0; transform: translateY(8px); } }++@keyframes ovh-rise-calm { from { opacity: 0; } }++html[data-ov-motion=reduced] .ovh-hrow { animation-name: ovh-rise-calm; animation-delay: 0s; }++html[data-ov-motion=off] .ovh-hrow { animation: none; }++++/* 4.223.0, round two: All and Edit in the tab row; the tabs as one segmented control whose ink slides to the chosen tab,++ and the tab's content slides in from its side (the owner, 11-12: "animations, effects to the tabs … aligned more++ perfectly … bigger font size and better icon size"). */++.ovh-tabrow .ovh-tabs { position: relative; isolation: isolate; }++.ovh-tab-ink { position: absolute; left: 0; top: 0; bottom: 0; width: 0; z-index: -1; border-radius: 12px; opacity: 0;++ background: var(--bg-secondary, #121826); box-shadow: 0 1px 2px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent), 0 6px 18px -8px color-mix(in srgb, var(--accent, #3b82f6) 60%, transparent);++ transition: transform .34s cubic-bezier(.3, 1.3, .4, 1), width .34s cubic-bezier(.3, 1.3, .4, 1), opacity .2s; }++.ovh-tab-ink.is-on { opacity: 1; }++:root[data-theme="light"] .ovh-tab-ink { background: #fff; }++.ovh-tabrow .ovh-tab[aria-selected="true"] { background: transparent; box-shadow: none; }++.ovh-tabrow .ovh-tab { font-size: 14.5px; gap: 8px; }++.ovh-tabrow .ovh-tab svg { width: 19px; height: 19px; transition: transform .25s cubic-bezier(.3, 1.4, .4, 1); }++.ovh-tabrow .ovh-tab[aria-selected="true"] svg { transform: scale(1.08); }++@media (hover: hover) and (pointer: fine) { .ovh-tabrow .ovh-tab:not([aria-selected="true"]):hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); } }++.ovh-tabrow .ovh-tab:active svg { transform: scale(.9); }++html[data-ov-motion=reduced] .ovh-tab-ink { transition: opacity .25s; }++html[data-ov-motion=off] .ovh-tab-ink, html[data-ov-motion=off] .ovh-tabrow .ovh-tab svg { transition: none; }++.ovh[data-enter] > :not(.ovh-top):not(.ovh-tabrow) { animation: ovh-pane-r .3s cubic-bezier(.2, .8, .2, 1) both; }++.ovh[data-enter="left"] > :not(.ovh-top):not(.ovh-tabrow) { animation-name: ovh-pane-l; }++@keyframes ovh-pane-r { from { opacity: 0; transform: translateX(18px); } }++@keyframes ovh-pane-l { from { opacity: 0; transform: translateX(-18px); } }++html[data-ov-motion=reduced] .ovh[data-enter] > :not(.ovh-top):not(.ovh-tabrow) { animation-name: ovh-rise-calm; }++html[data-ov-motion=off] .ovh[data-enter] > :not(.ovh-top):not(.ovh-tabrow) { animation: none; }++.ovh-tabrow .ovh-allbtn, .ovh-tabrow .ovh-edit { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 42px; border-radius: 12px; cursor: pointer;++ font-size: 13.5px; font-weight: 700; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent); transition: background-color .15s, color .15s, transform .12s; -webkit-tap-highlight-color: transparent; }++.ovh-tabrow .ovh-allbtn { padding: 0 12px 0 10px; }++.ovh-tabrow .ovh-edit { width: 42px; }++.ovh-tabrow .ovh-edit.is-done { width: auto; padding: 0 14px 0 11px; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }++.ovh-tabrow .ovh-allbtn svg, .ovh-tabrow .ovh-edit svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }++.ovh-tabrow .ovh-allbtn b { font-weight: 800; font-variant-numeric: tabular-nums; color: var(--text-primary, #e8ecf3); }++.ovh-tabrow .ovh-allbtn:focus-visible, .ovh-tabrow .ovh-edit:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++.ovh-tabrow .ovh-allbtn:active, .ovh-tabrow .ovh-edit:active { transform: scale(.94); }++@media (hover: hover) and (pointer: fine) { .ovh-tabrow .ovh-allbtn:hover, .ovh-tabrow .ovh-edit:not(.is-done):hover { color: var(--text-primary, #e8ecf3); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 13%, transparent); } }++@container ovh (max-width: 380px) { .ovh-tabrow .ovh-allbtn span { display: none; } }++++/* Your own AI, not yet set up: one slim row under you (its mark, two short lines, Set it up, ×). */++.ovh-notice .ovh-actor.is-invite { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 8px 8px 8px 10px; }++.ovh-notice .ovh-actor.is-invite .ovh-face { flex: none; grid-row: auto; align-self: center; width: 40px; height: 40px; border-radius: 12px; }++.ovh-notice .ovh-actor.is-invite .ovh-actor-txt { display: grid; flex: 1; min-width: 0; gap: 1px; }++.ovh-notice .ovh-actor.is-invite .ovh-actor-txt small { -webkit-line-clamp: 2; font-size: 12.5px; line-height: 1.3; }++.ovh-notice .ovh-actor.is-invite .ovh-acts { flex: none; display: flex; flex-direction: row; align-items: center; gap: 4px; grid-column: auto; padding: 0; }++.ovh-notice .ovh-actor.is-invite .ovh-go { height: 38px; padding: 0 14px; }++.ovh-notice .ovh-actor.is-invite .ovh-x { display: grid; place-items: center; width: 38px; min-height: 38px; height: 38px; padding: 0; }++.ovh-notice .ovh-actor.is-invite .ovh-x svg { width: 16px; height: 16px; }++++/* Names in the brand face (OV Brand, Nunito 700–900, loaded by the navbar on every site): app names, the tabs, section++ heads; the words you read (times, notes) stay in the UI face. Section heads are names now, not small capitals. */++.ovh-name, .ovh-tab, .ovh-sec > span, .ovh-allbtn, .ovh-hist-day, .ovh-htxt b, .ovh-who-txt b, .ovh-actor-txt b { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; }++.ovh-grid:not(.ovh-recent) .ovh-name { font-size: 13px; font-weight: 800; letter-spacing: .005em; color: color-mix(in srgb, var(--text-primary, #e8ecf3) 92%, transparent); opacity: 1; }++.ovh-tabrow .ovh-tab { font-weight: 800; letter-spacing: .005em; }++.ovh-sec { font-size: 15px; font-weight: 800; letter-spacing: .005em; text-transform: none; color: var(--text-primary, #e8ecf3); }++.ovh-sec small { font-family: inherit; }++.ovh-hist-day { font-size: 13px; font-weight: 800; letter-spacing: .01em; color: var(--text-secondary, #a8b3c4); }++.ovh-who-txt b { font-size: 16px; font-weight: 800; letter-spacing: .005em; }++.ovh-actor-txt b { font-weight: 800; }++.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic { gap: 7%; padding: 11%; }++++/* The AI card: its face says what it is doing (a glyph on its picture's corner, or the whole face), working ones breathe++ (Calm: slower; Off: still); Open is a round arrow, Answer a pill with its glyph. */++.ovh-actor .ovh-face { position: relative; overflow: visible; }++.ovh-actor .ovh-face img { border-radius: 50%; }++.ovh-doing { position: absolute; right: -4px; bottom: -4px; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; color: #fff;++ background: linear-gradient(140deg, var(--accent, #3b82f6), color-mix(in srgb, var(--accent, #3b82f6) 45%, #a855f7)); box-shadow: 0 0 0 2px var(--bg-secondary, #141a24); }++.ovh-doing svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.3; stroke-linecap: round; stroke-linejoin: round; }++.ovh-face.is-glyph { border-radius: 13px; background: linear-gradient(140deg, var(--accent, #3b82f6), color-mix(in srgb, var(--accent, #3b82f6) 45%, #a855f7)); }++.ovh-face.is-glyph .ovh-doing { position: static; width: 100%; height: 100%; border-radius: inherit; background: none; box-shadow: none; }++.ovh-face.is-glyph .ovh-doing svg { width: 22px; height: 22px; }++.ovh-doing.is-waiting { background: linear-gradient(140deg, #f59e0b, #ef4444); }++.ovh-face.is-glyph:has(.is-waiting) { background: linear-gradient(140deg, #f59e0b, #ef4444); }++.ovh-face.is-glyph:has(.is-working) { animation: ovh-breathe 2.4s ease-in-out infinite; }++@keyframes ovh-breathe { 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); } }++html[data-ov-motion=reduced] .ovh-face.is-glyph:has(.is-working) { animation-duration: 5s; }++html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none; }++.ovh-actor .ovh-go.is-ic { flex: none; display: grid; place-items: center; width: 38px; height: 38px; padding: 0; border-radius: 50%; color: var(--accent-light, var(--accent, #60a5fa));++ background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); transition: transform .2s cubic-bezier(.3, 1.4, .4, 1), background-color .15s; }++.ovh-actor .ovh-go.is-ic svg, .ovh-actor .ovh-go.is-answer svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }++.ovh-actor .ovh-go.is-answer { flex: none; display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 14px 0 11px; border-radius: 999px; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); font-weight: 750; font-size: 13.5px; }++@media (hover: hover) and (pointer: fine) { .ovh-actor:hover .ovh-go.is-ic { transform: translate(2px, -2px); background: color-mix(in srgb, var(--accent, #3b82f6) 24%, transparent); } }`;document.head.appendChild(st);}@@@ -337,6 +542,8 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durconst ICON = {pencil: [['path', { d: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16z' }], ['path', { d: 'M13.5 6.5l4 4' }]],check: [['path', { d: 'M5 12.5l4.5 4.5L19 7.5' }]],++ bell: [['path', { d: 'M6 14.6V9.5a6.1 6.1 0 0 1 12.2 0v5.1l1.5 2.1h-15.3Z' }], ['path', { d: 'M10 18.7a2.1 2.1 0 0 0 4 0' }]],++ history: [['path', { d: 'M3.8 12.1a8.3 8.3 0 1 0 2.5-5.9L3.8 8.5M3.8 4.2v4.2h4.2' }], ['path', { d: 'M12 7.9v4.3l3.1 1.9' }]],attach: [['path', { d: 'M12 5v14M5 12h14' }]],plus: [['circle', { cx: 9.5, cy: 8.5, r: 3.2 }], ['path', { d: 'M3.5 18.5c1-2.900 3.300-4.400 6-4.400 1.300 0 2.500.300 3.500 1' }], ['path', { d: 'M18 13.500v6M15 16.500h6' }]],out: [['path', { d: 'M13.500 4.500h4a1.500 1.500 0 0 1 1.500 1.500v12a1.500 1.500 0 0 1-1.500 1.500h-4' }], ['path', { d: 'M9.500 8l-4 4 4 4M5.500 12h9.500' }]],@@@ -355,11 +562,30 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durchat: [['path', { d: 'M5 5h11a2.5 2.5 0 0 1 2.5 2.5v6A2.5 2.5 0 0 1 16 16H10l-4.5 3.5V16H5a2.5 2.5 0 0 1-2.5-2.5v-6A2.5 2.5 0 0 1 5 5Z' }], ['path', { d: 'M7 9.5h7M7 12.5h4' }]],page: [['path', { d: 'M6.5 3h7.5l4 4v14h-11.5Z' }], ['path', { d: 'M14 3v4h4M9.5 12h5M9.5 15.5h5' }]],play: [['path', { d: 'M8 5.5v13l10.5-6.5Z' }]],++ // 4.223.0: drawings from the glyph registry (openvibe-shared/glyphs.js).++ arrow: [['path', { d: 'M7 17L17 7M9 7h8v8' }]],++ clock: [['circle', { cx: 12, cy: 12, r: 8.5 }], ['path', { d: 'M12 7.5V12l3 2' }]],++ back: [['path', { d: 'M20.8 12H4M10.3 5.7l-6.3 6.3l6.3 6.3' }]],++ game: [['rect', { x: 3.7, y: 7.2, width: 16.7, height: 9.7, rx: 4.4 }], ['path', { d: 'M7.6 10.7v2.7M6.4 12h2.7M15.1 11.6h0M17.4 12.9h0' }]],++ video: [['rect', { x: 3.4, y: 6.4, width: 12.3, height: 11.3, rx: 1.9 }], ['path', { d: 'M15.6 10.2l4.7-2.8v9.4l-4.7-2.8' }]],++ book: [['path', { d: 'M12 6.3C10 4.8 7 4.3 3.5 4.9v13.2c3.5-0.6 6.5-0.1 8.5 1.4c2-1.5 5-2 8.5-1.4V4.9C17 4.3 14 4.8 12 6.3Z' }], ['path', { d: 'M12 6.3v13.2' }]],++ store: [['path', { d: 'M3.7 8.8L5.3 3.7h13.4L20.3 8.8M3.7 8.8h16.5v1.6a2.8 2.8 0 0 1-5.4 0a2.8 2.8 0 0 1-5.6 0A2.8 2.8 0 0 1 3.7 10.4ZM4.8 12.9v7.2h14.5v-7.2M9.9 20.1v-4.1h4.1v4.1' }]],++ wrench: [['path', { d: 'M14.6 3.6a5.2 5.2 0 0 0-4.6 7.3L3.7 17.3V20.3h3.1l6.4-6.4a5.2 5.2 0 0 0 7.3-4.6l-3.2 1.2l-2.5-2.5l1.2-3.2Z' }]],++ code: [['path', { d: 'M7.8 7.8l-4.2 4.2l4.2 4.2M16.2 7.8l4.2 4.2l-4.2 4.2M14.2 4.6l-4.2 14.8' }]],++ spark: [['path', { d: 'M10.4 3.6l1.7 4.8L17 10.1l-4.8 1.7L10.4 16.7l-1.7-4.8L3.9 10.1l4.8-1.7ZM17 14.8l0.8 2.1L19.8 17.6l-2.1 0.8L17 20.3l-0.8-2.1L14.3 17.6l2.1-0.8Z' }]],++ user: [['circle', { cx: 12, cy: 7.4, r: 3.8 }], ['path', { d: 'M4.3 20.5c0-3.9 3.4-6.6 7.7-6.6s7.7 2.6 7.7 6.6' }]],++ star: [['path', { d: 'M12 4l2.6 5.3l5.7 0.6l-4.3 3.9l1.2 5.7L12 16.8l-5.2 2.9l1.2-5.7l-4.3-3.9l5.7-0.6Z' }]],++ toolbox: [['rect', { x: 3.5, y: 8.1, width: 17, height: 11.3, rx: 1.9 }], ['path', { d: 'M9.2 8.1V5.8A1.4 1.4 0 0 1 10.6 4.3h2.8A1.4 1.4 0 0 1 14.8 5.8V8.1M3.5 12.8h17M8.2 11.4v2.8M15.8 11.4v2.8' }]],++ compass: [['circle', { cx: 12, cy: 12, r: 8.5 }], ['path', { d: 'm15.6 8.4-2.1 5.1-5.1 2.1 2.1-5.1Z' }]],++ home: [['path', { d: 'M3.6 10.6L12 3.3l8.4 7.3v8.4a1 1 0 0 1-1 1h-4.2v-6.3h-6.3v6.3H4.7a1 1 0 0 1-1-1Z' }]],++ live: [['circle', { cx: 12, cy: 11.8, r: 2 }], ['path', { d: 'M8.6 8.4a4.9 4.9 0 0 0 0 7.1M15.4 8.4a4.9 4.9 0 0 1 0 7.1M6.3 6.1a8.1 8.1 0 0 0 0 11.9M17.7 6.1a8.1 8.1 0 0 1 0 11.9' }]],++ shield: [['path', { d: 'M12 3.7L5.5 6.6v5.6c0 4.1 2.8 7.1 6.5 8.4c3.7-1.3 6.5-4.2 6.5-8.4V6.6Z' }]],};function svgIcon(name) {const NS = 'http://www.w3.org/2000/svg';const s = document.createElementNS(NS, 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true');-- ICON[name].forEach(([tag, at]) => { const n = document.createElementNS(NS, tag); Object.keys(at).forEach((k) => n.setAttribute(k, String(at[k]))); s.append(n); });++ const def = ICON[name] || ICON.grid;++ def.forEach(([tag, at]) => { const n = document.createElementNS(NS, tag); Object.keys(at).forEach((k) => n.setAttribute(k, String(at[k]))); s.append(n); });return s;}@@@ -368,6 +594,14 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durconst 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 section's glyph from its name, so every group of apps is marked (the owner, 11-12: "each section should have an icon"). */++ function groupGlyph(name) {++ const n = String(name || '').toLowerCase();++ return /your|yours/.test(n) ? 'user' : /watch|stream|video|live/.test(n) ? 'video' : /talk|belong|chat|social|community/.test(n) ? 'chat'++ : /play|game|fun/.test(n) ? 'game' : /read|ask|news|learn/.test(n) ? 'book' : /shop|money|buy|market/.test(n) ? 'store'++ : /quick/.test(n) ? 'toolbox' : /tool/.test(n) ? 'wrench' : /\bai\b|make/.test(n) ? 'spark' : /develop|build|code/.test(n) ? 'code'++ : /home|popular/.test(n) ? 'home' : /used|most/.test(n) ? 'star' : /recent|where|history/.test(n) ? 'history' : 'compass';++ }/** 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 } = {}) {@@@ -404,11 +638,13 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durstyles();el.classList.add('ovh');el.setAttribute('data-sec', 'home');-- const state = { view: 'home', edit: false, folder: null, filter: '', drag: null, suppress: 0 };++ // 4.223.0: You is three tabs (Apps first); the one a person chose stays for this tab of the browser.++ let keptTab = null; try { keptTab = sessionStorage.getItem('ovh-you-tab'); } catch { /* */ }++ const state = { view: 'home', edit: false, folder: null, filter: '', drag: null, suppress: 0, tab: o.startTab || (['apps', 'notifications', 'recent'].includes(keptTab) ? keptTab : 'apps') };let L = layoutOf(o);const save = () => { const keep = { home: L.home.slice(0, HOME_MAX), folders: L.folders.map((f) => ({ id: f.id, name: f.name, apps: f.apps.slice(0, 400) })) }; writeLocal(keep); pushRemote(o, keep); L.arranged = true; };const icons = () => { if (typeof o.mountIcons === 'function') { try { o.mountIcons(el); } catch { /* optional */ } } };-- let actorBox = null, meBox = null;++ let actorBox = null, meBox = null, notesBox = null;// The Look panel (theme, text size, motion, icons) opens under your name, drawn by the navbar (the account menu's).if (typeof o.look === 'function') { try { o.look(el); } catch { /* the button stays a link to openvibe.design/look */ } }@@@ -434,26 +670,135 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durconst gear = h('a', 'ovh-ib'); gear.append(svgIcon('gear')); gear.href = 'https://openvibe.network/my'; gear.setAttribute('aria-label', 'Account and settings'); gear.title = 'Account and settings';bar.append(who);const m = o.me || {};-- if (m.add && safe(m.add)) { const add = h('a', 'ovh-ib'); add.append(svgIcon('plus')); add.href = m.add; add.setAttribute('aria-label', 'Add or switch account'); add.setAttribute('data-ov-tip', 'Add or switch account'); bar.append(add); }-- bar.append(look, gear);-- if (typeof m.out === 'function') { const out = h('button', 'ovh-ib ovh-out'); out.type = 'button'; out.append(svgIcon('out')); out.setAttribute('aria-label', 'Sign out'); out.setAttribute('data-ov-tip', 'Sign out'); out.addEventListener('click', () => { try { m.out(); } catch { /* the bar signs out */ } }); bar.append(out); }++ const acts = h('div', 'ovh-acts'); acts.setAttribute('role', 'group'); acts.setAttribute('aria-label', 'Yours');++ // This site's own items (your channel, Admin, what a site adds) as round buttons beside Display and Settings:++ // a known one with its glyph, a site's own with its short name (4.223.0; it was a row of its own).++ const OWN_GLYPH = { 'ov-channel': 'live', 'ov-admin': 'shield' };++ const own = (Array.isArray(m.own) ? m.own : []).filter((it) => it && it.label && (typeof it.onClick === 'function' || (it.href && safe(it.href)))).slice(0, 6);++ own.forEach((it) => {++ const g = OWN_GLYPH[it.id] || (ICON[it.glyph] ? it.glyph : '');++ const b = it.href ? h('a', `ovh-ib is-own${g ? '' : ' is-text'}`) : h('button', `ovh-ib is-own${g ? '' : ' is-text'}`);++ if (it.href) b.href = it.href; else { b.type = 'button'; b.addEventListener('click', () => { try { it.onClick(); } catch { /* the item is the site's */ } }); }++ if (g) b.append(svgIcon(g)); else b.textContent = it.short || it.label;++ b.setAttribute('aria-label', it.label); b.setAttribute('data-ov-tip', it.label);++ acts.append(b);++ });++ if (m.add && safe(m.add)) { const add = h('a', 'ovh-ib'); add.append(svgIcon('plus')); add.href = m.add; add.setAttribute('aria-label', 'Add or switch account'); add.setAttribute('data-ov-tip', 'Add or switch account'); acts.append(add); }++ acts.append(look, gear);++ if (typeof m.out === 'function') { const out = h('button', 'ovh-ib ovh-out'); out.type = 'button'; out.append(svgIcon('out')); out.setAttribute('aria-label', 'Sign out'); out.setAttribute('data-ov-tip', 'Sign out'); out.addEventListener('click', () => { try { m.out(); } catch { /* the bar's own */ } }); acts.append(out); }++ bar.append(acts);const panel = h('div', 'ud-look ovh-me-look'); panel.hidden = true;meBox.append(bar, panel);-- // This site's own items, your status, Admin: small, in one line that wraps; nothing that the menu has elsewhere.-- const own = (Array.isArray(m.own) ? m.own : []).filter((it) => it && it.label && (typeof it.onClick === 'function' || (it.href && safe(it.href)))).slice(0, 8);-- if (own.length) {-- const row = h('div', 'ovh-own'); row.setAttribute('role', 'group'); row.setAttribute('aria-label', 'Yours on this site');-- own.forEach((it) => {-- const el2 = it.href ? h('a', 'ovh-own-i') : h('button', 'ovh-own-i');-- if (it.href) el2.href = it.href; else { el2.type = 'button'; el2.addEventListener('click', () => { try { it.onClick(); } catch { /* the item is the site's */ } }); }-- el2.textContent = it.short || it.label;-- row.append(el2);-- });-- meBox.append(row);-- }return meBox;}++ /** The three tabs under your row; Notifications carries how many wait. */++ let countBound = false, inkWas = null, tabWas = null;++ function youTabs() {++ const bar = h('div', 'ovh-tabs'); bar.setAttribute('role', 'tablist'); bar.setAttribute('aria-label', 'You');++ if (!countBound && typeof document !== 'undefined') {++ countBound = true;++ const onCount = () => {++ if (!el.isConnected) { document.removeEventListener('openvibe-notifications-count', onCount); countBound = false; return; }++ const t = el.querySelector('.ovh-tab[data-tab="notifications"]'); if (!t) return;++ let n = 0; try { n = typeof o.unread === 'function' ? Number(o.unread()) || 0 : 0; } catch { n = 0; }++ let b = t.querySelector('.ovh-tab-n');++ if (n > 0) { if (!b) { b = h('b', 'ovh-tab-n'); t.append(b); } b.textContent = n > 99 ? '99+' : String(n); t.setAttribute('aria-label', `Notifications, ${n} new`); }++ else if (b) { b.remove(); t.setAttribute('aria-label', 'Notifications'); }++ };++ document.addEventListener('openvibe-notifications-count', onCount);++ }++ let unread = 0; try { unread = typeof o.unread === 'function' ? Number(o.unread()) || 0 : 0; } catch { unread = 0; }++ [['apps', 'Apps', 'grid'], ['notifications', 'Notifications', 'bell'], ['recent', 'Recent', 'history']].forEach(([id, label, ic]) => {++ const b = h('button', 'ovh-tab'); b.type = 'button'; b.setAttribute('role', 'tab'); b.dataset.tab = id;++ b.setAttribute('aria-selected', String(state.tab === id));++ b.append(svgIcon(ic), h('span', 'ovh-tab-t', label)); b.setAttribute('aria-label', label); b.setAttribute('data-ov-tip', label);++ if (id === 'notifications' && unread > 0) { b.append(h('b', 'ovh-tab-n', unread > 99 ? '99+' : String(unread))); b.setAttribute('aria-label', `Notifications, ${unread} new`); }++ b.addEventListener('click', () => {++ if (state.tab === id) return;++ const on = bar.querySelector('.ovh-tab[aria-selected="true"]');++ inkWas = on ? { x: on.offsetLeft, w: on.offsetWidth } : null;++ tabWas = state.tab;++ state.tab = id; try { sessionStorage.setItem('ovh-you-tab', id); } catch { /* */ } paint();++ });++ // Arrow keys move along the tabs (a tablist's own keys).++ b.addEventListener('keydown', (e) => {++ if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;++ const all = [...bar.querySelectorAll('.ovh-tab')]; const k = all.indexOf(b);++ const next = all[(k + (e.key === 'ArrowRight' ? 1 : all.length - 1)) % all.length];++ e.preventDefault(); next.click(); requestAnimationFrame(() => { const t = el.querySelector(`.ovh-tab[data-tab="${next.dataset.tab}"]`); if (t) t.focus(); });++ });++ b.tabIndex = state.tab === id ? 0 : -1;++ bar.append(b);++ });++ // The ink under the chosen tab: it starts where the last one was and slides over (Calm: a short fade; Off: none).++ const ink = h('i', 'ovh-tab-ink'); ink.setAttribute('aria-hidden', 'true'); bar.prepend(ink);++ const order = ['apps', 'notifications', 'recent'];++ const from = inkWas, dir = tabWas ? Math.sign(order.indexOf(state.tab) - order.indexOf(tabWas)) : 0;++ inkWas = null; tabWas = null;++ requestAnimationFrame(() => {++ const on = bar.querySelector('.ovh-tab[aria-selected="true"]'); if (!on) return;++ if (from) { ink.style.transition = 'none'; ink.style.transform = `translateX(${from.x}px)`; ink.style.width = `${from.w}px`; void ink.offsetWidth; ink.style.transition = ''; }++ ink.style.transform = `translateX(${on.offsetLeft}px)`; ink.style.width = `${on.offsetWidth}px`; ink.classList.add('is-on');++ });++ if (dir) el.dataset.enter = dir > 0 ? 'right' : 'left'; else delete el.dataset.enter;++ return bar;++ }++ /** The whole notifications list, made once per opening (the menu gives the mount): a repaint keeps its place. */++ function notes() {++ if (notesBox) return notesBox;++ notesBox = h('div', 'ovh-notes');++ if (typeof o.notifications === 'function') { try { o.notifications(notesBox); } catch { notesBox.append(h('p', 'ovh-hint', 'Notifications did not load. Close the menu and open it again.')); } }++ else notesBox.append(h('p', 'ovh-hint', 'Notifications are on openvibe.network/my.'));++ return notesBox;++ }++++ /** Recent (4.223.0; the owner, 11-12: "jump back in … shows a little more … like in the feed, longer"; "view full++ * history doesn't even work"): every place you were, newest first, in days, each a card with its whole title. */++ function history() {++ const items = (o.back || []).filter((x) => x && safe(x.url));++ if (!items.length) return null;++ const wrap = h('div', 'ovh-hist');++ const dayOf = (at) => {++ const t = Date.parse(at || ''); if (!t) return 'Earlier';++ const d0 = new Date(); d0.setHours(0, 0, 0, 0);++ const d = new Date(t); d.setHours(0, 0, 0, 0);++ const diff = Math.round((d0 - d) / 864e5);++ return diff <= 0 ? 'Today' : diff === 1 ? 'Yesterday' : diff < 7 ? 'This week' : 'Earlier';++ };++ const list = h('div', 'ovh-hist-list'); list.setAttribute('role', 'list'); list.setAttribute('aria-label', 'Where you were');++ const more = h('button', 'ovh-all ovh-hist-more', 'Show more'); more.type = 'button';++ let shown = 12;++ const draw = () => {++ list.textContent = '';++ let last = '';++ items.slice(0, shown).forEach((x, i) => {++ const d = dayOf(x.at);++ if (d !== last) { last = d; list.append(h('div', 'ovh-hist-day', d)); }++ const live = x.tag === 'Live';++ const c = h('a', `ovh-hrow${live ? ' is-live' : ''}`); c.href = safe(x.url); c.setAttribute('role', 'listitem'); c.style.setProperty('--i', String(i % 12));++ const ic = h('span', 'ovh-ic'); ic.style.setProperty('--ovh-h', String(hueOf(x.site || 'ov'))); ic.append(siteIcon(x.site || 'ov', 44));++ const t = h('span', 'ovh-htxt');++ t.append(h('b', '', x.title));++ const meta = h('small');++ if (live) meta.append(h('span', 'ovh-live', 'LIVE'));++ const pre = String(x.metaPre || '').replace(/^Live now(?: · )?/, '');++ const when = h('span', '', [pre, ago(x.at)].filter(Boolean).join(' · ') || x.meta || '');++ if (x.at) { when.dataset.at = x.at; when.dataset.pre = pre; } // search.js keeps the times current++ meta.append(when); t.append(meta);++ c.append(ic, t);++ list.append(c);++ });++ more.hidden = shown >= items.length;++ };++ more.addEventListener('click', () => { shown += 12; draw(); icons(); });++ draw();++ const head = sec('Where you were', 'history');++ const full = h('a', 'ovh-link', 'Full history'); full.href = 'https://openvibe.my/history'; head.append(full);++ wrap.append(head, list, more);++ return wrap;++ }++/** Jump back in: the last places, as cards you swipe; a stream that is live now says so. */function backRail() {const items = (o.back || []).filter((x) => x && safe(x.url)).slice(0, 8);@@@ -490,7 +835,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durconst rows = (o.usage || []).map((u) => ({ a: L.byId.get(u.id), n: u.count })).filter((r) => r.a && r.n > 0).sort((x, y) => y.n - x.n).slice(0, 5);if (rows.length < 2) return null;const wrap = h('div');-- const head = sec('Most used');++ const head = sec('Most used', 'star');// Your stats on Report (private by default; you choose what is public), as the section's link.const more = h('a', 'ovh-link', 'Your stats'); more.href = 'https://openvibe.report/me';head.append(more);@@@ -524,33 +869,41 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durconst calm = (x) => { x.classList.add('ovh-calm'); x.inert = state.edit; return x; };// The account's block wherever the bar has a person (a site with its own session may have no token for// the network's reads yet: sign out and the rest must still be there); else the short bar.-- if ((o.user && !o.user.is_anon) || signedIn(o)) el.append(calm(me()));-- if (signedIn(o)) { actorBox = actorBox || h('div'); el.append(actorBox); }-- const head = sec(L.arranged || signedIn(o) ? 'Your home' : 'Popular on OpenVibe');-- const edit = h('button', state.edit ? 'ovh-edit is-done' : 'ovh-edit');-- edit.type = 'button';-- edit.append(svgIcon(state.edit ? 'check' : 'pencil'));-- if (state.edit) edit.append(h('span', '', 'Done'));-- edit.setAttribute('aria-label', state.edit ? 'Done arranging Home' : 'Edit Home');-- edit.title = state.edit ? 'Done' : 'Edit Home: move apps, remove them, add folders';-- edit.setAttribute('aria-pressed', state.edit ? 'true' : 'false');-- edit.addEventListener('click', () => setEdit(!state.edit));-- // All apps sits beside Edit, small, with its count (the owner, 2026-11-10: "all apps should just be in a-- // button in the corner so it's more compact"): it was a bar the width of the sheet under the grid.-- const total = L.byId.size;-- const all = h('button', 'ovh-allbtn'); all.type = 'button';-- all.append(svgIcon('grid'), h('span', '', 'All'), h('b', '', String(total)));-- all.setAttribute('aria-label', `All apps (${total})`); all.title = `All apps (${total})`;-- all.addEventListener('click', () => { state.view = 'drawer'; paint(); });-- const tools = h('div', 'ovh-headtools'); tools.append(all, edit);-- head.append(tools);-- el.append(head);++ const top = h('div', 'ovh-top');++ if ((o.user && !o.user.is_anon) || signedIn(o)) top.append(calm(me()));++ if (signedIn(o)) { actorBox = actorBox || h('div', 'ovh-notice'); top.append(actorBox); }++ if (top.childNodes.length) el.append(top);++ // 4.223.0 (the owner, 11-12: "at the top … the goosely Online bar. Under that is tabs … put the apps more front++ // facing … the first tab"; then "compact … waste of space"): one row of tabs, and on Apps the All and Edit++ // buttons at its end, so no head of its own. Edit stays pressable while the rest is quiet.++ const tab = signedIn(o) ? state.tab : 'apps';++ const tools = tab === 'apps' ? homeTools() : null;++ if (signedIn(o)) {++ const row = h('div', 'ovh-tabrow');++ row.append(calm(youTabs()));++ if (tools) row.append(tools);++ el.append(row);++ }++ const later = () => { icons(); if (actorBox && !actorBox.childNodes.length) actor(actorBox, o); };++ if (tab === 'notifications') { el.append(notes()); return later(); }++ if (tab === 'recent') {++ const u = usage(), b = history();++ if (u) el.append(u);++ if (b) el.append(b);++ if (!u && !b) { const e = h('div', 'ovh-empty'); e.append(svgIcon('history')); const w = h('div'); w.append(h('b', '', 'Nothing here yet'), h('span', '', 'The places you open and the apps you use most show up here.')); e.append(w); el.append(e); }++ return later();++ }++ if (!signedIn(o)) {++ const head = sec(L.arranged ? 'Your home' : 'Popular on OpenVibe', 'home');++ head.append(tools);++ el.append(head);++ }const grid = h('div', 'ovh-grid'); grid.setAttribute('aria-label', 'Your home');L.home.forEach((id, i) => grid.append(homeTile(id, i)));el.append(grid);if (state.edit) el.append(h('p', 'ovh-hint', 'Drag an app to move it, or use the arrow keys. − takes it off Home; All puts it back.'));-- // Under your home: what you use most (shortcuts), then where you were last (history).-- { const u = usage(); if (u) el.append(calm(u)); const b = backRail(); if (b) el.append(calm(b)); }++ // Signed out there are no tabs: what you use most and where you were stay under the apps.++ if (!signedIn(o)) { const u = usage(); if (u) el.append(calm(u)); const b = backRail(); if (b) el.append(calm(b)); }icons();if (signedIn(o) && actorBox && !actorBox.childNodes.length) actor(actorBox, o);}@@@ -677,7 +1030,26 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durwindow.addEventListener('pointercancel', end);}-- function setEdit(on) { state.edit = on; paint(); const b = el.querySelector('.ovh-sec button'); if (b && !on) b.focus(); }++ function homeTools() {++ const edit = h('button', state.edit ? 'ovh-edit is-done' : 'ovh-edit');++ edit.type = 'button';++ edit.append(svgIcon(state.edit ? 'check' : 'pencil'));++ if (state.edit) edit.append(h('span', '', 'Done'));++ edit.setAttribute('aria-label', state.edit ? 'Done arranging Home' : 'Edit Home');++ edit.title = state.edit ? 'Done' : 'Edit Home: move apps, remove them, add folders';++ edit.setAttribute('aria-pressed', state.edit ? 'true' : 'false');++ edit.addEventListener('click', () => setEdit(!state.edit));++ // All apps sits beside Edit, small, with its count (the owner, 2026-11-10: "all apps should just be in a++ // button in the corner so it's more compact").++ const total = L.byId.size;++ const all = h('button', 'ovh-allbtn'); all.type = 'button';++ all.append(svgIcon('grid'), h('span', '', 'All'), h('b', '', String(total)));++ all.setAttribute('aria-label', `All apps (${total})`); all.title = `All apps (${total})`;++ all.addEventListener('click', () => { state.view = 'drawer'; paint(); });++ const tools = h('div', 'ovh-headtools'); tools.append(all, edit);++ return tools;++ }++ function setEdit(on) { state.edit = on; paint(); const b = el.querySelector('.ovh-edit'); if (b && !on) b.focus(); }function paintFolder() {const f = L.folders.find((x) => x.id === state.folder);@@@ -695,9 +1067,10 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durfunction paintDrawer() {el.textContent = '';-- el.append(backHead('All apps'));-- const field = h('input', 'ovh-filter'); field.type = 'search'; field.placeholder = 'Find an app'; field.setAttribute('aria-label', 'Find an app'); field.value = state.filter;-- el.append(field);++ const head = backHead('All apps');++ const field = h('input', 'ovh-filter'); field.type = 'search'; field.placeholder = `Find among ${L.byId.size} apps`; field.setAttribute('aria-label', 'Find an app'); field.value = state.filter;++ head.querySelector('b').replaceWith(field);++ el.append(head);const body = h('div');el.append(body);const groups = (o.groups && o.groups.length ? o.groups : [{ id: '', name: 'OpenVibe' }]).slice();@@@ -718,7 +1091,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durL.folders.forEach((f) => { if (!q || f.name.toLowerCase().includes(q)) sets.push([`${f.name} folder`, [{ folder: f }]]); });if (!sets.length) { body.append(h('p', 'ovh-note', 'No app has that name.')); return; }sets.forEach(([name, list]) => {-- body.append(sec(`${name} (${list.length})`));++ body.append(sec(name, groupGlyph(name), list.length));const grid = h('div', 'ovh-grid');list.forEach((a) => {const id = a.folder ? `folder:${a.folder.id}` : a.id;@@@ -742,12 +1115,20 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durfunction backHead(title) {const head = h('div', 'ovh-head');-- const back = h('button', 'ovh-back', '← Home'); back.type = 'button';++ const back = h('button', 'ovh-back'); back.type = 'button'; back.append(svgIcon('back'));++ back.setAttribute('aria-label', 'Back to your home'); back.setAttribute('data-ov-tip', 'Back to your home');back.addEventListener('click', () => { state.view = 'home'; state.folder = null; paint(); const b = el.querySelector('.ovh-allbtn'); if (b) b.focus(); });head.append(back, h('b', '', title));return head;}-- function sec(text) { const s = h('div', 'ovh-sec'); s.append(h('span', '', text)); return s; }++ function sec(text, glyph, count) {++ const s = h('div', 'ovh-sec');++ if (glyph) { const i = h('i', 'ovh-sec-ic'); i.append(svgIcon(glyph)); s.append(i); }++ s.append(h('span', '', text));++ if (count != null) s.append(h('b', 'ovh-sec-n', String(count)));++ s.append(h('i', 'ovh-sec-sp'));++ return s;++ }function paint() { if (state.view === 'folder') return paintFolder(); if (state.view === 'drawer') return paintDrawer(); return paintHome(); }paint();@@@ -769,6 +1150,23 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur}// ── Your Actor, on top of You ──────────────────────────────────────────────────────────────────────────────++ /** The glyph for what an agent is doing now (the owner, 11-12: "put an icon depending on the agent status … custom for++ * the agent"): its step's words first, then its state. A site or Actor may name one (`a.now.glyph`) from the registry. */++ function doingGlyph(a, st) {++ const named = a && a.now && a.now.glyph; if (named && ICON[named]) return named;++ const t = String((a && a.now && a.now.current_step) || '').toLowerCase();++ if (st === 'waiting') return 'chat';++ if (st === 'paused') return 'clock';++ if (/read|review|look|watch/.test(t)) return 'book';++ if (/search|find|hunt|scan|brows/.test(t)) return 'compass';++ if (/writ|draft|post|reply|edit/.test(t)) return 'pencil';++ if (/code|build|fix|deploy|test/.test(t)) return 'code';++ if (/stream|live|clip|video/.test(t)) return 'live';++ if (/buy|deal|shop|price|sell/.test(t)) return 'store';++ if (/game|play/.test(t)) return 'game';++ if (/think|plan|idea/.test(t)) return 'spark';++ return st === 'working' ? 'spark' : 'clock';++ }async function actor(box, o) {const base = String(o.actorBase || ACTOR).replace(/\/+$/, '');let a = null;@@@ -786,13 +1184,20 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durconst face = h('span', 'ovh-face');const pic = a.avatar && /^https:\/\//.test(String(a.avatar.url || '')) ? a.avatar.url : '';if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.append(img); } else face.textContent = String(a.name || 'A').trim().charAt(0).toUpperCase() || 'A';++ // What it is doing, as a glyph on its face (its whole face when it has no picture).++ const doing = h('span', `ovh-doing is-${st}`); doing.append(svgIcon(doingGlyph(a, st))); doing.setAttribute('aria-hidden', 'true');++ if (!pic) { face.textContent = ''; face.classList.add('is-glyph'); }++ face.append(doing);const txt = h('span', 'ovh-actor-txt');const name = h('b', '', a.name || 'Your Actor');const chip = h('span', `ovh-chip is-${st}`); chip.append(h('span', 'ovh-dot'), document.createTextNode(label));name.append(chip);const now = a.now || {};txt.append(name, h('small', '', asks.length ? `It asks you: ${asks[0].text || ''}` : now.current_step ? String(now.current_step) : st === 'paused' ? 'Paused until you resume it' : 'Resting until its next goal is due'));-- b.append(face, txt, h('span', 'ovh-go', asks.length ? 'Answer' : 'Open'));++ const go = h('span', asks.length ? 'ovh-go is-answer' : 'ovh-go is-ic'); go.append(svgIcon(asks.length ? 'chat' : 'arrow'));++ if (asks.length) go.append(h('span', '', 'Answer'));++ b.setAttribute('aria-label', `${a.name || 'Your Actor'}, ${label}: ${asks.length ? 'answer it' : 'open it'}`);++ b.append(face, txt, go);b.addEventListener('click', () => {if (root.OpenVibeNavbar && typeof root.OpenVibeNavbar.openDock === 'function') { if (o.close) o.close(); try { root.OpenVibeNavbar.openDock('agent'); return; } catch { /* */ } }location.href = `${base}/you`;@@@ -808,7 +1213,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durcard.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 and runs your channel.'));++ txt.append(h('b', '', 'Your own AI'), h('small', '', 'It works for you while you watch.'));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'); no.append(svgIcon('x')); no.type = 'button'; no.setAttribute('aria-label', 'Not now'); no.title = 'Not now';@@@ -1135,8 +1540,18 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durparts.forEach(([k, g, word]) => { const s = h('span', ''); s.setAttribute('aria-label', `${num(n[k])} ${word}`); s.title = `${num(n[k])} ${word}`; s.append(glyph(g), document.createTextNode(count(n[k]))); row.append(s); });return row;}++ /** A length as people read it: 5203 → 1:26:43, 95 → 1:35; a length already written ("12:04") stays. */++ function lenOf(v) {++ const t = String(v == null ? '' : v).trim();++ if (!/^\d+(\.\d+)?$/.test(t)) return t.slice(0, 10);++ const n = Math.round(Number(t)); const hh = Math.floor(n / 3600), mm = Math.floor((n % 3600) / 60), ss = n % 60;++ return hh ? `${hh}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}` : `${mm}:${String(ss).padStart(2, '0')}`;++ }++ /** The Rundown's episodes (Live writes them from what streamers said): Network's kind `rundown`, or the older items++ * that came as "System" with a "Rundown:" title. */++ function isRundown(c) { return c.kind === 'rundown' || (/^Rundown:\s*/i.test(String(c.title || '')) && (!c.actor || /^system$/i.test(String(c.actor.name || '')))); }function feedCard(c) {-- const kind = String(c.kind || 'post');++ const kind = isRundown(c) ? 'rundown' : String(c.kind || 'post');const url = safe(c.url);if (!url) return null;if (ACTS.has(kind) && !(c.media && safe(c.media.thumb))) return activity(c);@@@ -1147,6 +1562,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dura.append(ic, t); return a;}const a = h('a', `ovfd-card ovfd-k-${kind.replace(/[^a-z]/g, '')}`); a.href = url;++ if (kind === 'rundown') a.setAttribute('aria-label', `The Rundown: ${String(c.title || '').replace(/^Rundown:\s*/i, '').slice(0, 160)}`);const head = h('span', 'ovfd-head');const face = h('span', 'ovfd-face');const av = c.actor && safe(c.actor.avatar);@@@ -1155,9 +1571,19 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durelse if (!c.actor) { face.classList.add('is-site'); face.style.setProperty('--ovh-h', String(hueOf(c.site || 'ov'))); face.append(siteIcon(c.site || 'ov', 20)); }else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();const who = h('span', 'ovfd-who');-- who.append(h('b', '', String((c.actor && c.actor.name) || c.label || c.site_name || '').slice(0, 60)));const meta = h('small', '');-- meta.append(siteIcon(c.site || 'ov', 14), document.createTextNode(` ${[c.site_name || '', c.actor ? when(c.at) : String(c.text || when(c.at) || '').slice(0, 60)].filter(Boolean).join(' · ')}`));++ if (kind === 'rundown') {++ // 4.223.0 (the owner, 11-12: "the rundown should look visually distinct versus the live feeds"): its own face and++ // name, whom it is about, and its picture marked as the Rundown's.++ face.textContent = ''; face.className = 'ovfd-face is-rundown'; face.append(glyph('page'));++ const about = c.subject && c.subject.name ? String(c.subject.name) : (/^Rundown:\s*([^:]{1,40}?)\s*(?:prep|:)/i.exec(String(c.title || '')) || [])[1] || '';++ who.append(h('b', '', 'The Rundown'));++ meta.append(siteIcon('live', 18), document.createTextNode(` ${[about ? `about ${about}` : 'from today’s streams', when(c.at)].filter(Boolean).join(' · ')}`));++ a.classList.add('ovfd-k-rundown');++ } else {++ who.append(h('b', '', String((c.actor && c.actor.name) || c.label || c.site_name || '').slice(0, 60)));++ meta.append(siteIcon(c.site || 'ov', 18), document.createTextNode(` ${[c.site_name || '', c.actor ? when(c.at) : String(c.text || when(c.at) || '').slice(0, 60)].filter(Boolean).join(' · ')}`));++ }who.append(meta);head.append(face, who);if (kind === 'live' && c.live_now) { a.classList.add('is-on'); head.append(h('span', 'ovfd-livechip', 'LIVE')); }@@@ -1169,7 +1595,9 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durconst r = Number(media.ratio) > 0 ? Math.max(0.8, Math.min(1.91, Number(media.ratio))) : (kind === 'pic' || kind === 'meme' ? 1 : 16 / 9);box.style.aspectRatio = String(r);const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = thumb; box.append(img);-- if (media.length) box.append(h('span', 'ovfd-len', String(media.length).slice(0, 10)));++ const len = media.length || c.duration_s;++ if (len) box.append(h('span', 'ovfd-len', lenOf(len)));++ if (kind === 'rundown') { const rib = h('span', 'ovfd-ribbon'); rib.append(glyph('page'), document.createTextNode('Rundown')); box.append(rib); }if (kind === 'live' && c.counts && c.counts.viewers) box.append(h('span', 'ovfd-len ovfd-viewers', `${count(c.counts.viewers)} watching`));a.append(box);if (kind === 'clip' || kind === 'video' || kind === 'live') watchPreview(box, c);@@@ -1177,6 +1605,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durlet title = String(c.title || '');if (kind === 'post' && /^Status:\s*/.test(title) && c.text && String(c.text).startsWith(title.replace(/^Status:\s*/, '').replace(/…$/, '').slice(0, 40))) title = '';if (kind === 'live') title = title.replace(/^.{1,60}? went live:?\s*/i, '') || title;++ if (kind === 'rundown') title = title.replace(/^Rundown:\s*/i, '') || title;if (title) a.append(h('b', 'ovfd-title', title.slice(0, 200)));if (c.text && (kind === 'post' || !thumb)) a.append(h('p', 'ovfd-text', String(c.text).slice(0, 500)));const counts = countsRow(c.counts);@@@ -1405,6 +1834,29 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur.ovfd-skel{display:block;height:180px;border-radius:16px;background:linear-gradient(90deg,color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent) 0,color-mix(in srgb,var(--text-primary,#e8ecf3) 9%,transparent) 50%,color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent) 100%);background-size:200% 100%;animation:ovfd-shimmer 1.4s ease-in-out infinite}.ovfd-more{min-height:1px}.ovfd-caught{text-align:center;padding:10px 0}@keyframes ovfd-shimmer{to{background-position:-200% 0}}++++/* 4.223.0 (the owner, 11-12: "these icons are tiny … fix this icon shit"): the kind glyph beside the time at 18 px and a++ site's face drawn as an app icon; lengths read as times. The Rundown has its own card: its mark, whom it is about, a++ ribbon on its picture, and the picture graded so it never reads as the stream's own card above it. */++.ovfd-who small{gap:6px;font-size:12.5px}++.ovfd-who small .ov-icon{position:relative;flex:none;width:18px;height:18px;overflow:hidden;border-radius:5px}++.ovfd-who small .ov-icon .ovi-ring,.ovfd-who small .ov-icon .ovi-bg,.ovfd-who small .ov-icon .ovi-comet,.ovfd-who small .ov-icon .ovi-prog{display:none}++.ovfd-who small .ov-icon svg{position:absolute;left:-26%;top:-26%;width:152%;height:152%;transform:none!important}++.ovfd-face{width:36px;height:36px}++.ovfd-face.is-site{position:relative}++.ovfd-face.is-site .ov-icon{position:absolute;inset:0;width:100%;height:100%;display:block;--ovi-glyph:hsl(var(--ovh-h,215) 90% 82%)}++.ovfd-face.is-site .ov-icon .ovi-ring,.ovfd-face.is-site .ov-icon .ovi-bg,.ovfd-face.is-site .ov-icon .ovi-comet,.ovfd-face.is-site .ov-icon .ovi-prog{display:none}++.ovfd-face.is-site .ov-icon svg{position:absolute;left:-13%;top:-13%;width:126%;height:126%;transform:none!important}++.ovfd-act .ovfd-face,.ovfd-tile,.ovfd-tile-wrap{width:44px;height:44px}++.ovfd-face.is-rundown{border-radius:12px;background:linear-gradient(140deg,#f59e0b,#ef4444 55%,#a855f7);color:#fff;box-shadow:0 4px 14px -4px rgba(239,68,68,.55)}++.ovfd-face.is-rundown svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2.1;stroke-linecap:round;stroke-linejoin:round}++.ovfd-k-rundown{box-shadow:inset 0 0 0 1px color-mix(in srgb,#f59e0b 38%,transparent);background:linear-gradient(180deg,color-mix(in srgb,#f59e0b 7%,transparent),transparent 60%)}++.ovfd-k-rundown .ovfd-media img{filter:saturate(1.35) contrast(1.06) sepia(.12)}++.ovfd-k-rundown .ovfd-media::after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,transparent 55%,rgba(15,8,30,.55)),linear-gradient(120deg,rgba(245,158,11,.18),transparent 40%,rgba(168,85,247,.18))}++.ovfd-ribbon{position:absolute;left:10px;top:10px;z-index:1;display:inline-flex;align-items:center;gap:6px;padding:5px 10px 5px 8px;border-radius:999px;font-size:12px;font-weight:800;letter-spacing:.02em;color:#fff;background:linear-gradient(140deg,#f59e0b,#ef4444 60%,#a855f7);box-shadow:0 4px 12px -2px rgba(0,0,0,.45)}++.ovfd-ribbon svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round}++.ovfd-media{position:relative}++.ovfd-len{font-variant-numeric:tabular-nums;z-index:1}`;document.head.appendChild(st);}@@@ -24,7 +24,7 @@// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);-- const THREAD_REV = { 'ov-composer.js': '203e770ec00f', 'ov-combo.js': '3e426e56fc75' };++ const THREAD_REV = { 'ov-composer.js': '2d3782ec0b52', 'ov-combo.js': '3e426e56fc75' };var selfSrc = (document.currentScript && document.currentScript.src) || '';// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.function loadCombo() {@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.222.0",++ "version": "4.223.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.222.0",++ "version": "4.223.0","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.222.0",++ "version": "4.223.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -159,14 +159,14 @@/** The person's history: one request per opening, kept two minutes (signed in only; anonymous sessions keep none). */async function loadHistory() {if (!ctx.token || !ctx.user || ctx.user.is_anon) return null;-- const key = `ov_hist:${ctx.user.id || ctx.user.username || ''}`;++ const key = `ov_hist2:${ctx.user.id || ctx.user.username || ''}`;try { const c = JSON.parse(sessionStorage.getItem(key) || 'null'); if (c && Date.now() - c.at < 120000) return c.data; } catch { /* */ }try {const r = await fetch(`${ctx.apiBase || 'https://openvibe.network'}/api/history?limit=60`, { headers: { Authorization: `Bearer ${ctx.token}` }, credentials: 'omit' });if (!r.ok) return null;const j = await r.json();const data = {-- items: (j.items || []).map((h) => ({ title: String(h.title || '').slice(0, 160), url: safeUrl(h.url), site: h.service_label || siteOf(h.url), service: h.service, at: h.updated_at })).filter((h) => h.url && h.title),++ items: (j.items || []).map((h) => ({ title: String(h.title || '').slice(0, 160), url: safeUrl(h.url), site: h.service_label || siteOf(h.url), service: h.service, at: utcOf(h.updated_at) })).filter((h) => h.url && h.title),services: (j.services || []).map((s) => ({ service: String(s.service || ''), count: Number(s.count) || 0 })),};try { sessionStorage.setItem(key, JSON.stringify({ at: Date.now(), data })); } catch { /* */ }@@@ -200,8 +200,11 @@el.querySelectorAll('[data-at]').forEach((m) => { const t = ago(m.dataset.at); if (t) m.textContent = [m.dataset.pre, t].filter(Boolean).join(' · '); });}, 60000);}++ /** A time without a zone is UTC (Network's history and older APIs send "YYYY-MM-DD HH:MM:SS"; a browser reads that as++ * local time, and west of UTC every visit looked hours ahead: "just now" for all of them, 11-12). */++ function utcOf(t) { const v = String(t || ''); return /^\d{4}-\d\d-\d\d[ T]\d\d:\d\d(:\d\d(\.\d+)?)?$/.test(v) ? `${v.replace(' ', 'T')}Z` : v; }function ago(iso) {-- const t = Date.parse(iso || ''); if (!isFinite(t)) return '';++ const t = Date.parse(utcOf(iso)); if (!isFinite(t)) return '';const m = Math.max(0, Math.round((Date.now() - t) / 60000));return m < 1 ? 'just now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`;}@@@ -237,6 +240,18 @@.ovs-x { flex: none; height: 36px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--text-muted, #93a0b4); font: inherit; font-size: 13px; cursor: pointer; }.ovs-x svg { display: none; width: 20px; height: 20px; }.ovs-x:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++/* 4.223.0 (the owner, 11-12: "Close … ICON"; "these buttons and borders … get extra wide … make that more sleek"): Close++ is a round × on every screen; the phone's orb keeps a hairline edge and a soft glass, never a thick ring. */++.ovs-x { display: inline-grid; place-items: center; width: 36px; height: 36px; padding: 0; border-radius: 50%; color: var(--text-secondary, #a8b3c4); transition: background-color .15s, color .15s, transform .12s; }++.ovs-x svg { display: block; width: 18px; height: 18px; }++.ovs-x span { display: none; } /* the button's own aria-label (Close the menu) names it */++.ovs-x:active { transform: scale(.9); }++@media (hover: hover) { .ovs-x:hover { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 10%, transparent); } }++@media (max-width: 640px) {++ .ovs.ovs-anchored .ovs-orb { background: color-mix(in srgb, var(--bg-secondary, #121826) 72%, transparent); -webkit-backdrop-filter: blur(12px) saturate(1.4); backdrop-filter: blur(12px) saturate(1.4);++ box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 14%, transparent), 0 8px 22px -10px rgba(0, 0, 0, .6); }++ .ovs.ovs-anchored .ovs-orb.is-x::before { border-width: 1px; }++}/* Phones and small windows: the menu covers the screen (the bar under it too), so Close is always there, a round x atthe end of the search pill (the owner, 11-07: "This needs a close button"). The hit area is 48 px. */@media (max-width: 860px) {@@@ -297,6 +312,42 @@/* 4.202.0: on a phone a site's name sits under its mark, so the whole name shows (Download, Community: not "Downlo…"). */@media (max-width: 480px) { .ovs-grid.is-mini .ovs-tile { flex-direction: column; justify-content: center; gap: 5px; min-height: 64px; padding: 6px 2px 5px; text-align: center; } .ovs-grid.is-mini .ovs-tile b { max-width: 100%; font-size: 12.5px; } }.ovs-group[data-sec="all"] { display: flex; align-items: center; gap: 10px; }++/* 4.223.0 (the owner, 11-12: "the icons are so small, they're hard to see … make the icons bigger so they actually take up++ more of the square … fix the alignment"): a site's icon is an app icon in every tab — its own hue on a rounded square,++ the glyph at 58% of it, placed absolutely so no hover transform can move it off centre. */++.ovs .ovs-ic.ovs-site { position: relative; overflow: hidden; border-radius: 30%; background: linear-gradient(150deg, hsl(var(--ovs-h, 215) 72% 58% / .36), hsl(var(--ovs-h, 215) 70% 38% / .2)); box-shadow: inset 0 0 0 1px hsl(var(--ovs-h, 215) 70% 64% / .3); }++.ovs .ovs-ic.ovs-site .ovi-ring, .ovs-ic.ovs-site .ovi-bg, .ovs-ic.ovs-site .ovi-comet, .ovs-ic.ovs-site .ovi-prog { display: none; }++.ovs .ovs-ic.ovs-site > .ov-icon { position: absolute; inset: 0; width: 100%; height: 100%; display: block; --ovi-glyph: hsl(var(--ovs-h, 215) 92% 84%); color: hsl(var(--ovs-h, 215) 92% 84%); }++:root[data-theme="light"] .ovs .ovs-ic.ovs-site > .ov-icon { --ovi-glyph: hsl(var(--ovs-h, 215) 70% 30%); color: hsl(var(--ovs-h, 215) 70% 30%); }++.ovs .ovs-ic.ovs-site > .ov-icon svg { position: absolute; left: -13%; top: -13%; width: 126%; height: 126%; margin: 0; transform: none !important; }++/* The site list: a grid of app icons, four across on a phone and as many as fit beyond, names on two balanced lines. */++.ovs-grid.is-mini { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px; padding: 2px 0 6px; }++@media (min-width: 560px) { .ovs-grid.is-mini { grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); } }++.ovs-grid.is-mini .ovs-tile { flex-direction: column; justify-content: flex-start; align-items: center; gap: 7px; min-height: 0; padding: 6px 0 8px; border-radius: 16px; text-align: center; background: transparent; }++.ovs-grid.is-mini .ovs-tile > span:last-child { max-width: 100%; }++.ovs-grid.is-mini .ovs-ic { width: min(100% - 14px, 64px); height: auto; aspect-ratio: 1; border-radius: 29%; transition: transform .22s cubic-bezier(.2, 1.4, .4, 1), box-shadow .22s; }++.ovs-grid.is-mini .ovs-tile b { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; max-width: 100%; white-space: normal; text-overflow: clip; font-size: 12px; font-weight: 650; line-height: 1.2; text-wrap: balance; }++.ovs-grid.is-mini .ovs-tile.is-here { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }++.ovs-grid.is-mini .ovs-tile:active .ovs-ic { transform: scale(.93); transition-duration: .08s; }++@media (hover: hover) { .ovs-grid.is-mini .ovs-tile:hover { background: transparent; } .ovs-grid.is-mini .ovs-tile:hover .ovs-ic { transform: translateY(-3px) scale(1.04); box-shadow: inset 0 0 0 1px hsl(var(--ovs-h, 215) 75% 70% / .5), 0 12px 26px -8px hsl(var(--ovs-h, 215) 75% 45% / .6); } }++html[data-ov-motion=off] .ovs-grid.is-mini .ovs-ic { transition: none; }++/* Each section of sites wears its glyph. */++.ovs-group-ic { order: -1; flex: none; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 8px; background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }++.ovs-group-ic svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2.1; stroke-linecap: round; stroke-linejoin: round; }++/* Names in the brand face (openvibe.design rule: a place's or an app's name in OV Brand 800; words you read in the UI++ face): the site list, the tabs, Vibe's switch. */++.ovs-grid.is-mini .ovs-tile b, .ovs-door-sw button, .ovs-starttabs .ovs-tab { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-weight: 800; letter-spacing: .005em; }++.ovs-grid.is-mini .ovs-tile b { font-size: 13px; color: color-mix(in srgb, var(--text-primary, #e6edf7) 92%, transparent); }++.ovs-group[data-sec="all"] { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-size: 13.5px; font-weight: 800; letter-spacing: .01em; text-transform: none; color: var(--text-primary, #e6edf7); }++/* A slow light drifts behind the menu (Calm slows it, only Off stops it; openvibe.design: calm reduces, only off removes). */++.ovs-panel { isolation: isolate; }++.ovs-panel::before { content: ""; position: absolute; inset: -20%; z-index: -1; pointer-events: none; opacity: .5;++ background: radial-gradient(38% 30% at 20% 10%, color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent), transparent 70%), radial-gradient(34% 28% at 85% 30%, color-mix(in srgb, #a855f7 16%, transparent), transparent 70%), radial-gradient(40% 32% at 50% 95%, color-mix(in srgb, #14b8a6 12%, transparent), transparent 70%);++ animation: ovs-drift 26s ease-in-out infinite alternate; }++@keyframes ovs-drift { from { transform: translate3d(-3%, -2%, 0) rotate(0deg); } to { transform: translate3d(3%, 2%, 0) rotate(8deg); } }++html[data-ov-motion=reduced] .ovs-panel::before { animation-duration: 60s; }++html[data-ov-motion=off] .ovs-panel::before { animation: none; }++:root[data-theme="light"] .ovs-panel::before { opacity: .35; }.ovs-group[data-sec="all"]::after { content: ""; flex: 1; height: 1px; background: var(--border, rgba(255,255,255,.1)); }.ovs-tabs[hidden] { display: none; }.ovs-tab { flex: none; height: 32px; padding: 0 14px; border: 0; border-radius: 999px; background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13.5px; font-weight: 650; cursor: pointer; }@@@ -343,6 +394,24 @@.ovs-door-sw button[aria-selected="true"] { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #121826); box-shadow: 0 1px 2px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }.ovs-door-sw button[aria-selected="true"] .ovs-tab-ic { color: var(--accent-light, var(--accent, #60a5fa)); }.ovs-door-sw button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++/* 4.223.0: one segmented control (You's tabs have the same): the ink slides to the chosen part, names at 15 px, glyphs at++ 20 px, the chosen glyph a touch larger in the accent. */++.ovs-door-sw { position: relative; isolation: isolate; gap: 4px; padding: 4px; border-radius: 16px; }++.ovs-door-sw button { min-height: 46px; gap: 8px; font-size: 15px; font-weight: 700; border-radius: 12px; transition: color .18s, background-color .18s; -webkit-tap-highlight-color: transparent; }++.ovs-door-sw button[aria-selected="true"] { background: transparent; box-shadow: none; }++.ovs-door-sw .ovs-tab-ic, .ovs-door-sw .ovs-tab-ic > svg { width: 20px; height: 20px; }++.ovs-door-sw .ovs-tab-ic { transition: transform .25s cubic-bezier(.3, 1.4, .4, 1); }++.ovs-door-sw button[aria-selected="true"] .ovs-tab-ic { transform: scale(1.08); }++.ovs-door-sw button:active .ovs-tab-ic { transform: scale(.9); }++@media (hover: hover) and (pointer: fine) { .ovs-door-sw button:not([aria-selected="true"]):hover { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); } }++.ovs-door-ink { position: absolute; left: 0; top: 4px; bottom: 4px; width: 0; z-index: -1; border-radius: 12px; opacity: 0; pointer-events: none;++ background: var(--bg-secondary, #121826); box-shadow: 0 1px 2px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent), 0 6px 18px -8px color-mix(in srgb, var(--accent, #3b82f6) 60%, transparent);++ transition: transform .34s cubic-bezier(.3, 1.3, .4, 1), width .34s cubic-bezier(.3, 1.3, .4, 1), opacity .2s; }++.ovs-door-ink.is-on { opacity: 1; }++:root[data-theme="light"] .ovs-door-ink { background: #fff; }++html[data-ov-motion=reduced] .ovs-door-ink { transition: opacity .25s; }++html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .ovs-tab-ic { transition: none; }++@media (max-width: 380px) { .ovs-door-sw button { font-size: 14px; gap: 6px; } }.ovs-in-door .ovs-foot { display: none; }.ovs-group { display: flex; align-items: center; gap: 8px; padding: 10px 10px 4px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }.ovs-group button { margin-left: auto; border: 0; background: none; color: var(--accent-light, #8ab4ff); font: inherit; font-size: 11.5px; font-weight: 650; text-transform: none; letter-spacing: 0; cursor: pointer; }@@@ -542,11 +611,30 @@// chats with it, your coding tasks, and your agents.doorSw.innerHTML = [['vibe', 'Chat', 'talk'], ['code', 'Code', 'code'], ['agent', 'Agents', 'agent']].map(([id, label, ic]) => `<button type="button" role="tab" data-part="${id}" aria-selected="false"><span class="ovs-tab-ic">${BRIDGE_SVG[ic]}</span><span>${label}</span></button>`).join('');doorSw.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-part]'); if (b && door === 'vibe') { aiPart = b.getAttribute('data-part'); openDoor('vibe'); } });++ // 4.223.0: the switch's ink slides to the chosen part (the owner, 11-12: "animations, effects to the tabs"); it++ // follows aria-selected wherever that is set, and the size of the row.++ {++ const ink = document.createElement('i'); ink.className = 'ovs-door-ink'; ink.setAttribute('aria-hidden', 'true'); doorSw.prepend(ink);++ const place = () => {++ const on = doorSw.querySelector('[data-part][aria-selected="true"]');++ if (!on || !on.offsetWidth) { ink.classList.remove('is-on'); return; }++ ink.style.transform = `translateX(${on.offsetLeft}px)`; ink.style.width = `${on.offsetWidth}px`; ink.classList.add('is-on');++ };++ if (typeof MutationObserver === 'function') new MutationObserver(() => requestAnimationFrame(place)).observe(doorSw, { subtree: true, attributes: true, attributeFilter: ['aria-selected', 'hidden'] });++ if (typeof ResizeObserver === 'function') new ResizeObserver(() => { ink.style.transition = 'none'; place(); void ink.offsetWidth; ink.style.transition = ''; }).observe(doorSw);++ doorSw.addEventListener('keydown', (e) => {++ if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;++ const all = [...doorSw.querySelectorAll('[data-part]')]; const k = all.indexOf(document.activeElement); if (k < 0) return;++ const next = all[(k + (e.key === 'ArrowRight' ? 1 : all.length - 1)) % all.length];++ e.preventDefault(); next.focus(); next.click();++ });++ }doorHost = document.createElement('div'); doorHost.className = 'ovs-door-host';const timerButton = document.createElement('button'); timerButton.type = 'button'; timerButton.className = 'ovs-ai-timer'; timerButton.setAttribute('aria-label', 'Start a timer'); timerButton.setAttribute('data-ov-tip', 'Start a timer'); timerButton.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/></svg><span>Start a timer</span>'; timerButton.hidden = true;timerButton.addEventListener('click', () => { try { if (root.OpenVibeDock && root.OpenVibeDock.timer) root.OpenVibeDock.timer(timerButton); else if (ctx.dockMore) ctx.dockMore('timer'); } catch { /* not loaded */ } });// Vibe's switch and its timer share one row (4.220.0): the timer is an icon at its end, never a row under the box.-- const doorTop = document.createElement('div'); doorTop.className = 'ovs-door-top'; doorTop.append(doorSw, timerButton);++ // 4.223.0: the timer is in every box's + (the owner: "the timer should be in the plus thing"), not in this row.++ const doorTop = document.createElement('div'); doorTop.className = 'ovs-door-top'; doorTop.append(doorSw); void timerButton;doorEl.append(doorTop, doorHost);listEl.after(doorEl);doorHost.addEventListener('openvibe:open-vibe', (e) => {@@@ -681,10 +769,30 @@ghost.append(typed, document.createTextNode(completion.slice(q.length)));}++ /** A steady hue per site (ov-home.js has the same), so an app wears one colour in every tab. */++ 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; }++ // The glyphs a section of sites can wear (openvibe-shared/glyphs.js), and which one a section's name asks for.++ const GROUP_GLYPH = {++ 'video': '<rect x="3.4" y="6.4" width="12.3" height="11.3" rx="1.9"/><path d="M15.6 10.2l4.7-2.8v9.4l-4.7-2.8"/>',++ 'chat': '<path d="M3.8 4.3h16.8v11.7H8l-4.2 4.2Z"/>',++ 'game': '<rect x="3.7" y="7.2" width="16.7" height="9.7" rx="4.4"/><path d="M7.6 10.7v2.7M6.4 12h2.7M15.1 11.6h0M17.4 12.9h0"/>',++ 'book': '<path d="M12 6.3C10 4.8 7 4.3 3.5 4.9v13.2c3.5-0.6 6.5-0.1 8.5 1.4c2-1.5 5-2 8.5-1.4V4.9C17 4.3 14 4.8 12 6.3Z"/><path d="M12 6.3v13.2"/>',++ 'store': '<path d="M3.7 8.8L5.3 3.7h13.4L20.3 8.8M3.7 8.8h16.5v1.6a2.8 2.8 0 0 1-5.4 0a2.8 2.8 0 0 1-5.6 0A2.8 2.8 0 0 1 3.7 10.4ZM4.8 12.9v7.2h14.5v-7.2M9.9 20.1v-4.1h4.1v4.1"/>',++ 'wrench': '<path d="M14.6 3.6a5.2 5.2 0 0 0-4.6 7.3L3.7 17.3V20.3h3.1l6.4-6.4a5.2 5.2 0 0 0 7.3-4.6l-3.2 1.2l-2.5-2.5l1.2-3.2Z"/>',++ 'spark': '<path d="M10.4 3.6l1.7 4.8L17 10.1l-4.8 1.7L10.4 16.7l-1.7-4.8L3.9 10.1l4.8-1.7ZM17 14.8l0.8 2.1L19.8 17.6l-2.1 0.8L17 20.3l-0.8-2.1L14.3 17.6l2.1-0.8Z"/>',++ 'code': '<path d="M7.8 7.8l-4.2 4.2l4.2 4.2M16.2 7.8l4.2 4.2l-4.2 4.2M14.2 4.6l-4.2 14.8"/>',++ 'compass': '<circle cx="12" cy="12" r="8.5"/><path d="m15.6 8.4-2.1 5.1-5.1 2.1 2.1-5.1Z"/>',++ };++ function groupGlyph(name) {++ const n = String(name || '').toLowerCase();++ return /watch|stream|video|live/.test(n) ? 'video' : /talk|belong|chat|social|community/.test(n) ? 'chat' : /play|game|fun/.test(n) ? 'game'++ : /read|ask|news|learn/.test(n) ? 'book' : /shop|money|buy|market/.test(n) ? 'store' : /tool/.test(n) ? 'wrench'++ : /\bai\b|make/.test(n) ? 'spark' : /develop|build|code/.test(n) ? 'code' : 'compass';++ }function icon(it) {const ic = document.createElement('span');ic.className = 'ovs-ic';-- if (it.site) { ic.classList.add('ovs-site'); const s = document.createElement('span'); s.className = 'ov-icon'; s.setAttribute('data-icon', it.site); s.setAttribute('data-size', '28'); s.setAttribute('data-fx', 'none'); ic.appendChild(s); }++ if (it.site) { ic.classList.add('ovs-site'); ic.style.setProperty('--ovs-h', String(hueOf(it.site))); const s = document.createElement('span'); s.className = 'ov-icon'; s.setAttribute('data-icon', it.site); s.setAttribute('data-size', '28'); s.setAttribute('data-fx', 'none'); ic.appendChild(s); }else if (ICON[it.icon]) ic.innerHTML = ICON[it.icon];else ic.textContent = (it.icon || '•').slice(0, 2);return ic;@@@ -790,7 +898,10 @@h.className = 'ovs-group';h.setAttribute('role', 'presentation');if (g.section) h.setAttribute('data-sec', g.section);++ // A section of sites wears its glyph (4.223.0), drawn from the glyph registry's set.h.appendChild(document.createTextNode(g.title));++ // The name stays the first node (readers and the launcher read it there); the glyph is drawn before it (order -1).++ if (g.section === 'all' && g.title) { const gi = document.createElement('span'); gi.className = 'ovs-group-ic'; gi.setAttribute('aria-hidden', 'true'); gi.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${GROUP_GLYPH[groupGlyph(g.title)]}</svg>`; h.appendChild(gi); }if (g.clear) { const b = document.createElement('button'); b.type = 'button'; b.textContent = 'Clear'; b.setAttribute('data-ovs-clear', ''); h.appendChild(b); }if (g.more && g.more.href) { const m = document.createElement('a'); m.className = 'ovs-more'; m.href = safeUrl(g.more.href) || '#'; m.textContent = g.more.label; h.appendChild(m); }else if (g.more) { const b = document.createElement('button'); b.type = 'button'; b.textContent = g.more.label; b.setAttribute('data-ovs-tab', g.more.tab); h.appendChild(b); }@@@ -874,7 +985,7 @@if (as === 'sites' && it.meta) { const sm = document.createElement('small'); sm.textContent = it.meta; txt.appendChild(sm); }if (it.host) { const hs = document.createElement('span'); hs.className = 'ovs-host'; hs.textContent = it.host; txt.appendChild(hs); }const ic = icon(it);-- if (as === 'mini') { const sp = ic.querySelector('.ov-icon'); if (sp) sp.setAttribute('data-size', '26'); }++ if (as === 'mini') { const sp = ic.querySelector('.ov-icon'); if (sp) sp.setAttribute('data-size', '44'); }a.append(ic, txt);box.appendChild(a);});@@@ -1437,8 +1548,10 @@function youGroups() {const { take, ranked } = basis();const groups = [];-- if (root.OpenVibeNotifications && ctx.token) groups.push({ title: 'Notifications', as: 'notifications', items: [] });-- if (homeLib()) {++ const H = homeLib();++ // With the home screen, Notifications is one of You's tabs (4.223.0); without it, the section stays at the top.++ if (root.OpenVibeNotifications && ctx.token && !H) groups.push({ title: 'Notifications', as: 'notifications', items: [] });++ if (H) {groups.push({ as: 'home', items: [], section: 'home' });// Signed out, Display stays at the foot (a signed-in person has it beside their name, in ov-home).if (!(ctx.token && ctx.user && !ctx.user.is_anon) && typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });@@@ -1492,16 +1605,47 @@}// Jump back in (the last places, newest first) and what this person uses most (pages opened per site).const seenB = new Set();-- const back = mine ? mine.items.filter((x) => { if (seenB.has(x.url)) return false; seenB.add(x.url); return true; }).slice(0, 8).map(backRow) : [];++ const back = mine ? mine.items.filter((x) => { if (seenB.has(x.url)) return false; seenB.add(x.url); return true; }).slice(0, 60).map(backRow) : [];const usage = mine ? mine.services.filter((x) => x && x.count > 0).map((x) => ({ id: x.service === 'media' ? 'media-hub' : x.service, count: Number(x.count) || 0 })) : [];return { apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, back, usage, groups: f && Array.isArray(f.groups) ? f.groups : [],-- token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, look: ctx.look, me: ctx.me, sibling: ctx.sibling, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,++ token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, look: ctx.look, me: ctx.me, sibling: ctx.sibling,++ // You's Notifications tab: the whole list with Mark all read, mounted once; the count rides on the tab.++ notifications: (host) => mountNotes(host), unread: () => { try { return root.OpenVibeNotifications ? Number(root.OpenVibeNotifications.unread()) || 0 : 0; } catch { return 0; } },++ startTab: ['notifications', 'recent', 'apps'].includes(ctx.section) ? ctx.section : null, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,onSearch: (q) => { input.value = q; render(q); input.focus(); },pulseReady: pulseReady || (pulseReady = root.OpenVibeHome.loadPulse({ token: ctx.token, user: ctx.user })),// The feed answered: the Feed tab is the stream (its sections would sit after an endless list); it did not: as before.feedLive: () => { if (!feedV2) { feedV2 = true; if (el && el.classList.contains('is-open') && !input.value && tab === 'feed') render(''); } } };}++ /** The whole notifications list for You's tab: a head with how many are new and Mark all read, then every notice. */++ function mountNotes(host) {++ const N = root.OpenVibeNotifications;++ if (!N || typeof N.renderMenuNotifications !== 'function') {++ // The navbar fetches notification-ui.js once you are signed in; the tab can open first. Wait for it (or fetch it++ // here), start it with your token, then draw. It said "unavailable" for good before (11-12).++ host.textContent = '';++ const wait = document.createElement('p'); wait.className = 'ovs-note'; wait.textContent = 'Loading your notifications…'; host.appendChild(wait);++ const ready = () => {++ const M = root.OpenVibeNotifications;++ if (!M || typeof M.renderMenuNotifications !== 'function') { wait.textContent = 'Notifications did not load. Close the menu and open it again.'; return; }++ try { if (!M.__ovNavInit && ctx.token) { M.init({ token: ctx.token, apiBase: ctx.apiBase || 'https://openvibe.network' }); M.__ovNavInit = true; } } catch { /* the navbar's own init may follow */ }++ host.textContent = ''; mountNotes(host);++ };++ let sc = document.getElementById('ov-notify-loader');++ if (!sc && typeof ctx.sibling === 'function') { sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = ctx.sibling('notification-ui.js'); sc.async = true; document.head.appendChild(sc); }++ if (!sc) { wait.textContent = 'Notifications are on openvibe.network/notifications.'; return; }++ sc.addEventListener('load', () => setTimeout(ready, 0), { once: true });++ sc.addEventListener('error', () => { wait.textContent = 'Notifications did not load. Check your connection and open the menu again.'; }, { once: true });++ setTimeout(() => { if (root.OpenVibeNotifications && host.contains(wait)) ready(); }, 1200);++ return;++ }++ // The list draws its own search, tabs and Mark all read: the tab needs no second heading.++ const list = document.createElement('div'); list.className = 'ovs-notifications-host';++ host.append(list);++ try { const view = N.renderMenuNotifications(list, () => {}); if (view && view.showAll) view.showAll(); } catch { list.textContent = 'Notifications did not load.'; }++ }++function renderTab() {paint(tab === 'feed' ? feedGroups() : tab === 'you' ? youGroups() : hereGroups());}@@@ -51,7 +51,7 @@ window.bigPicture = async (w, h) => { const c = document.createElement('canvas')assert.ok(await until(`!!window.post`), 'the composers mount');// The camera: an agent's box and a post have it.-- const tools = await page.evaluate(`[...document.querySelectorAll('.ovc-v2')].map((f) => [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')))`);++ const tools = await page.evaluate(`[...document.querySelectorAll('.ovc-v2')].map((f) => [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')).filter((l) => l !== 'Speak' && l !== 'More: attach, timer and tools'))`);assert.deepEqual(tools[0].slice(0, 2), ['Attach', 'Camera'], 'an agent: attach and the camera');assert.ok(tools[1].includes('Camera'), 'a post: the camera beside photos');assert.equal(await page.evaluate(`document.getElementById('g1').getAttribute('data-ovc-resize')`), '1568');@@@ -49,10 +49,13 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'r// Each preset draws its own tools, chips and send.const shape = await page.evaluate(`(() => [...document.querySelectorAll('.ovc-v2')].map((f) => ({ preset: f.dataset.preset,-- tools: [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')),++ // 4.223.0: the mic and the + lead the row; the box's own tools follow.++ lead: [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')).filter((l) => l === 'Speak' || l === 'More: attach, timer and tools'),++ tools: [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')).filter((l) => l !== 'Speak' && l !== 'More: attach, timer and tools'),avatar: !!f.querySelector('.ovc-head .ovc-av'), full: Math.abs(f.querySelector('textarea').getBoundingClientRect().left - f.querySelector('.ovc-body').getBoundingClientRect().left) < 4, round: f.querySelector('[data-ovt-send]').classList.contains('ovt-send--round'),send: f.querySelector('[data-ovt-send]').textContent.trim(), chips: [...f.querySelectorAll('.ovc-chip-v')].map((c) => c.textContent) })))()`);assert.deepEqual(shape.map((s) => s.preset), ['post', 'chat', 'agent', 'comment']);++ assert.ok(shape.every((s) => s.lead[s.lead.length - 1] === 'More: attach, timer and tools' && (s.lead.length === 1 || s.lead[0] === 'Speak')), `the mic, then the +, lead every row (${JSON.stringify(shape.map((s) => s.lead))})`);assert.deepEqual(shape[0].tools.slice(0, 4), ['Photos and video', 'Files', 'Emoji', 'Poll'], 'a post: photos, files, emoji and the site\'s own tool');assert.ok(shape[0].avatar && shape[0].send === 'Post' && !shape[0].round);assert.ok(shape.every((x) => x.full), 'the words use the full width: no column kept for the picture');@@@ -587,7 +587,12 @@ const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...doawait until(p, "!!window.OpenVibeDock", 'the panels');await p.evaluate(OPEN('vibe'));await until(p, PLACE, 'the menu on Vibe');-- await p.evaluate("document.querySelector('.ovs .ovs-ai-timer').click()");++ // 4.223.0: the timer is in the box's + (every composer's), not a button of Vibe's own.++ await p.evaluate("(document.querySelector('.ovs .ovdk-vibe-new') || { click() {} }).click()");++ await until(p, "!!document.querySelector('.ovs .ovc-more')", 'the box and its +');++ await p.evaluate("document.querySelector('.ovs .ovc-more').click()");++ await until(p, "[...document.querySelectorAll('.ovs .ovc-tray-i')].some((b) => b.textContent === 'Timer')", 'Timer in the +');++ await p.evaluate("[...document.querySelectorAll('.ovs .ovc-tray-i')].find((b) => b.textContent === 'Timer').click()");await until(p, "!!document.querySelector('.ovdk .ovdk-tmenu')", 'the timer menu');await p.evaluate("document.querySelector('.ovdk .ovdk-tmenu [data-min=\"5\"]').click()");const strip = await until(p, "(() => { const s = document.querySelector('.ovdk .ovdk-timer'); return s ? s.innerText : null; })()", 'the timer strip in the Dock');@@@ -51,7 +51,7 @@ const server = http.createServer((req, res) => {assert.ok(await until(`!!document.querySelector('form.ovc-v2[data-preset="comment"]')`), 'the reply is the shared comment composer');assert.equal(await page.evaluate(`document.querySelectorAll('[data-ovt-composer]').length`), 0, 'the server form is replaced, not doubled');const shape = await page.evaluate(`(() => { const f = document.querySelector('form.ovc-v2[data-preset="comment"]'); return { action: f.getAttribute('action'), method: f.method, parent: (f.querySelector('input[name=parent_id]') || {}).value, send: f.querySelector('[data-ovt-send]').textContent.trim(), tools: [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')) }; })()`);-- assert.deepEqual(shape, { action: '/p/x/comments', method: 'post', parent: '1', send: 'Reply', tools: ['Photos and video', 'Emoji'] });++ assert.deepEqual(shape, { action: '/p/x/comments', method: 'post', parent: '1', send: 'Reply', tools: ['More: attach, timer and tools', 'Photos and video', 'Emoji'] });assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390, 'nothing is wider than a phone');await page.evaluate(`(() => { const t = document.querySelector('form.ovc-v2 textarea'); t.value = 'Hello from the shared box'; t.dispatchEvent(new Event('input', { bubbles: true })); document.querySelector('form.ovc-v2 [data-ovt-send]').click(); })()`);const landed = await until(`!!document.getElementById('posted')`);@@@ -91,8 +91,9 @@ async function openMenu(p) {await wait(200);await openMenu(wide);const d = await wide.evaluate(BUTTON);-- assert.ok(d && d.shown && d.word && !d.icon, `a wide screen keeps the word Close: ${JSON.stringify(d)}`);-- console.log('menu-close-chrome: a phone has a round close button (40 px, 48 px hit area) that closes the menu; wide screens keep Close');++ // 4.223.0 (the owner, 11-12: "Close … ICON"): a wide screen has the round × too, named for readers.++ assert.ok(d && d.shown && d.icon && !d.word && d.label === 'Close the menu', `a wide screen has a round × named Close the menu: ${JSON.stringify(d)}`);++ console.log('menu-close-chrome: a phone has a round close button (40 px, 48 px hit area) that closes the menu; wide screens a round × too');} finally {await browser.close();server.close();@@@ -190,32 +190,37 @@ const server = http.createServer((req, res) => {await openTab(s, 'you');assert.ok(await until(s, `!!document.querySelector('.ovh-actor')`), 'the Actor strip is on top');await eventually(async () => assert.match(await s.evaluate(`document.querySelector('.ovh-actor').textContent`), /Juniper.*Working.*Reading job posts/, 'it names the Actor, its state and its step'));-- // You on top: the name, Display (the Look panel) and Settings beside it; then your home, Most used and Jump back in.-- // 4.195.0 (the owner, the old block crossed out: "get rid of this thing … we didn't really need [it] here, other than-- // like adding your account"): one slim row of its own, and the old account block is not set in the menu at all.++ // 4.223.0 (the owner, 11-12: "at the top … the goosely Online bar. Under that is tabs … move the notifications into a++ // tab … put the apps more front facing … the first tab"; "waste of space"; "compact"): your row holds every round++ // button (this site's own first, as icons), one AI notice, then one row of tabs with All and Edit at its end.await eventually(async () => assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-me .ovh-who-txt b').textContent`), 'Ada', 'your name on top'));await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovs .ovh-account, .ovs .openvibe-navbar-dropdown, .ovs .ud-grid')`), 'the old account block and its grid of tiles are not in the menu'));-- await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-me > .ovh-ib')].map((b) => b.getAttribute('aria-label'))`), ['Add or switch account', 'Display: theme, text size, motion', 'Account and settings', 'Sign out'], 'add or switch account, Display, Settings, Sign out, in that order'));-- await eventually(async () => assert.ok(await s.evaluate(`/[?&]add_account=1/.test(document.querySelector('.ovh-me > a.ovh-ib').getAttribute('href')) && !!document.querySelector('.ovh-me [data-ov-look-open]') && !!document.querySelector('.ovh-me a[href="https://openvibe.network/my"]')`), 'the add-account address is the bar\'s; Display and Settings are in the row'));++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-me .ovh-acts > .ovh-ib:not(.is-own)')].map((b) => b.getAttribute('aria-label'))`), ['Add or switch account', 'Display: theme, text size, motion', 'Account and settings', 'Sign out'], 'add or switch account, Display, Settings, Sign out, in that order'));++ await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovh-own') && [...document.querySelectorAll('.ovh-me .ovh-acts > .is-own')].every((b) => !!b.getAttribute('aria-label'))`), 'this site\'s own items are buttons in your row, never a row of their own'));++ await eventually(async () => assert.ok(await s.evaluate(`/[?&]add_account=1/.test(document.querySelector('.ovh-me a.ovh-ib[aria-label="Add or switch account"]').getAttribute('href')) && !!document.querySelector('.ovh-me [data-ov-look-open]') && !!document.querySelector('.ovh-me a[href="https://openvibe.network/my"]')`), 'the add-account address is the bar\'s; Display and Settings are in the row'));await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovs-displayrow')`), 'Display is not at the foot any more'));-- assert.ok(await until(s, `document.querySelectorAll('.ovh-rail .ovh-card').length === 2`), 'Jump back in shows the places opened lately');-- await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-rail .ovh-card')].map((a) => a.getAttribute('href'))`), ['https://openvibe.live/ada', 'https://openvibe.pics/1']));++ await eventually(async () => assert.ok(await s.evaluate(`document.querySelector('.ovh-me').getBoundingClientRect().height <= 112`), 'your row is one card: you, then your buttons'));++ await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-me .ovh-ib')].every((b) => { const r = b.getBoundingClientRect(); return r.height >= 44 && r.width >= 40; })`), 'and its buttons are full touch targets'));++ await eventually(async () => assert.ok(await s.evaluate(`(() => { const m = document.querySelector('.ovh-me').getBoundingClientRect(); return m.right <= innerWidth && [...document.querySelectorAll('.ovh-me .ovh-ib')].every((x) => x.getBoundingClientRect().right <= m.right + 1); })()`), 'nothing in your row runs off the screen'));++ // The tab row: Apps first and chosen; All and Edit at its end; the tabs move with the arrow keys.++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-tabrow .ovh-tab')].map((t) => t.dataset.tab + (t.getAttribute('aria-selected') === 'true' ? '*' : ''))`), ['apps*', 'notifications', 'recent'], 'Apps, Notifications, Recent; Apps first'));++ await eventually(async () => assert.ok(await s.evaluate(`!!document.querySelector('.ovh-tabrow .ovh-headtools .ovh-allbtn') && !!document.querySelector('.ovh-tabrow .ovh-headtools .ovh-edit') && !document.querySelector('.ovh-sec .ovh-edit')`), 'All and Edit sit at the end of the tab row'));++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons'));++ // Recent: what you use most, then where you were, in days, each a card with its whole title and its time.++ await s.evaluate(`document.querySelector('.ovh-tab[data-tab="recent"]').click(), true`);++ assert.ok(await until(s, `document.querySelectorAll('.ovh-hrow').length === 2`), 'Recent lists the places opened lately');++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-hrow')].map((a) => a.getAttribute('href'))`), ['https://openvibe.live/ada', 'https://openvibe.pics/1']));++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-sec > span')].map((x) => x.textContent)`), ['Most used', 'Where you were'], 'most used, then where you were'));await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => k.textContent)`), ['Live5', 'Pics2'], 'Most used: pages opened per site'));await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec a.ovh-link[href="https://openvibe.report/me"]') || {}).textContent`), 'Your stats', 'Most used links to your stats on Report'));-- // The owner, 2026-11-10: "Your home should be above Jump back in… the thing at the top a little more compact… links-- // to whatever the user uses the most". Your home comes first, then what you use most, then where you were last;-- // the bar with your name is short; each most-used app is a way in (its icon, a full touch target), not a legend.-- await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-sec > span')].map((x) => x.textContent)`), ['Your home', 'Most used', 'Jump back in'], 'your home, then most used, then where you were'));-- await eventually(async () => assert.ok(await s.evaluate(`document.querySelector('.ovh-me').getBoundingClientRect().height <= 64`), 'the row with your name is one short row'));-- await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-me > .ovh-ib')].every((b) => { const r = b.getBoundingClientRect(); return r.height >= 44 && r.width >= 40; })`), 'and its buttons are full touch targets'));-- await eventually(async () => assert.ok(await s.evaluate(`(() => { const m = document.querySelector('.ovh-me').getBoundingClientRect(); return m.right <= innerWidth && [...document.querySelectorAll('.ovh-me > *')].every((x) => x.getBoundingClientRect().right <= m.right + 1); })()`), 'all of it on the screen'));assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => [k.getAttribute('href'), k.getAttribute('aria-label'), !!k.querySelector('.ovh-ic > *')].join('|'))`),['https://openvibe.live/|Live, opened 5 times|true', 'https://openvibe.pics/|Pics, opened 2 times|true'], 'each most-used app is a link with its icon and says how often');await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].every((k) => { const r = k.getBoundingClientRect(); return r.height >= 44 && r.width >= 44; })`), 'each a full touch target'));-- await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-rail').parentElement.querySelector('.ovh-sec small') || {}).textContent`), 'where you were last', 'Jump back in says what it is'));-- await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start'));-- await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons'));-- await s.evaluate(`document.querySelector('.ovh-sec .ovh-edit').click(), true`);++ await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-hrow small span:last-child')].every((t) => /now|min ago|h ago|yesterday|days ago/.test(t.textContent))`), 'each place says when'));++ await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-hist .ovh-link') || {}).getAttribute && document.querySelector('.ovh-hist .ovh-link').getAttribute('href')`), 'https://openvibe.my/history', 'Full history opens the whole list'));++ await s.evaluate(`document.querySelector('.ovh-tab[data-tab="apps"]').click(), true`);++ await eventually(async () => assert.ok(await s.evaluate(`!!document.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app')`), 'back on Apps'));++ await s.evaluate(`document.querySelector('.ovh-tabrow .ovh-edit').click(), true`);await s.evaluate(`(() => { const t = document.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); t.focus(); t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); })()`);assert.ok(await until(s, `window.__calls.some((c) => c.m === 'PUT' && c.u.endsWith('/api/v1/me/home-screen') && c.auth === 'Bearer tok-1')`, 3000), 'the arrangement goes to Network with the bearer');await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);@@@ -92,8 +92,8 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).maawait eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat Code* Agents', chosen: 'Vibe' }, `${name}: the switch goes to Code`));await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="agent"]').click()`); await wait(80);await eventually(async () => assert.equal((await inside()).sw, 'Chat Code Agents*', `${name}: the switch goes to Agents`));-- await page.evaluate(`document.querySelector('.ovs-ai-timer').click()`);-- await eventually(async () => assert.equal(await page.evaluate('window.timerCalled'), 'timer', `${name}: AI timer entry`));++ // 4.223.0: the timer is in every box's + (the owner: "the timer should be in the plus thing"), not in this row.++ assert.equal(await page.evaluate(`!!document.querySelector('.ovs-door-top .ovs-ai-timer')`), false, `${name}: no timer button beside the switch`);await page.evaluate(`document.querySelector('.ovs-input').value = 'find this'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(170);await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));`);await eventually(async () => assert.equal(await page.evaluate('window.asked'), 'find this', `${name}: AI Enter hands its text to Vibe`));@@@ -143,7 +143,7 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).maawait eventually(async () => assert.equal(await page.evaluate("document.querySelector('.ovnav-avatar-count')?.textContent"), '7', `${width}px: avatar carries the unread count`));await page.evaluate("document.querySelector('#openvibe-avatar-btn').click()"); await wait(500);await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open .ovs-starttabs [data-tab=you][aria-selected=true]')"), true, `${width}px avatar opens You`));-- await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open [data-sec=notifications]')"), true, `${width}px avatar opens at Notifications`));++ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open [data-sec=notifications], .ovs.is-open .ovh-tab[data-tab=notifications][aria-selected=true]')"), true, `${width}px avatar opens at Notifications`));await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.openvibe-navbar .ovs-feed-dot:not([hidden])')"), true, `${width}px menu button carries the calm Feed dot`));await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovdk.is-open')"), false, `${width}px avatar does not open the floating Dock`));await page.close();
@@ -1,5 +1,76 @@
@@ -1,5 +1,76 @@
# Changelog
# Changelog
+## 4.223.0 — 2026-11-12
+
+You redone as tabs, icons that fill their squares everywhere in the menu, names in the brand face, and one composer with
+a + for every tool (the owner, 11-12, a run of phone and desktop screenshots: "the notifications area is ultra buggy …
+move the notifications into a tab … put the apps more front facing"; "make the icons bigger so they actually take up
+more of the square … fix the alignment"; "waste of space … compact"; "fix the mic to the left … the timer should be in
+the plus thing … usable on all composers across all sites").
+
+- **You:**
+ - **One top:** your row, then your AI. Side by side when the menu is wide. Your row holds every round button, with
+ this site's own (your channel, Admin, what a site adds) first as icons. The lone "Channel" row is gone.
+ - **Three tabs:** Apps (first) · Notifications · Recent. One row, with All and Edit at its end; no "Your home" head.
+ - An ink slides to the chosen tab and the tab's content slides in from its side. Calm fades; Off stills.
+ - On a phone, only the chosen tab says its name. The arrow keys move between tabs.
+ - **Notifications** is a tab:
+ - the whole list, with its own search, filters and Mark all read;
+ - it loads `notification-ui.js` itself when the tab opens first (it said "unavailable");
+ - its count follows the bell live.
+ - **Recent** is the whole history:
+ - newest first, in days (Today, Yesterday, This week, Earlier);
+ - each place a card with its whole title, its site and when, and LIVE when it is live;
+ - two columns when wide, Show more, and Full history to openvibe.my/history.
+ - **Your AI:**
+ - its face shows what it is doing (reading, writing, searching, coding, waiting for you…) as a glyph;
+ - it breathes while working;
+ - Open is a round arrow and Answer is a pill;
+ - not yet set up, it is one slim row.
+ - **All apps:** back is a round arrow, with the search beside it. Each group is marked with its glyph and its count.
+- **App icons, everywhere in the menu** (You, the site list, search rows, the feed):
+ - the site's hue on a rounded square, with the glyph at 58% of it;
+ - drawn absolutely, so no hover can move it off centre;
+ - a folder's four apps sit in an even 2×2 (they were offset and cut off);
+ - a + to add an app sits on the icon's corner;
+ - a mouse lifts an icon and lights it in its hue, and a press sinks it.
+- **Names in the brand face:**
+ - app, site, tab and section names are in OV Brand (Nunito 800, loaded by the navbar on every site); words you
+ read stay in the UI face;
+ - names wrap to two balanced lines and never end in "…";
+ - section heads are names, not small capitals.
+- **The site list** (a site's own tab) is a grid of app icons: four across on a phone, as many as fit beyond. Each
+ section wears its glyph.
+- **The feed:**
+ - a recording's length reads as a time (5203 → 1:26:43);
+ - the Rundown is its own card: "The Rundown · about Maticus", its mark, a ribbon on its picture, and a grade, so it
+ never reads as the stream's card;
+ - the kind glyph beside the time is 18 px.
+- **One composer** (`ov-composer.js`, every box on every site):
+ - **The row:** the mic first, then the + and the box's first tool on a phone; the + opens every tool of the box, big
+ and named. It lists the row's own tools, the mods' tools and the extras.
+ - **Timer:** a tool in every box's + (`OpenVibeComposer` TOOL `timer`, `extra: true`). Vibe's switch row no longer
+ has a timer of its own.
+ - **Examples:** an empty box shows what people do with it, in turn: `examples`, or a preset's own, or
+ `data-ovc-examples`. Calm is slower; Off keeps the first.
+ - **Send:** the round send is an arrow up, in the accent when there is something to send.
+- **Vibe:**
+ - its box is the same composer, with its hold-to-talk mic first and then the +;
+ - a link in your own message reads (it was blue on blue);
+ - Chat · Code · Agents is one segmented control with a sliding ink, at 15 px with 20 px glyphs.
+- **The menu:**
+ - Close is a round × on every screen; the phone's close orb is glass with a hairline edge, never a thick ring;
+ - a slow light drifts behind the panel (Calm slows it; Off stops it).
+ - `OpenVibeNavbar.openMenu(place)` opens the menu at a place: `notifications`, `recent`, `apps`, `you`, `feed`,
+ `vibe`, `chats` or `agent`. A site's own links use it (openvibe.my's Notifications tile clicked a bell that is gone).
+- **Times:** a time without a zone is UTC. Network's history sends "YYYY-MM-DD HH:MM:SS", and browsers read it as local
+ time; west of UTC every visit said "just now".
+- **The notification rows:**
+ - one way to clear a notice (×); a tap reads it, and a swipe still offers Read or Clear;
+ - the title and its ×N count are one run of text (the count overlapped the words).
+- **Tests updated:** menu-home-screen, menu-places, menu-close, dock-chrome (the timer through the +), composer-v2,
+ composer-camera, inline-composer.
+
## 4.222.0 — 2026-11-12
## 4.222.0 — 2026-11-12
The wallet sheet redesigned for phone and desktop, every phone page back to its true width, and whole site names in the
The wallet sheet redesigned for phone and desktop, every phone page back to its true width, and whole site names in the
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.222.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.223.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.222.0",
+ "version": "4.223.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)",
@@ -84,6 +84,8 @@
@@ -84,6 +84,8 @@
.ovdk-send-tools .ovdk-attach, .ovdk-send-tools .ovdk-mic { width: 40px; height: 40px; border-radius: 12px; color: var(--accent-light, var(--accent, #60a5fa)); }
.ovdk-send-tools .ovdk-attach, .ovdk-send-tools .ovdk-mic { width: 40px; height: 40px; border-radius: 12px; color: var(--accent-light, var(--accent, #60a5fa)); }
.ovdk-send-tools .ovdk-attach:hover, .ovdk-send-tools .ovdk-mic:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
.ovdk-send-tools .ovdk-attach:hover, .ovdk-send-tools .ovdk-mic:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
.ovdk-send-tools .ovdk-attach svg, .ovdk-send-tools .ovdk-mic svg { width: 20px; height: 20px; }
.ovdk-send-tools .ovdk-attach svg, .ovdk-send-tools .ovdk-mic svg { width: 20px; height: 20px; }
+ .ovdk-msg.is-you .ovdk-text a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 55%, transparent); text-underline-offset: 2px; overflow-wrap: anywhere; }
+ .ovdk-send-tools .ovdk-go:not(:disabled) { background: linear-gradient(150deg, color-mix(in srgb, var(--accent, #3b82f6) 85%, #fff), var(--accent-strong, var(--accent, #2563eb))); color: var(--on-accent-strong, #fff); box-shadow: 0 6px 18px -6px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); }
.ovdk-send-tools .ovdk-go { margin-left: auto; width: 40px; height: 40px; border-radius: 50%; }
.ovdk-send-tools .ovdk-go { margin-left: auto; width: 40px; height: 40px; border-radius: 50%; }
.ovdk-send-tools .ovdk-go svg { width: 18px; height: 18px; stroke-width: 2.2; }
.ovdk-send-tools .ovdk-go svg { width: 18px; height: 18px; stroke-width: 2.2; }
.ovdk-msg.is-vibe .ovdk-text a { word-break: normal; overflow-wrap: anywhere; }
.ovdk-msg.is-vibe .ovdk-text a { word-break: normal; overflow-wrap: anywhere; }
@@ -112,10 +114,11 @@
@@ -112,10 +114,11 @@
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
- const VIBE_REV = { 'ov-composer.js': '203e770ec00f' };
+ const VIBE_REV = { 'ov-composer.js': '2d3782ec0b52' };
function joinComposer(form) {
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
form.setAttribute('data-ovc-no-files', '');
+ if (!form.dataset.ovcExamples) form.dataset.ovcExamples = JSON.stringify(['Ask Vibe, or hold the mic', 'Find a live stream about cooking', 'Start a 10-minute timer', 'What did people talk about on stream today?', 'Summarize today’s Rundown', 'Write a title for my stream']);
const go = () => { try { if (root.OpenVibeComposer && form.isConnected) root.OpenVibeComposer.enhance(form); } catch (e) { console.error(e); } };
const go = () => { try { if (root.OpenVibeComposer && form.isConnected) root.OpenVibeComposer.enhance(form); } catch (e) { console.error(e); } };
if (root.OpenVibeComposer) { go(); return; }
if (root.OpenVibeComposer) { go(); return; }
const m = /^(https?:\/\/[^?#]*\/)dock-vibe(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
const m = /^(https?:\/\/[^?#]*\/)dock-vibe(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
@@ -295,13 +298,13 @@
@@ -295,13 +298,13 @@
build.dispatchEvent(new CustomEvent('ov-dock:part', { bubbles: true, detail: { part: 'code', text: text.slice(0, 16000) } }));
build.dispatchEvent(new CustomEvent('ov-dock:part', { bubbles: true, detail: { part: 'code', text: text.slice(0, 16000) } }));
});
});
form.classList.add('ovdk-send--two');
form.classList.add('ovdk-send--two');
- const tools = h('div', 'ovdk-send-tools');
+ const tools = h('div', 'ovdk-send-tools'); tools.setAttribute('data-ovc-tools', ''); form.setAttribute('data-ovc-contain', '');
tools.append(attach, file, build);
tools.append(attach, file, build);
let mic = null;
let mic = null;
if (TALK) {
if (TALK) {
mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));
mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));
mic.setAttribute('aria-label', 'Talk to Vibe: hold, or press Alt+V'); mic.title = 'Hold to talk (Alt+V)'; mic.setAttribute('aria-pressed', 'false');
mic.setAttribute('aria-label', 'Talk to Vibe: hold, or press Alt+V'); mic.title = 'Hold to talk (Alt+V)'; mic.setAttribute('aria-pressed', 'false');
- tools.append(mic);
+ tools.prepend(mic); form.setAttribute('data-ovc-no-voice', '');
}
}
go.textContent = ''; go.innerHTML = svg(V.send);
go.textContent = ''; go.innerHTML = svg(V.send);
tools.append(go);
tools.append(go);
@@ -80,7 +80,7 @@
@@ -80,7 +80,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
- const DOCK_REV = { 'dock-vibe.js': 'def2148254a0', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
+ const DOCK_REV = { 'dock-vibe.js': 'e34bcde16fb8', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
function load(src, test) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -604,7 +604,7 @@
@@ -604,7 +604,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
*/
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'c96584376da1', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'cc406790c229', 'dock.js': '328a715e0ecd', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '1dd1ca512625', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };
+ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'c96584376da1', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f1d4e50592b4', 'dock.js': '1fb16839cb0e', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '4086b813ffda', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '02d7b0204716', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
// ov-tip.js loads at the first hover over a tooltip's owner.
@@ -721,11 +721,7 @@
@@ -721,11 +721,7 @@
if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(() => { formWatch = null; go(); }, { timeout: 2000 });
if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(() => { formWatch = null; go(); }, { timeout: 2000 });
else { const t = setTimeout(() => { formWatch = null; go(); }, 1200); if (t && typeof t.unref === 'function') t.unref(); }
else { const t = setTimeout(() => { formWatch = null; go(); }, 1200); if (t && typeof t.unref === 'function') t.unref(); }
}
}
- // A box that scrolls (a code block, a wide table, a list held to a height) with nothing focusable inside it cannot be
- // scrolled from a keyboard (WCAG 2.1.1; axe scrollable-region-focusable flagged pages on six sites, 2026-11-02). Each
- // such box gets tabindex="0", so Tab reaches it and the arrow keys scroll it. A box with a link or a button inside is
- // reachable already and is left alone; a page's own tabindex always wins. Run when the page settles, again a little
- // later for content that loads after it, and after a resize changes what overflows.
+ // A scrolling box with nothing focusable gets tabindex="0" so a keyboard can scroll it (WCAG 2.1.1); a page's own wins.
const SCROLL_FOCUSABLE = 'a[href],button,input,select,textarea,summary,iframe,[tabindex],[contenteditable="true"]';
const SCROLL_FOCUSABLE = 'a[href],button,input,select,textarea,summary,iframe,[tabindex],[contenteditable="true"]';
function focusScrollers() {
function focusScrollers() {
if (!document.body || !document.body.getElementsByTagName) return 0;
if (!document.body || !document.body.getElementsByTagName) return 0;
@@ -2787,6 +2783,8 @@
@@ -2787,6 +2783,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); },
+ /** The menu at a place (4.223.0). */
+ openMenu(p) { if (/^(vibe|chats|agent)$/.test(p)) return openMenuOn(p); const y = /^(notifications|recent|apps|you)$/.test(p); openSearch('', y ? 'you' : p === 'feed' ? 'feed' : 'here', null, y && p !== 'you' ? p : null); },
pop: popFrom,
pop: popFrom,
unpop,
unpop,
askAgent(text) { askAgent(text); },
askAgent(text) { askAgent(text); },
@@ -271,8 +271,10 @@
@@ -271,8 +271,10 @@
.openvibe-notif-item .ovn-under span { display: inline-flex; align-items: center; gap: 6px; }
.openvibe-notif-item .ovn-under span { display: inline-flex; align-items: center; gap: 6px; }
.openvibe-notif-item .ovn-under svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.openvibe-notif-item .ovn-under svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.openvibe-notif-item .content { min-width: 0; }
.openvibe-notif-item .content { min-width: 0; }
- .openvibe-notif-item .title { font-size: 13.5px; font-weight: 550; line-height: 1.32; display: flex; align-items: baseline; gap: 6px; }
- .openvibe-notif-item .title .t { min-width: 0; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+ /* 4.223.0: the title and its count are one run of text (the count overlapped the words when the title was a box). */
+ .openvibe-notif-item .title { font-size: 13.5px; font-weight: 550; line-height: 1.32; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+ .openvibe-notif-item .title .t { display: inline; }
+ .openvibe-notif-item .title .count { display: inline-block; margin-left: 6px; vertical-align: 1px; }
.openvibe-notif-item.unread .title { font-weight: 700; }
.openvibe-notif-item.unread .title { font-weight: 700; }
.openvibe-notif-item .title .count { flex: none; font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 50%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 15%, transparent); }
.openvibe-notif-item .title .count { flex: none; font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 50%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 15%, transparent); }
.openvibe-notif-item .msg { margin-top: 2px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); line-height: 1.42; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
.openvibe-notif-item .msg { margin-top: 2px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); line-height: 1.42; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
@@ -1076,7 +1078,7 @@
@@ -1076,7 +1078,7 @@
${p.message ? `<div class="msg">${esc(p.message)}</div>` : ''}
${p.message ? `<div class="msg">${esc(p.message)}</div>` : ''}
${chipsHtml({ ...p, chips })}
${chipsHtml({ ...p, chips })}
${cmd ? `<button type="button" class="copy" data-row="copy" data-cmd="${esc(cmd)}">Copy fix command</button>` : ''}
${cmd ? `<button type="button" class="copy" data-row="copy" data-cmd="${esc(cmd)}">Copy fix command</button>` : ''}
- <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${when}</span>${label}<div class="row-actions">${unread ? `<button type="button" data-row="read" title="Mark as read" aria-label="Mark as read">${svg(ICON.read)}</button>` : ''}<button type="button" data-row="dismiss" title="Clear" aria-label="Clear">${svg(ICON.close)}</button></div></div>
+ <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${when}</span>${label}<div class="row-actions"><button type="button" data-row="dismiss" title="Clear this notice" aria-label="Clear this notice">${svg(ICON.close)}</button></div></div>
</div>
</div>
${thumb ? `<img class="thumb" src="${esc(thumb)}" alt="" loading="lazy">` : ''}
${thumb ? `<img class="thumb" src="${esc(thumb)}" alt="" loading="lazy">` : ''}
</div>
</div>
@@ -283,6 +283,37 @@ html[data-ov-motion=off] .ovc-cam-shutter { transition: none; }
@@ -283,6 +283,37 @@ html[data-ov-motion=off] .ovc-cam-shutter { transition: none; }
.ovc-emoji button:hover, .ovc-emoji button:focus-visible { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); }
.ovc-emoji button:hover, .ovc-emoji button:focus-visible { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); }
@media (max-width: 480px) { .ovc-v2.ovt-composer { padding: 12px 12px 8px; } .ovc-emoji { grid-template-columns: repeat(7, 38px); } .ovc-v2 .ovt-send { padding: 0 16px; } }
@media (max-width: 480px) { .ovc-v2.ovt-composer { padding: 12px 12px 8px; } .ovc-emoji { grid-template-columns: repeat(7, 38px); } .ovc-v2 .ovt-send { padding: 0 16px; } }
html[data-ov-motion=off] .ovc-emoji { animation: none; }
html[data-ov-motion=off] .ovc-emoji { animation: none; }
+
+/* 4.223.0: the + sheet, the mic first, the examples' fade, the round send. */
+.ovc-v2, form.ovc[data-ovc-contain] { container: ovc / inline-size; }
+.ovc-more[aria-expanded="true"] { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 18%, transparent); }
+.ovc-more svg { transition: transform .25s cubic-bezier(.3, 1.4, .4, 1); }
+.ovc-more[aria-expanded="true"] svg { transform: rotate(45deg); }
+@container ovc (max-width: 460px) {
+ .ovt-c-tools > .ovt-btn:not(.ovc-mic):not(.ovc-more) ~ .ovt-btn:not(.ovc-mic):not(.ovc-more):not([aria-pressed="true"]) { display: none; }
+}
+.ovc-tray { grid-column: 1 / -1; flex: 1 0 100%; width: 100%; box-sizing: border-box; margin-top: 8px; padding: 8px 0 2px; border-top: 1px solid var(--border, rgba(148,163,184,.14)); animation: ovc-tray-in .22s cubic-bezier(.2, .9, .3, 1.15) both; }
+.ovc-tray-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 4px; }
+.ovc-tray-i { all: unset; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 7px; padding: 10px 4px 8px; border-radius: 14px; cursor: pointer; text-align: center; -webkit-tap-highlight-color: transparent; }
+.ovc-tray-ic { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 15px; color: var(--ovt-accent, var(--accent-light, var(--accent, #60a5fa)));
+ background: linear-gradient(150deg, color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 30%, transparent), color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 12%, transparent));
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 30%, transparent); transition: transform .2s cubic-bezier(.3, 1.4, .4, 1); }
+.ovc-tray-ic svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
+.ovc-tray-t { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-size: 13px; font-weight: 800; line-height: 1.2; color: var(--text-primary, #e6edf7); text-wrap: balance; }
+.ovc-tray-i:active .ovc-tray-ic { transform: scale(.92); }
+.ovc-tray-i:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+@media (hover: hover) and (pointer: fine) { .ovc-tray-i:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); } .ovc-tray-i:hover .ovc-tray-ic { transform: translateY(-2px); } }
+@keyframes ovc-tray-in { from { opacity: 0; transform: translateY(8px) scale(.98); } }
+html[data-ov-motion=reduced] .ovc-tray { animation-name: ovc-fade-calm; }
+@keyframes ovc-fade-calm { from { opacity: 0; } }
+html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { animation: none; transition: none; }
+.ovc textarea::placeholder, .ovt-composer textarea::placeholder { transition: opacity .25s ease; }
+.ovc textarea.ovc-ph-swap::placeholder, .ovt-composer textarea.ovc-ph-swap::placeholder { opacity: 0; }
+.ovc-v2 .ovt-send--round { background: linear-gradient(150deg, color-mix(in srgb, var(--accent, #3b82f6) 85%, #fff), var(--accent-strong, var(--accent, #2563eb))); color: var(--on-accent-strong, #fff);
+ box-shadow: 0 6px 18px -6px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); transition: transform .2s cubic-bezier(.3, 1.4, .4, 1), opacity .2s, box-shadow .2s; }
+.ovc-v2 .ovt-send--round svg { width: 20px; height: 20px; stroke-width: 2.3; }
+.ovc-v2 .ovt-send--round:disabled { opacity: 1; background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); color: var(--text-muted, #7386a3); box-shadow: none; }
+.ovc-v2 .ovt-send--round:not(:disabled):active { transform: scale(.9); }
`;
`;
function styles() { if (document.getElementById('ovc-styles')) return; const st = document.createElement('style'); st.id = 'ovc-styles'; st.textContent = CSS; document.head.appendChild(st); }
function styles() { if (document.getElementById('ovc-styles')) return; const st = document.createElement('style'); st.id = 'ovc-styles'; st.textContent = CSS; document.head.appendChild(st); }
const mods = [];
const mods = [];
@@ -337,6 +368,9 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -337,6 +368,9 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');
const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');
const IC = {
const IC = {
mic: 'M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3zM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',
mic: 'M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3zM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',
+ plus: 'M12 5v14M5 12h14',
+ timer: 'M12 4.5a8.2 8.2 0 1 1 0 16.4 8.2 8.2 0 0 1 0-16.4zM12 8.6v4.2l2.7 1.7M9.6 2.6h4.8',
+ up: 'M12 19V5.5M6.2 11.2L12 5.4l5.8 5.8',
x: 'M7 7l10 10M17 7L7 17',
x: 'M7 7l10 10M17 7L7 17',
file: 'M7 3h7l5 5v12a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM14 3v5h5',
file: 'M7 3h7l5 5v12a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM14 3v5h5',
slash: 'M15 4L9 20',
slash: 'M15 4L9 20',
@@ -412,7 +446,8 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -412,7 +446,8 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
const text = form.querySelector('[data-ovt-text]') || form.querySelector('textarea');
const text = form.querySelector('[data-ovt-text]') || form.querySelector('textarea');
if (!text) return null;
if (!text) return null;
const row = form.querySelector('.ovt-c-row');
const row = form.querySelector('.ovt-c-row');
- const tools = form.querySelector('.ovt-c-tools');
+ // A box drawn by its own page marks its row with data-ovc-tools (Vibe's); else the composer's own row.
+ const tools = form.querySelector('.ovt-c-tools') || form.querySelector('[data-ovc-tools]');
const note = form.querySelector('[data-ovt-note]');
const note = form.querySelector('[data-ovt-note]');
const send = form.querySelector('[data-ovt-send]') || form.querySelector('[type="submit"]');
const send = form.querySelector('[data-ovt-send]') || form.querySelector('[type="submit"]');
const inputs = () => [...form.querySelectorAll('input[type="file"]')];
const inputs = () => [...form.querySelectorAll('input[type="file"]')];
@@ -722,6 +757,63 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -722,6 +757,63 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
// Voice: the browser listens, the words go in at the caret as they come.
// Voice: the browser listens, the words go in at the caret as they come.
let mic = null, rec = null;
let mic = null, rec = null;
+ // 4.223.0, the +: every tool of this box in one sheet (its row's own, the mods', and the extras such as Timer), big
+ // and named. On a narrow box the row keeps the mic, the + and the first tool; the sheet has the rest.
+ let more = null, sheet = null;
+ function closeSheet() { if (sheet) { sheet.remove(); sheet = null; } if (more) more.setAttribute('aria-expanded', 'false'); }
+ function openSheet(ev) {
+ if (sheet) { closeSheet(); return; }
+ sheet = el('div', 'ovc-tray'); sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-label', 'Tools');
+ const grid = el('div', 'ovc-tray-grid');
+ const add = (label, path, act, drawn) => {
+ const b = el('button', 'ovc-tray-i', `<span class="ovc-tray-ic">${drawn ? '' : svg(PATH_OK.test(path || '') ? path : IC.slash)}</span><span class="ovc-tray-t"></span>`);
+ if (drawn) { const c = drawn.cloneNode(true); c.removeAttribute('width'); c.removeAttribute('height'); c.removeAttribute('class'); b.firstChild.appendChild(c); } // the button's own whole drawing
+ b.type = 'button'; b.querySelector('.ovc-tray-t').textContent = label;
+ b.addEventListener('click', () => { closeSheet(); try { act(); } catch (e) { console.error(e); } });
+ grid.appendChild(b);
+ };
+ const seen = new Set();
+ [...tools.children].forEach((t) => {
+ if (t === mic || t === more || !t.matches('.ovt-btn, button, label') || t.matches('[type="submit"], [data-ovt-send], .ovc-mic, .ovdk-mic, .ovdk-go, [hidden]')) return;
+ const label = t.getAttribute('aria-label') || t.title || ''; if (!label || seen.has(label)) return; seen.add(label);
+ const input = t.querySelector('input[type=file]');
+ add(label, '', () => { if (input) input.click(); else t.click(); }, t.querySelector('svg'));
+ });
+ Object.keys(TOOL).forEach((id) => {
+ const t = TOOL[id]; if (!t || !t.extra || seen.has(t.label) || form.hasAttribute(`data-ovc-no-${id}`)) return; seen.add(t.label);
+ add(t.label, t.path, () => t.run(ctx, more, form));
+ });
+ sheet.appendChild(grid);
+ form.appendChild(sheet); // inside the box, under its row: nothing can clip it
+ more.setAttribute('aria-expanded', 'true');
+ // From the keyboard (a click with no pointer) focus moves into the sheet; a tap leaves it on the +.
+ if (ev && ev.detail === 0) { const first = sheet.querySelector('button'); if (first) first.focus(); }
+ sheet.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeSheet(); more.focus(); } });
+ }
+ if (tools && !form.hasAttribute('data-ovc-no-more')) {
+ more = el('button', 'ovt-btn ovc-more', svg(IC.plus));
+ more.type = 'button'; more.setAttribute('aria-label', 'More: attach, timer and tools'); more.title = 'More';
+ more.setAttribute('aria-haspopup', 'dialog'); more.setAttribute('aria-expanded', 'false');
+ more.addEventListener('click', openSheet);
+ const lead = tools.querySelector(':scope > .ovdk-mic'); // a page's own mic stays first
+ tools.insertBefore(more, lead ? lead.nextSibling : tools.firstChild);
+ document.addEventListener('pointerdown', (e) => { if (sheet && !sheet.contains(e.target) && !more.contains(e.target)) closeSheet(); });
+ }
+ // What people do with this box, in turn while it is empty and you are elsewhere (Calm: slower; Off: the first stays).
+ {
+ let ex = null; try { ex = JSON.parse(form.dataset.ovcExamples || 'null'); } catch { ex = null; }
+ if (Array.isArray(ex) && ex.length > 1 && typeof setInterval === 'function') {
+ let k = 0;
+ const motion = () => (document.documentElement.getAttribute('data-ov-motion') || '');
+ const tick = setInterval(() => {
+ if (!form.isConnected) { clearInterval(tick); return; }
+ if (motion() === 'off' || text.value || document.activeElement === text) return;
+ k = (k + 1) % ex.length;
+ text.classList.add('ovc-ph-swap');
+ setTimeout(() => { text.placeholder = String(ex[k]).slice(0, 80); text.classList.remove('ovc-ph-swap'); }, motion() === 'reduced' ? 120 : 260);
+ }, motion() === 'reduced' ? 8000 : 4200);
+ }
+ }
function micButton() {
function micButton() {
if (!Speech || !tools) return;
if (!Speech || !tools) return;
if (!prefs.voice || form.hasAttribute('data-ovc-no-voice')) { if (mic) { mic.remove(); mic = null; } return; }
if (!prefs.voice || form.hasAttribute('data-ovc-no-voice')) { if (mic) { mic.remove(); mic = null; } return; }
@@ -740,7 +832,8 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -740,7 +832,8 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
rec.onerror = (ev) => { if (ev.error === 'not-allowed') say('The microphone is blocked for this site.'); };
rec.onerror = (ev) => { if (ev.error === 'not-allowed') say('The microphone is blocked for this site.'); };
try { rec.start(); mic.setAttribute('aria-pressed', 'true'); mic.title = 'Stop listening'; } catch { rec = null; }
try { rec.start(); mic.setAttribute('aria-pressed', 'true'); mic.title = 'Stop listening'; } catch { rec = null; }
});
});
- tools.appendChild(mic);
+ tools.insertBefore(mic, tools.firstChild);
+ if (more && more.parentNode === tools) tools.insertBefore(more, mic.nextSibling);
}
}
// The mods' tools: buttons in the row, in the order the mods came.
// The mods' tools: buttons in the row, in the order the mods came.
@@ -753,7 +846,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -753,7 +846,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
const b = el('button', 'ovt-btn ovc-tool', svg(PATH_OK.test(t.path || '') ? t.path : IC.slash));
const b = el('button', 'ovt-btn ovc-tool', svg(PATH_OK.test(t.path || '') ? t.path : IC.slash));
b.type = 'button'; b.setAttribute('aria-label', t.label); b.title = t.label;
b.type = 'button'; b.setAttribute('aria-label', t.label); b.title = t.label;
b.addEventListener('click', () => { try { t.run(ctx); } catch (e) { console.error(e); } });
b.addEventListener('click', () => { try { t.run(ctx); } catch (e) { console.error(e); } });
- tools.insertBefore(b, mic && mic.parentNode === tools ? mic : null);
+ tools.appendChild(b);
modTools.push(b);
modTools.push(b);
}
}
}
}
@@ -764,7 +857,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -764,7 +857,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
add, files: () => files.map((f) => f.file), clearFiles: () => { [...files].forEach(remove); },
add, files: () => files.map((f) => f.file), clearFiles: () => { [...files].forEach(remove); },
uploads: () => files.map((f) => f.id).filter(Boolean),
uploads: () => files.map((f) => f.id).filter(Boolean),
clear() { files.slice().forEach(remove); text.value = ''; if (draftKey) store.set(draftKey, null); fire(); },
clear() { files.slice().forEach(remove); text.value = ''; if (draftKey) store.set(draftKey, null); fire(); },
- destroy() { live.delete(api); pop.remove(); strip.remove(); if (mic) mic.remove(); modTools.forEach((b) => b.remove()); form.__ovc = null; },
+ destroy() { live.delete(api); pop.remove(); strip.remove(); if (mic) mic.remove(); if (more) more.remove(); closeSheet(); modTools.forEach((b) => b.remove()); form.__ovc = null; },
};
};
form.__ovc = api;
form.__ovc = api;
form.addEventListener('reset', () => setTimeout(() => api.clear(), 0));
form.addEventListener('reset', () => setTimeout(() => api.clear(), 0));
@@ -802,6 +895,10 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -802,6 +895,10 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
// value = { verb, text, files, uploads, model, sources, depth, context, chips }.
// value = { verb, text, files, uploads, model, sources, depth, context, chips }.
// A tool is a module: OpenVibeComposer.tool(id, { label, path, accept? | run(ctx, button, form) }) adds or replaces
// A tool is a module: OpenVibeComposer.tool(id, { label, path, accept? | run(ctx, button, form) }) adds or replaces
// one for every box; a preset or a site names the ones it shows. Mods (mod()) still add their own buttons.
// one for every box; a preset or a site names the ones it shows. Mods (mod()) still add their own buttons.
+ const EXAMPLES = {
+ agent: ['Ask for anything', 'Find a live stream about cooking', 'Start a 10-minute timer', 'Summarize today’s Rundown', 'Write a title for my stream', 'What did people post today?'],
+ post: ["What's new?", 'Share a photo from today', 'Ask your followers a question', 'Post a clip you made'],
+ };
const PRESET = {
const PRESET = {
post: { tools: ['photo', 'camera', 'file', 'emoji', 'voice'], send: 'Post', avatar: true, rows: 2, max: 2000, ring: true, resize: 2560 },
post: { tools: ['photo', 'camera', 'file', 'emoji', 'voice'], send: 'Post', avatar: true, rows: 2, max: 2000, ring: true, resize: 2560 },
chat: { tools: ['attach', 'emoji', 'voice'], send: '', avatar: false, rows: 1, max: 4000, ring: false, resize: 2560 },
chat: { tools: ['attach', 'emoji', 'voice'], send: '', avatar: false, rows: 1, max: 4000, ring: false, resize: 2560 },
@@ -811,6 +908,17 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -811,6 +908,17 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
const EMOJI = '😀 😂 🥹 😊 😍 🥰 😘 😎 🤩 🥳 😇 🙂 😉 😌 🤔 🤨 😐 🙄 😏 😬 😮 😱 😭 😤 😡 🤯 🥶 🥵 😴 🤤 🤗 🫡 🤝 👍 👎 👏 🙌 🙏 💪 👀 🫶 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ✨ 🔥 💯 🎉 🎊 🏆 🎮 🎧 🎵 📸 🎬 🍕 🍔 🍿 ☕ 🍻 🌈 🌙 ⭐ ⚡ 💥 💀 🤖 👻 🐐 🦆 🐸 🚀 ✅ ❌ ⚠️ ❓ 👋 🫠'.split(' ');
const EMOJI = '😀 😂 🥹 😊 😍 🥰 😘 😎 🤩 🥳 😇 🙂 😉 😌 🤔 🤨 😐 🙄 😏 😬 😮 😱 😭 😤 😡 🤯 🥶 🥵 😴 🤤 🤗 🫡 🤝 👍 👎 👏 🙌 🙏 💪 👀 🫶 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ✨ 🔥 💯 🎉 🎊 🏆 🎮 🎧 🎵 📸 🎬 🍕 🍔 🍿 ☕ 🍻 🌈 🌙 ⭐ ⚡ 💥 💀 🤖 👻 🐐 🦆 🐸 🚀 ✅ ❌ ⚠️ ❓ 👋 🫠'.split(' ');
const TOOL = {
const TOOL = {
photo: { label: 'Photos and video', path: IC.photo, accept: 'image/*,video/*' },
photo: { label: 'Photos and video', path: IC.photo, accept: 'image/*,video/*' },
+ // 4.223.0: in every box's + (extra: never in a row of its own). Your timers are Vibe's: one list on every site.
+ timer: {
+ label: 'Timer', path: IC.timer, extra: true,
+ run(ctx, button) {
+ const D = root.OpenVibeDock;
+ if (D && typeof D.timer === 'function') { try { D.timer(button); return; } catch (e) { console.error(e); } }
+ const N = root.OpenVibeNavbar;
+ if (N && typeof N.openMenuOn === 'function') { try { N.openMenuOn('vibe'); ctx.note('Say “a timer for 10 minutes” to Vibe.'); return; } catch { /* below */ } }
+ ctx.note('Sign in to set timers: they ring on every OpenVibe site.');
+ },
+ },
file: { label: 'Files', path: IC.file, accept: '' },
file: { label: 'Files', path: IC.file, accept: '' },
attach: { label: 'Attach', path: IC.clip, accept: '' },
attach: { label: 'Attach', path: IC.clip, accept: '' },
emoji: {
emoji: {
@@ -959,6 +1067,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -959,6 +1067,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
ta.name = o.name || 'text'; ta.rows = p.rows; ta.maxLength = o.maxLength || p.max;
ta.name = o.name || 'text'; ta.rows = p.rows; ta.maxLength = o.maxLength || p.max;
ta.placeholder = o.placeholder || (name === 'post' ? "What's new?" : name === 'agent' ? 'Ask for anything' : name === 'comment' ? 'Write a reply' : 'Write a message');
ta.placeholder = o.placeholder || (name === 'post' ? "What's new?" : name === 'agent' ? 'Ask for anything' : name === 'comment' ? 'Write a reply' : 'Write a message');
ta.setAttribute('aria-label', o.label || ta.placeholder);
ta.setAttribute('aria-label', o.label || ta.placeholder);
+ { const ex = Array.isArray(o.examples) ? o.examples : (o.placeholder ? null : EXAMPLES[name]); if (ex && ex.length > 1) form.dataset.ovcExamples = JSON.stringify(ex.slice(0, 8)); }
ta.setAttribute('data-ovt-text', '');
ta.setAttribute('data-ovt-text', '');
if (o.required !== false) ta.required = true;
if (o.required !== false) ta.required = true;
const row = el('div', 'ovt-c-row'), tools = el('div', 'ovt-c-tools'), chips = el('div', 'ovc-chips');
const row = el('div', 'ovt-c-row'), tools = el('div', 'ovt-c-tools'), chips = el('div', 'ovc-chips');
@@ -1007,7 +1116,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -1007,7 +1116,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
ring.innerHTML = '<svg viewBox="0 0 26 26" width="26" height="26" aria-hidden="true"><circle class="ovc-ring-bg" cx="13" cy="13" r="10"/><circle class="ovc-ring-fg" cx="13" cy="13" r="10" stroke-dasharray="62.83" stroke-dashoffset="62.83" transform="rotate(-90 13 13)"/></svg>';
ring.innerHTML = '<svg viewBox="0 0 26 26" width="26" height="26" aria-hidden="true"><circle class="ovc-ring-bg" cx="13" cy="13" r="10"/><circle class="ovc-ring-fg" cx="13" cy="13" r="10" stroke-dasharray="62.83" stroke-dashoffset="62.83" transform="rotate(-90 13 13)"/></svg>';
ring.hidden = true;
ring.hidden = true;
}
}
- const send = el('button', p.send ? 'ovt-send' : 'ovt-send ovt-send--round', p.send ? '' : svg(IC.send));
+ const send = el('button', p.send ? 'ovt-send' : 'ovt-send ovt-send--round', p.send ? '' : svg(IC.up));
if (p.send) send.textContent = o.submitLabel || p.send;
if (p.send) send.textContent = o.submitLabel || p.send;
send.type = 'submit'; send.disabled = true; send.setAttribute('data-ovt-send', '');
send.type = 'submit'; send.disabled = true; send.setAttribute('data-ovt-send', '');
send.setAttribute('aria-label', o.submitLabel || p.send || 'Send');
send.setAttribute('aria-label', o.submitLabel || p.send || 'Send');
@@ -36,7 +36,8 @@
@@ -36,7 +36,8 @@
const safe = (u) => { if (u == null || String(u).trim() === '') return null; try { const x = new URL(String(u), location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } };
const safe = (u) => { if (u == null || String(u).trim() === '') return null; try { const x = new URL(String(u), location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } };
const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const ago = (iso) => {
const ago = (iso) => {
- const t = Date.parse(iso || ''); if (!t) return '';
+ // A time without a zone is UTC (see search.js utcOf).
+ const v = String(iso || ''); const t = Date.parse(/^\d{4}-\d\d-\d\d[ T]\d\d:\d\d(:\d\d(\.\d+)?)?$/.test(v) ? `${v.replace(' ', 'T')}Z` : v); if (!t) return '';
const s = Math.max(1, Math.round((Date.now() - t) / 1000));
const s = Math.max(1, Math.round((Date.now() - t) / 1000));
if (s < 60) return 'now'; if (s < 3600) return `${Math.round(s / 60)} min ago`; if (s < 86400) return `${Math.round(s / 3600)} h ago`;
if (s < 60) return 'now'; if (s < 3600) return `${Math.round(s / 60)} min ago`; if (s < 86400) return `${Math.round(s / 3600)} h ago`;
const d = Math.round(s / 86400); return d === 1 ? 'yesterday' : `${d} days ago`;
const d = Math.round(s / 86400); return d === 1 ? 'yesterday' : `${d} days ago`;
@@ -247,6 +248,210 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -247,6 +248,210 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
.ovh-actor.is-invite .ovh-go { grid-column: 3; grid-row: 1; }
.ovh-actor.is-invite .ovh-go { grid-column: 3; grid-row: 1; }
.ovh-actor.is-invite .ovh-x { grid-column: 4; grid-row: 1; display: grid; place-items: center; width: 36px; min-width: 36px; padding: 0; border-radius: 50%; }
.ovh-actor.is-invite .ovh-x { grid-column: 4; grid-row: 1; display: grid; place-items: center; width: 36px; min-width: 36px; padding: 0; border-radius: 50%; }
.ovh-actor.is-invite .ovh-x svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; }
.ovh-actor.is-invite .ovh-x svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; }
+
+/* 4.223.0: You's three tabs, the one notice above them, and an empty tab said with its mark (openvibe.design rule 141). */
+.ovh-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; margin: 10px 0 12px; padding: 4px; border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); }
+.ovh-tab { all: unset; box-sizing: border-box; position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 44px; padding: 0 8px; border-radius: 12px; cursor: pointer; font-size: 14px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }
+.ovh-tab svg { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
+.ovh-tab:hover { color: var(--text-primary, #e6edf7); }
+.ovh-tab:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #60a5fa)); outline-offset: -2px; }
+.ovh-tab[aria-selected="true"] { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #121826); box-shadow: 0 1px 2px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
+.ovh-tab[aria-selected="true"] svg { color: var(--accent-light, var(--accent, #60a5fa)); }
+.ovh-tab-n { min-width: 18px; height: 18px; padding: 0 5px; box-sizing: border-box; border-radius: 999px; background: var(--danger, #ef4444); color: #fff; font: 750 11px/18px inherit; text-align: center; }
+@media (max-width: 380px) { .ovh-tab { gap: 5px; font-size: 13px; } .ovh-tab svg { width: 16px; height: 16px; } }
+.ovh-notes { min-height: 120px; }
+.ovh-empty { display: flex; align-items: center; gap: 14px; margin: 6px 0; padding: 14px 16px; border-radius: 14px; border: 1px dashed color-mix(in srgb, var(--text-muted, #93a0b4) 45%, transparent); }
+.ovh-empty > svg { width: 22px; height: 22px; flex: none; fill: none; stroke: var(--accent-light, var(--accent, #60a5fa)); stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
+.ovh-empty > div { display: grid; gap: 2px; } .ovh-empty b { font-size: 14.5px; } .ovh-empty span { font-size: 13px; color: var(--text-muted, #93a0b4); }
+/* Your own AI is one notice under your row: a slim line, not a card (the owner: "make that a notification … up there"). */
+.ovh-notice .ovh-actor.is-invite { margin: 8px 0 0; padding: 8px 8px 8px 10px; gap: 10px; min-height: 0; border-radius: 14px; }
+.ovh-notice .ovh-actor.is-invite .ovh-face { width: 32px; height: 32px; border-radius: 10px; }
+.ovh-notice .ovh-actor.is-invite .ovh-actor-txt small { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
+.ovh-notice .ovh-actor.is-invite .ovh-acts { flex-direction: row; align-items: center; }
+@media (max-width: 480px) { .ovh-notice .ovh-actor.is-invite { flex-wrap: nowrap; } .ovh-notice .ovh-actor.is-invite .ovh-actor-txt small { -webkit-line-clamp: 2; } }
+
+/* ── 4.223.0: You, redone (the owner, 11-12) ──────────────────────────────────────────────────────────────────────────
+ One top (you, and your AI beside you when there is room), one tab row (Apps · Notifications · Recent, with All and
+ Edit at its end), then the tab. App icons draw the glyph at 58% of the tile, placed absolutely so a hover can never
+ pull it off centre; a folder's four sit in an even 2×2 with their glyphs at two thirds. Names wrap to two balanced
+ lines and never end in "…". */
+.ovh { container: ovh / inline-size; }
+.ovh-top { display: grid; gap: 6px; margin: 2px 4px 0; }
+.ovh-top > * { min-width: 0; }
+.ovh-top .ovh-me, .ovh-top .ovh-notice .ovh-actor { margin: 0; width: 100%; }
+@container ovh (min-width: 600px) {
+ .ovh-top:has(.ovh-notice > *) { grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); align-items: stretch; }
+ .ovh-top .ovh-notice > .ovh-actor { height: 100%; }
+}
+.ovh-notice:empty { display: none; }
+.ovh-notice .ovh-actor { box-sizing: border-box; min-height: 56px; padding: 8px 12px; border-radius: 16px; }
+.ovh-notice .ovh-actor .ovh-face { width: 36px; height: 36px; font-size: 15px; }
+/* Your row: you, then your round buttons (this site's own first). On a narrow screen the buttons go under your name. */
+.ovh-me { flex-wrap: wrap; row-gap: 0; padding: 6px 6px 6px 8px; min-height: 56px; box-sizing: border-box; }
+.ovh-acts { display: flex; align-items: center; gap: 4px; flex: none; }
+.ovh-ib.is-own { color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 13%, transparent); }
+.ovh-ib.is-text { width: auto; padding: 0 12px; border-radius: 999px; font-size: 12.5px; font-weight: 750; white-space: nowrap; }
+@container ovh (max-width: 520px) {
+ .ovh-me { padding: 8px 8px 6px 10px; }
+ .ovh-acts { flex: 1 1 100%; flex-wrap: wrap; gap: 6px; padding: 6px 0 0 52px; }
+ .ovh-acts .ovh-out { margin-left: auto; }
+}
+/* The tab row: on a phone only the chosen tab says its name (the others are their glyph and count), so the row also
+ holds All and Edit; from 520 px every tab says its name. It stays at the top while the apps scroll. */
+.ovh-tabrow { position: sticky; top: 0; z-index: 4; display: flex; align-items: center; gap: 8px; margin: 8px 0 6px; padding: 4px;
+ background: color-mix(in srgb, var(--bg-secondary, #121826) 90%, transparent); -webkit-backdrop-filter: blur(14px) saturate(1.3); backdrop-filter: blur(14px) saturate(1.3); border-radius: 16px; }
+.ovh-tabrow .ovh-tabs { flex: 1 1 auto; min-width: 0; display: flex; gap: 4px; margin: 0; padding: 0; background: none; }
+.ovh-tabrow .ovh-tab { flex: 0 0 auto; min-width: 44px; min-height: 42px; padding: 0 12px; border-radius: 12px; transition: background-color .18s, color .18s, flex-grow .25s; }
+.ovh-tabrow .ovh-tab[aria-selected="true"] { flex: 1 1 auto; }
+.ovh-tabrow .ovh-headtools { flex: none; display: flex; align-items: center; gap: 4px; }
+@container ovh (max-width: 519px) {
+ .ovh-tabrow .ovh-tab:not([aria-selected="true"]) .ovh-tab-t { display: none; }
+ .ovh-tabrow .ovh-tab:not([aria-selected="true"]) { padding: 0 10px; }
+}
+@container ovh (min-width: 520px) { .ovh-tabrow .ovh-tab { flex: 0 1 auto; } .ovh-tabrow .ovh-tab[aria-selected="true"] { flex: 0 1 auto; } }
+.ovh-tabrow .ovh-allbtn, .ovh-tabrow .ovh-edit { min-height: 42px; }
+.ovh-tabrow .ovh-edit { width: 42px; }
+.ovh-tabrow .ovh-edit.is-done { width: auto; }
+/* Section heads: a marked glyph, the name, its count; buttons at the end. */
+.ovh-sec { gap: 8px; padding: 12px 6px 6px; }
+.ovh-sec > span { flex: 0 1 auto; min-width: 0; }
+.ovh-sec-sp { flex: 1; }
+.ovh-sec-ic { flex: none; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 8px; background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
+.ovh-sec-ic svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2.1; stroke-linecap: round; stroke-linejoin: round; }
+.ovh-sec-n { flex: none; min-width: 20px; height: 20px; padding: 0 6px; box-sizing: border-box; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 9%, transparent); color: var(--text-secondary, #a8b3c4); font-size: 11px; font-weight: 750; line-height: 20px; letter-spacing: 0; text-align: center; }
+/* Back is a round arrow; in All apps the search sits beside it. */
+.ovh-head { gap: 8px; padding: 6px 4px; }
+.ovh-back { flex: none; display: inline-grid; place-items: center; width: 44px; height: 44px; min-height: 44px; padding: 0; border-radius: 50%; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); color: var(--text-primary, #e8ecf3); transition: background-color .15s, transform .12s; }
+.ovh-back svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2.1; stroke-linecap: round; stroke-linejoin: round; }
+.ovh-back:active { transform: scale(.92); }
+.ovh-back:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+@media (hover: hover) { .ovh-back:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 15%, transparent); } }
+.ovh-head .ovh-filter { flex: 1; min-width: 0; width: auto; margin: 0; }
+/* The grid: four across on a phone, as many as fit from 560 px. */
+.ovh-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+@media (min-width: 560px) { .ovh-grid { grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); } }
+/* App icons: the glyph at 58% of the tile. */
+.ovh-ic > .ov-icon { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
+.ovh-ic > .ov-icon svg { position: absolute; left: -13%; top: -13%; width: 126%; height: 126%; margin: 0; transform: none !important; }
+.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: auto; gap: 9%; padding: 14%; box-sizing: border-box; place-content: center stretch; align-content: center; }
+.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic > span { position: relative; display: block; width: 100%; height: auto; aspect-ratio: 1; align-self: center; border-radius: 30%; overflow: hidden;
+ background: linear-gradient(150deg, hsl(var(--ovh-h, 215) 72% 58% / .55), hsl(var(--ovh-h, 215) 70% 38% / .35)); box-shadow: inset 0 0 0 1px hsl(var(--ovh-h, 215) 70% 66% / .3); }
+.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic > span > .ov-icon { position: absolute; inset: 0; width: 100%; height: 100%; display: block; --ovi-glyph: hsl(var(--ovh-h, 215) 92% 86%); color: hsl(var(--ovh-h, 215) 92% 86%); }
+.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic > span > .ov-icon svg { position: absolute; left: -23%; top: -23%; width: 146%; height: 146%; margin: 0; transform: none !important; }
+:root[data-theme="light"] .ovh-grid .ovh-folder .ovh-ic > span > .ov-icon { --ovi-glyph: hsl(var(--ovh-h, 215) 70% 28%); color: hsl(var(--ovh-h, 215) 70% 28%); }
+/* Names: two balanced lines, centred, never "…". */
+.ovh-name { white-space: normal; text-overflow: clip; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: center; line-height: 1.2; text-wrap: balance; overflow-wrap: normal; word-break: normal; }
+.ovh-grid:not(.ovh-recent) .ovh-name { font-size: 12px; letter-spacing: -.005em; }
+/* A + to put an app on Home sits on its icon's corner, not in the cell's. */
+.ovh-grid .ovh-app > .ovh-badge { top: 0; left: calc(50% - min(50% - 6px, 36px) - 8px); width: 24px; height: 24px; font-size: 15px; }
+/* Touch, pen and mouse: a press sinks the icon at once; a mouse lifts it and lights it in its own hue. */
+.ovh-ic { transition: transform .22s cubic-bezier(.2, 1.4, .4, 1), box-shadow .22s; }
+.ovh-app:active .ovh-ic { transform: scale(.93); transition-duration: .08s; }
+@media (hover: hover) and (pointer: fine) {
+ .ovh-app:hover .ovh-ic { transform: translateY(-3px) scale(1.04); box-shadow: inset 0 0 0 1px hsl(var(--ovh-h, 215) 75% 70% / .5), 0 12px 26px -8px hsl(var(--ovh-h, 215) 75% 45% / .6); }
+ .ovh-app:hover .ovh-name { opacity: 1; }
+}
+html[data-ov-motion=off] .ovh-ic { transition: none; }
+/* Recent: where you were, in days, as cards with the whole title; two columns when there is room. */
+.ovh-hist-list { display: grid; gap: 8px; padding: 0 4px; }
+@container ovh (min-width: 600px) { .ovh-hist-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+.ovh-hist-day { grid-column: 1 / -1; margin: 8px 4px 0; font-size: 12px; font-weight: 800; letter-spacing: .02em; color: var(--text-muted, #93a0b4); }
+.ovh-hist-day:first-child { margin-top: 0; }
+.ovh-hrow { box-sizing: border-box; display: flex; align-items: center; gap: 12px; min-width: 0; min-height: 70px; padding: 10px 12px; border-radius: 16px; color: inherit; text-decoration: none;
+ background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.08));
+ transition: background-color .15s, transform .15s; animation: ovh-rise .34s cubic-bezier(.2, .8, .2, 1) both; animation-delay: calc(var(--i, 0) * 22ms); }
+.ovh-hrow .ovh-ic { flex: none; width: 48px; height: 48px; border-radius: 15px; }
+.ovh-htxt { min-width: 0; display: grid; gap: 3px; }
+.ovh-htxt b { font-size: 14.5px; font-weight: 700; line-height: 1.28; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
+.ovh-htxt small { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }
+.ovh-hrow.is-live { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--live-red, #ef4444) 45%, transparent); }
+.ovh-hrow:active { transform: scale(.985); }
+.ovh-hrow:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+@media (hover: hover) and (pointer: fine) { .ovh-hrow:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); transform: translateY(-1px); } }
+.ovh-hist-more[hidden] { display: none; }
+@keyframes ovh-rise { from { opacity: 0; transform: translateY(8px); } }
+@keyframes ovh-rise-calm { from { opacity: 0; } }
+html[data-ov-motion=reduced] .ovh-hrow { animation-name: ovh-rise-calm; animation-delay: 0s; }
+html[data-ov-motion=off] .ovh-hrow { animation: none; }
+
+/* 4.223.0, round two: All and Edit in the tab row; the tabs as one segmented control whose ink slides to the chosen tab,
+ and the tab's content slides in from its side (the owner, 11-12: "animations, effects to the tabs … aligned more
+ perfectly … bigger font size and better icon size"). */
+.ovh-tabrow .ovh-tabs { position: relative; isolation: isolate; }
+.ovh-tab-ink { position: absolute; left: 0; top: 0; bottom: 0; width: 0; z-index: -1; border-radius: 12px; opacity: 0;
+ background: var(--bg-secondary, #121826); box-shadow: 0 1px 2px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent), 0 6px 18px -8px color-mix(in srgb, var(--accent, #3b82f6) 60%, transparent);
+ transition: transform .34s cubic-bezier(.3, 1.3, .4, 1), width .34s cubic-bezier(.3, 1.3, .4, 1), opacity .2s; }
+.ovh-tab-ink.is-on { opacity: 1; }
+:root[data-theme="light"] .ovh-tab-ink { background: #fff; }
+.ovh-tabrow .ovh-tab[aria-selected="true"] { background: transparent; box-shadow: none; }
+.ovh-tabrow .ovh-tab { font-size: 14.5px; gap: 8px; }
+.ovh-tabrow .ovh-tab svg { width: 19px; height: 19px; transition: transform .25s cubic-bezier(.3, 1.4, .4, 1); }
+.ovh-tabrow .ovh-tab[aria-selected="true"] svg { transform: scale(1.08); }
+@media (hover: hover) and (pointer: fine) { .ovh-tabrow .ovh-tab:not([aria-selected="true"]):hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); } }
+.ovh-tabrow .ovh-tab:active svg { transform: scale(.9); }
+html[data-ov-motion=reduced] .ovh-tab-ink { transition: opacity .25s; }
+html[data-ov-motion=off] .ovh-tab-ink, html[data-ov-motion=off] .ovh-tabrow .ovh-tab svg { transition: none; }
+.ovh[data-enter] > :not(.ovh-top):not(.ovh-tabrow) { animation: ovh-pane-r .3s cubic-bezier(.2, .8, .2, 1) both; }
+.ovh[data-enter="left"] > :not(.ovh-top):not(.ovh-tabrow) { animation-name: ovh-pane-l; }
+@keyframes ovh-pane-r { from { opacity: 0; transform: translateX(18px); } }
+@keyframes ovh-pane-l { from { opacity: 0; transform: translateX(-18px); } }
+html[data-ov-motion=reduced] .ovh[data-enter] > :not(.ovh-top):not(.ovh-tabrow) { animation-name: ovh-rise-calm; }
+html[data-ov-motion=off] .ovh[data-enter] > :not(.ovh-top):not(.ovh-tabrow) { animation: none; }
+.ovh-tabrow .ovh-allbtn, .ovh-tabrow .ovh-edit { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 42px; border-radius: 12px; cursor: pointer;
+ font-size: 13.5px; font-weight: 700; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent); transition: background-color .15s, color .15s, transform .12s; -webkit-tap-highlight-color: transparent; }
+.ovh-tabrow .ovh-allbtn { padding: 0 12px 0 10px; }
+.ovh-tabrow .ovh-edit { width: 42px; }
+.ovh-tabrow .ovh-edit.is-done { width: auto; padding: 0 14px 0 11px; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
+.ovh-tabrow .ovh-allbtn svg, .ovh-tabrow .ovh-edit svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+.ovh-tabrow .ovh-allbtn b { font-weight: 800; font-variant-numeric: tabular-nums; color: var(--text-primary, #e8ecf3); }
+.ovh-tabrow .ovh-allbtn:focus-visible, .ovh-tabrow .ovh-edit:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+.ovh-tabrow .ovh-allbtn:active, .ovh-tabrow .ovh-edit:active { transform: scale(.94); }
+@media (hover: hover) and (pointer: fine) { .ovh-tabrow .ovh-allbtn:hover, .ovh-tabrow .ovh-edit:not(.is-done):hover { color: var(--text-primary, #e8ecf3); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 13%, transparent); } }
+@container ovh (max-width: 380px) { .ovh-tabrow .ovh-allbtn span { display: none; } }
+
+/* Your own AI, not yet set up: one slim row under you (its mark, two short lines, Set it up, ×). */
+.ovh-notice .ovh-actor.is-invite { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 8px 8px 8px 10px; }
+.ovh-notice .ovh-actor.is-invite .ovh-face { flex: none; grid-row: auto; align-self: center; width: 40px; height: 40px; border-radius: 12px; }
+.ovh-notice .ovh-actor.is-invite .ovh-actor-txt { display: grid; flex: 1; min-width: 0; gap: 1px; }
+.ovh-notice .ovh-actor.is-invite .ovh-actor-txt small { -webkit-line-clamp: 2; font-size: 12.5px; line-height: 1.3; }
+.ovh-notice .ovh-actor.is-invite .ovh-acts { flex: none; display: flex; flex-direction: row; align-items: center; gap: 4px; grid-column: auto; padding: 0; }
+.ovh-notice .ovh-actor.is-invite .ovh-go { height: 38px; padding: 0 14px; }
+.ovh-notice .ovh-actor.is-invite .ovh-x { display: grid; place-items: center; width: 38px; min-height: 38px; height: 38px; padding: 0; }
+.ovh-notice .ovh-actor.is-invite .ovh-x svg { width: 16px; height: 16px; }
+
+/* Names in the brand face (OV Brand, Nunito 700–900, loaded by the navbar on every site): app names, the tabs, section
+ heads; the words you read (times, notes) stay in the UI face. Section heads are names now, not small capitals. */
+.ovh-name, .ovh-tab, .ovh-sec > span, .ovh-allbtn, .ovh-hist-day, .ovh-htxt b, .ovh-who-txt b, .ovh-actor-txt b { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; }
+.ovh-grid:not(.ovh-recent) .ovh-name { font-size: 13px; font-weight: 800; letter-spacing: .005em; color: color-mix(in srgb, var(--text-primary, #e8ecf3) 92%, transparent); opacity: 1; }
+.ovh-tabrow .ovh-tab { font-weight: 800; letter-spacing: .005em; }
+.ovh-sec { font-size: 15px; font-weight: 800; letter-spacing: .005em; text-transform: none; color: var(--text-primary, #e8ecf3); }
+.ovh-sec small { font-family: inherit; }
+.ovh-hist-day { font-size: 13px; font-weight: 800; letter-spacing: .01em; color: var(--text-secondary, #a8b3c4); }
+.ovh-who-txt b { font-size: 16px; font-weight: 800; letter-spacing: .005em; }
+.ovh-actor-txt b { font-weight: 800; }
+.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic { gap: 7%; padding: 11%; }
+
+/* The AI card: its face says what it is doing (a glyph on its picture's corner, or the whole face), working ones breathe
+ (Calm: slower; Off: still); Open is a round arrow, Answer a pill with its glyph. */
+.ovh-actor .ovh-face { position: relative; overflow: visible; }
+.ovh-actor .ovh-face img { border-radius: 50%; }
+.ovh-doing { position: absolute; right: -4px; bottom: -4px; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; color: #fff;
+ background: linear-gradient(140deg, var(--accent, #3b82f6), color-mix(in srgb, var(--accent, #3b82f6) 45%, #a855f7)); box-shadow: 0 0 0 2px var(--bg-secondary, #141a24); }
+.ovh-doing svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.3; stroke-linecap: round; stroke-linejoin: round; }
+.ovh-face.is-glyph { border-radius: 13px; background: linear-gradient(140deg, var(--accent, #3b82f6), color-mix(in srgb, var(--accent, #3b82f6) 45%, #a855f7)); }
+.ovh-face.is-glyph .ovh-doing { position: static; width: 100%; height: 100%; border-radius: inherit; background: none; box-shadow: none; }
+.ovh-face.is-glyph .ovh-doing svg { width: 22px; height: 22px; }
+.ovh-doing.is-waiting { background: linear-gradient(140deg, #f59e0b, #ef4444); }
+.ovh-face.is-glyph:has(.is-waiting) { background: linear-gradient(140deg, #f59e0b, #ef4444); }
+.ovh-face.is-glyph:has(.is-working) { animation: ovh-breathe 2.4s ease-in-out infinite; }
+@keyframes ovh-breathe { 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); } }
+html[data-ov-motion=reduced] .ovh-face.is-glyph:has(.is-working) { animation-duration: 5s; }
+html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none; }
+.ovh-actor .ovh-go.is-ic { flex: none; display: grid; place-items: center; width: 38px; height: 38px; padding: 0; border-radius: 50%; color: var(--accent-light, var(--accent, #60a5fa));
+ background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); transition: transform .2s cubic-bezier(.3, 1.4, .4, 1), background-color .15s; }
+.ovh-actor .ovh-go.is-ic svg, .ovh-actor .ovh-go.is-answer svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
+.ovh-actor .ovh-go.is-answer { flex: none; display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 14px 0 11px; border-radius: 999px; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); font-weight: 750; font-size: 13.5px; }
+@media (hover: hover) and (pointer: fine) { .ovh-actor:hover .ovh-go.is-ic { transform: translate(2px, -2px); background: color-mix(in srgb, var(--accent, #3b82f6) 24%, transparent); } }
`;
`;
document.head.appendChild(st);
document.head.appendChild(st);
}
}
@@ -337,6 +542,8 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -337,6 +542,8 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
const ICON = {
const ICON = {
pencil: [['path', { d: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16z' }], ['path', { d: 'M13.5 6.5l4 4' }]],
pencil: [['path', { d: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16z' }], ['path', { d: 'M13.5 6.5l4 4' }]],
check: [['path', { d: 'M5 12.5l4.5 4.5L19 7.5' }]],
check: [['path', { d: 'M5 12.5l4.5 4.5L19 7.5' }]],
+ bell: [['path', { d: 'M6 14.6V9.5a6.1 6.1 0 0 1 12.2 0v5.1l1.5 2.1h-15.3Z' }], ['path', { d: 'M10 18.7a2.1 2.1 0 0 0 4 0' }]],
+ history: [['path', { d: 'M3.8 12.1a8.3 8.3 0 1 0 2.5-5.9L3.8 8.5M3.8 4.2v4.2h4.2' }], ['path', { d: 'M12 7.9v4.3l3.1 1.9' }]],
attach: [['path', { d: 'M12 5v14M5 12h14' }]],
attach: [['path', { d: 'M12 5v14M5 12h14' }]],
plus: [['circle', { cx: 9.5, cy: 8.5, r: 3.2 }], ['path', { d: 'M3.5 18.5c1-2.900 3.300-4.400 6-4.400 1.300 0 2.500.300 3.500 1' }], ['path', { d: 'M18 13.500v6M15 16.500h6' }]],
plus: [['circle', { cx: 9.5, cy: 8.5, r: 3.2 }], ['path', { d: 'M3.5 18.5c1-2.900 3.300-4.400 6-4.400 1.300 0 2.500.300 3.500 1' }], ['path', { d: 'M18 13.500v6M15 16.500h6' }]],
out: [['path', { d: 'M13.500 4.500h4a1.500 1.500 0 0 1 1.500 1.500v12a1.500 1.500 0 0 1-1.500 1.500h-4' }], ['path', { d: 'M9.500 8l-4 4 4 4M5.500 12h9.500' }]],
out: [['path', { d: 'M13.500 4.500h4a1.500 1.500 0 0 1 1.500 1.500v12a1.500 1.500 0 0 1-1.500 1.500h-4' }], ['path', { d: 'M9.500 8l-4 4 4 4M5.500 12h9.500' }]],
@@ -355,11 +562,30 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -355,11 +562,30 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
chat: [['path', { d: 'M5 5h11a2.5 2.5 0 0 1 2.5 2.5v6A2.5 2.5 0 0 1 16 16H10l-4.5 3.5V16H5a2.5 2.5 0 0 1-2.5-2.5v-6A2.5 2.5 0 0 1 5 5Z' }], ['path', { d: 'M7 9.5h7M7 12.5h4' }]],
chat: [['path', { d: 'M5 5h11a2.5 2.5 0 0 1 2.5 2.5v6A2.5 2.5 0 0 1 16 16H10l-4.5 3.5V16H5a2.5 2.5 0 0 1-2.5-2.5v-6A2.5 2.5 0 0 1 5 5Z' }], ['path', { d: 'M7 9.5h7M7 12.5h4' }]],
page: [['path', { d: 'M6.5 3h7.5l4 4v14h-11.5Z' }], ['path', { d: 'M14 3v4h4M9.5 12h5M9.5 15.5h5' }]],
page: [['path', { d: 'M6.5 3h7.5l4 4v14h-11.5Z' }], ['path', { d: 'M14 3v4h4M9.5 12h5M9.5 15.5h5' }]],
play: [['path', { d: 'M8 5.5v13l10.5-6.5Z' }]],
play: [['path', { d: 'M8 5.5v13l10.5-6.5Z' }]],
+ // 4.223.0: drawings from the glyph registry (openvibe-shared/glyphs.js).
+ arrow: [['path', { d: 'M7 17L17 7M9 7h8v8' }]],
+ clock: [['circle', { cx: 12, cy: 12, r: 8.5 }], ['path', { d: 'M12 7.5V12l3 2' }]],
+ back: [['path', { d: 'M20.8 12H4M10.3 5.7l-6.3 6.3l6.3 6.3' }]],
+ game: [['rect', { x: 3.7, y: 7.2, width: 16.7, height: 9.7, rx: 4.4 }], ['path', { d: 'M7.6 10.7v2.7M6.4 12h2.7M15.1 11.6h0M17.4 12.9h0' }]],
+ video: [['rect', { x: 3.4, y: 6.4, width: 12.3, height: 11.3, rx: 1.9 }], ['path', { d: 'M15.6 10.2l4.7-2.8v9.4l-4.7-2.8' }]],
+ book: [['path', { d: 'M12 6.3C10 4.8 7 4.3 3.5 4.9v13.2c3.5-0.6 6.5-0.1 8.5 1.4c2-1.5 5-2 8.5-1.4V4.9C17 4.3 14 4.8 12 6.3Z' }], ['path', { d: 'M12 6.3v13.2' }]],
+ store: [['path', { d: 'M3.7 8.8L5.3 3.7h13.4L20.3 8.8M3.7 8.8h16.5v1.6a2.8 2.8 0 0 1-5.4 0a2.8 2.8 0 0 1-5.6 0A2.8 2.8 0 0 1 3.7 10.4ZM4.8 12.9v7.2h14.5v-7.2M9.9 20.1v-4.1h4.1v4.1' }]],
+ wrench: [['path', { d: 'M14.6 3.6a5.2 5.2 0 0 0-4.6 7.3L3.7 17.3V20.3h3.1l6.4-6.4a5.2 5.2 0 0 0 7.3-4.6l-3.2 1.2l-2.5-2.5l1.2-3.2Z' }]],
+ code: [['path', { d: 'M7.8 7.8l-4.2 4.2l4.2 4.2M16.2 7.8l4.2 4.2l-4.2 4.2M14.2 4.6l-4.2 14.8' }]],
+ spark: [['path', { d: 'M10.4 3.6l1.7 4.8L17 10.1l-4.8 1.7L10.4 16.7l-1.7-4.8L3.9 10.1l4.8-1.7ZM17 14.8l0.8 2.1L19.8 17.6l-2.1 0.8L17 20.3l-0.8-2.1L14.3 17.6l2.1-0.8Z' }]],
+ user: [['circle', { cx: 12, cy: 7.4, r: 3.8 }], ['path', { d: 'M4.3 20.5c0-3.9 3.4-6.6 7.7-6.6s7.7 2.6 7.7 6.6' }]],
+ star: [['path', { d: 'M12 4l2.6 5.3l5.7 0.6l-4.3 3.9l1.2 5.7L12 16.8l-5.2 2.9l1.2-5.7l-4.3-3.9l5.7-0.6Z' }]],
+ toolbox: [['rect', { x: 3.5, y: 8.1, width: 17, height: 11.3, rx: 1.9 }], ['path', { d: 'M9.2 8.1V5.8A1.4 1.4 0 0 1 10.6 4.3h2.8A1.4 1.4 0 0 1 14.8 5.8V8.1M3.5 12.8h17M8.2 11.4v2.8M15.8 11.4v2.8' }]],
+ compass: [['circle', { cx: 12, cy: 12, r: 8.5 }], ['path', { d: 'm15.6 8.4-2.1 5.1-5.1 2.1 2.1-5.1Z' }]],
+ home: [['path', { d: 'M3.6 10.6L12 3.3l8.4 7.3v8.4a1 1 0 0 1-1 1h-4.2v-6.3h-6.3v6.3H4.7a1 1 0 0 1-1-1Z' }]],
+ live: [['circle', { cx: 12, cy: 11.8, r: 2 }], ['path', { d: 'M8.6 8.4a4.9 4.9 0 0 0 0 7.1M15.4 8.4a4.9 4.9 0 0 1 0 7.1M6.3 6.1a8.1 8.1 0 0 0 0 11.9M17.7 6.1a8.1 8.1 0 0 1 0 11.9' }]],
+ shield: [['path', { d: 'M12 3.7L5.5 6.6v5.6c0 4.1 2.8 7.1 6.5 8.4c3.7-1.3 6.5-4.2 6.5-8.4V6.6Z' }]],
};
};
function svgIcon(name) {
function svgIcon(name) {
const NS = 'http://www.w3.org/2000/svg';
const NS = 'http://www.w3.org/2000/svg';
const s = document.createElementNS(NS, 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true');
const s = document.createElementNS(NS, 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true');
- ICON[name].forEach(([tag, at]) => { const n = document.createElementNS(NS, tag); Object.keys(at).forEach((k) => n.setAttribute(k, String(at[k]))); s.append(n); });
+ const def = ICON[name] || ICON.grid;
+ def.forEach(([tag, at]) => { const n = document.createElementNS(NS, tag); Object.keys(at).forEach((k) => n.setAttribute(k, String(at[k]))); s.append(n); });
return s;
return s;
}
}
@@ -368,6 +594,14 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -368,6 +594,14 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
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 section's glyph from its name, so every group of apps is marked (the owner, 11-12: "each section should have an icon"). */
+ function groupGlyph(name) {
+ const n = String(name || '').toLowerCase();
+ return /your|yours/.test(n) ? 'user' : /watch|stream|video|live/.test(n) ? 'video' : /talk|belong|chat|social|community/.test(n) ? 'chat'
+ : /play|game|fun/.test(n) ? 'game' : /read|ask|news|learn/.test(n) ? 'book' : /shop|money|buy|market/.test(n) ? 'store'
+ : /quick/.test(n) ? 'toolbox' : /tool/.test(n) ? 'wrench' : /\bai\b|make/.test(n) ? 'spark' : /develop|build|code/.test(n) ? 'code'
+ : /home|popular/.test(n) ? 'home' : /used|most/.test(n) ? 'star' : /recent|where|history/.test(n) ? 'history' : 'compass';
+ }
/** A steady hue per site (the same on every page and device), so each app has its own colour. */
/** 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 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 } = {}) {
@@ -404,11 +638,13 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -404,11 +638,13 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
styles();
styles();
el.classList.add('ovh');
el.classList.add('ovh');
el.setAttribute('data-sec', 'home');
el.setAttribute('data-sec', 'home');
- const state = { view: 'home', edit: false, folder: null, filter: '', drag: null, suppress: 0 };
+ // 4.223.0: You is three tabs (Apps first); the one a person chose stays for this tab of the browser.
+ let keptTab = null; try { keptTab = sessionStorage.getItem('ovh-you-tab'); } catch { /* */ }
+ const state = { view: 'home', edit: false, folder: null, filter: '', drag: null, suppress: 0, tab: o.startTab || (['apps', 'notifications', 'recent'].includes(keptTab) ? keptTab : 'apps') };
let L = layoutOf(o);
let L = layoutOf(o);
const save = () => { const keep = { home: L.home.slice(0, HOME_MAX), folders: L.folders.map((f) => ({ id: f.id, name: f.name, apps: f.apps.slice(0, 400) })) }; writeLocal(keep); pushRemote(o, keep); L.arranged = true; };
const save = () => { const keep = { home: L.home.slice(0, HOME_MAX), folders: L.folders.map((f) => ({ id: f.id, name: f.name, apps: f.apps.slice(0, 400) })) }; writeLocal(keep); pushRemote(o, keep); L.arranged = true; };
const icons = () => { if (typeof o.mountIcons === 'function') { try { o.mountIcons(el); } catch { /* optional */ } } };
const icons = () => { if (typeof o.mountIcons === 'function') { try { o.mountIcons(el); } catch { /* optional */ } } };
- let actorBox = null, meBox = null;
+ let actorBox = null, meBox = null, notesBox = null;
// The Look panel (theme, text size, motion, icons) opens under your name, drawn by the navbar (the account menu's).
// The Look panel (theme, text size, motion, icons) opens under your name, drawn by the navbar (the account menu's).
if (typeof o.look === 'function') { try { o.look(el); } catch { /* the button stays a link to openvibe.design/look */ } }
if (typeof o.look === 'function') { try { o.look(el); } catch { /* the button stays a link to openvibe.design/look */ } }
@@ -434,26 +670,135 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -434,26 +670,135 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
const gear = h('a', 'ovh-ib'); gear.append(svgIcon('gear')); gear.href = 'https://openvibe.network/my'; gear.setAttribute('aria-label', 'Account and settings'); gear.title = 'Account and settings';
const gear = h('a', 'ovh-ib'); gear.append(svgIcon('gear')); gear.href = 'https://openvibe.network/my'; gear.setAttribute('aria-label', 'Account and settings'); gear.title = 'Account and settings';
bar.append(who);
bar.append(who);
const m = o.me || {};
const m = o.me || {};
- if (m.add && safe(m.add)) { const add = h('a', 'ovh-ib'); add.append(svgIcon('plus')); add.href = m.add; add.setAttribute('aria-label', 'Add or switch account'); add.setAttribute('data-ov-tip', 'Add or switch account'); bar.append(add); }
- bar.append(look, gear);
- if (typeof m.out === 'function') { const out = h('button', 'ovh-ib ovh-out'); out.type = 'button'; out.append(svgIcon('out')); out.setAttribute('aria-label', 'Sign out'); out.setAttribute('data-ov-tip', 'Sign out'); out.addEventListener('click', () => { try { m.out(); } catch { /* the bar signs out */ } }); bar.append(out); }
+ const acts = h('div', 'ovh-acts'); acts.setAttribute('role', 'group'); acts.setAttribute('aria-label', 'Yours');
+ // This site's own items (your channel, Admin, what a site adds) as round buttons beside Display and Settings:
+ // a known one with its glyph, a site's own with its short name (4.223.0; it was a row of its own).
+ const OWN_GLYPH = { 'ov-channel': 'live', 'ov-admin': 'shield' };
+ const own = (Array.isArray(m.own) ? m.own : []).filter((it) => it && it.label && (typeof it.onClick === 'function' || (it.href && safe(it.href)))).slice(0, 6);
+ own.forEach((it) => {
+ const g = OWN_GLYPH[it.id] || (ICON[it.glyph] ? it.glyph : '');
+ const b = it.href ? h('a', `ovh-ib is-own${g ? '' : ' is-text'}`) : h('button', `ovh-ib is-own${g ? '' : ' is-text'}`);
+ if (it.href) b.href = it.href; else { b.type = 'button'; b.addEventListener('click', () => { try { it.onClick(); } catch { /* the item is the site's */ } }); }
+ if (g) b.append(svgIcon(g)); else b.textContent = it.short || it.label;
+ b.setAttribute('aria-label', it.label); b.setAttribute('data-ov-tip', it.label);
+ acts.append(b);
+ });
+ if (m.add && safe(m.add)) { const add = h('a', 'ovh-ib'); add.append(svgIcon('plus')); add.href = m.add; add.setAttribute('aria-label', 'Add or switch account'); add.setAttribute('data-ov-tip', 'Add or switch account'); acts.append(add); }
+ acts.append(look, gear);
+ if (typeof m.out === 'function') { const out = h('button', 'ovh-ib ovh-out'); out.type = 'button'; out.append(svgIcon('out')); out.setAttribute('aria-label', 'Sign out'); out.setAttribute('data-ov-tip', 'Sign out'); out.addEventListener('click', () => { try { m.out(); } catch { /* the bar's own */ } }); acts.append(out); }
+ bar.append(acts);
const panel = h('div', 'ud-look ovh-me-look'); panel.hidden = true;
const panel = h('div', 'ud-look ovh-me-look'); panel.hidden = true;
meBox.append(bar, panel);
meBox.append(bar, panel);
- // This site's own items, your status, Admin: small, in one line that wraps; nothing that the menu has elsewhere.
- const own = (Array.isArray(m.own) ? m.own : []).filter((it) => it && it.label && (typeof it.onClick === 'function' || (it.href && safe(it.href)))).slice(0, 8);
- if (own.length) {
- const row = h('div', 'ovh-own'); row.setAttribute('role', 'group'); row.setAttribute('aria-label', 'Yours on this site');
- own.forEach((it) => {
- const el2 = it.href ? h('a', 'ovh-own-i') : h('button', 'ovh-own-i');
- if (it.href) el2.href = it.href; else { el2.type = 'button'; el2.addEventListener('click', () => { try { it.onClick(); } catch { /* the item is the site's */ } }); }
- el2.textContent = it.short || it.label;
- row.append(el2);
- });
- meBox.append(row);
- }
return meBox;
return meBox;
}
}
+ /** The three tabs under your row; Notifications carries how many wait. */
+ let countBound = false, inkWas = null, tabWas = null;
+ function youTabs() {
+ const bar = h('div', 'ovh-tabs'); bar.setAttribute('role', 'tablist'); bar.setAttribute('aria-label', 'You');
+ if (!countBound && typeof document !== 'undefined') {
+ countBound = true;
+ const onCount = () => {
+ if (!el.isConnected) { document.removeEventListener('openvibe-notifications-count', onCount); countBound = false; return; }
+ const t = el.querySelector('.ovh-tab[data-tab="notifications"]'); if (!t) return;
+ let n = 0; try { n = typeof o.unread === 'function' ? Number(o.unread()) || 0 : 0; } catch { n = 0; }
+ let b = t.querySelector('.ovh-tab-n');
+ if (n > 0) { if (!b) { b = h('b', 'ovh-tab-n'); t.append(b); } b.textContent = n > 99 ? '99+' : String(n); t.setAttribute('aria-label', `Notifications, ${n} new`); }
+ else if (b) { b.remove(); t.setAttribute('aria-label', 'Notifications'); }
+ };
+ document.addEventListener('openvibe-notifications-count', onCount);
+ }
+ let unread = 0; try { unread = typeof o.unread === 'function' ? Number(o.unread()) || 0 : 0; } catch { unread = 0; }
+ [['apps', 'Apps', 'grid'], ['notifications', 'Notifications', 'bell'], ['recent', 'Recent', 'history']].forEach(([id, label, ic]) => {
+ const b = h('button', 'ovh-tab'); b.type = 'button'; b.setAttribute('role', 'tab'); b.dataset.tab = id;
+ b.setAttribute('aria-selected', String(state.tab === id));
+ b.append(svgIcon(ic), h('span', 'ovh-tab-t', label)); b.setAttribute('aria-label', label); b.setAttribute('data-ov-tip', label);
+ if (id === 'notifications' && unread > 0) { b.append(h('b', 'ovh-tab-n', unread > 99 ? '99+' : String(unread))); b.setAttribute('aria-label', `Notifications, ${unread} new`); }
+ b.addEventListener('click', () => {
+ if (state.tab === id) return;
+ const on = bar.querySelector('.ovh-tab[aria-selected="true"]');
+ inkWas = on ? { x: on.offsetLeft, w: on.offsetWidth } : null;
+ tabWas = state.tab;
+ state.tab = id; try { sessionStorage.setItem('ovh-you-tab', id); } catch { /* */ } paint();
+ });
+ // Arrow keys move along the tabs (a tablist's own keys).
+ b.addEventListener('keydown', (e) => {
+ if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
+ const all = [...bar.querySelectorAll('.ovh-tab')]; const k = all.indexOf(b);
+ const next = all[(k + (e.key === 'ArrowRight' ? 1 : all.length - 1)) % all.length];
+ e.preventDefault(); next.click(); requestAnimationFrame(() => { const t = el.querySelector(`.ovh-tab[data-tab="${next.dataset.tab}"]`); if (t) t.focus(); });
+ });
+ b.tabIndex = state.tab === id ? 0 : -1;
+ bar.append(b);
+ });
+ // The ink under the chosen tab: it starts where the last one was and slides over (Calm: a short fade; Off: none).
+ const ink = h('i', 'ovh-tab-ink'); ink.setAttribute('aria-hidden', 'true'); bar.prepend(ink);
+ const order = ['apps', 'notifications', 'recent'];
+ const from = inkWas, dir = tabWas ? Math.sign(order.indexOf(state.tab) - order.indexOf(tabWas)) : 0;
+ inkWas = null; tabWas = null;
+ requestAnimationFrame(() => {
+ const on = bar.querySelector('.ovh-tab[aria-selected="true"]'); if (!on) return;
+ if (from) { ink.style.transition = 'none'; ink.style.transform = `translateX(${from.x}px)`; ink.style.width = `${from.w}px`; void ink.offsetWidth; ink.style.transition = ''; }
+ ink.style.transform = `translateX(${on.offsetLeft}px)`; ink.style.width = `${on.offsetWidth}px`; ink.classList.add('is-on');
+ });
+ if (dir) el.dataset.enter = dir > 0 ? 'right' : 'left'; else delete el.dataset.enter;
+ return bar;
+ }
+ /** The whole notifications list, made once per opening (the menu gives the mount): a repaint keeps its place. */
+ function notes() {
+ if (notesBox) return notesBox;
+ notesBox = h('div', 'ovh-notes');
+ if (typeof o.notifications === 'function') { try { o.notifications(notesBox); } catch { notesBox.append(h('p', 'ovh-hint', 'Notifications did not load. Close the menu and open it again.')); } }
+ else notesBox.append(h('p', 'ovh-hint', 'Notifications are on openvibe.network/my.'));
+ return notesBox;
+ }
+
+ /** Recent (4.223.0; the owner, 11-12: "jump back in … shows a little more … like in the feed, longer"; "view full
+ * history doesn't even work"): every place you were, newest first, in days, each a card with its whole title. */
+ function history() {
+ const items = (o.back || []).filter((x) => x && safe(x.url));
+ if (!items.length) return null;
+ const wrap = h('div', 'ovh-hist');
+ const dayOf = (at) => {
+ const t = Date.parse(at || ''); if (!t) return 'Earlier';
+ const d0 = new Date(); d0.setHours(0, 0, 0, 0);
+ const d = new Date(t); d.setHours(0, 0, 0, 0);
+ const diff = Math.round((d0 - d) / 864e5);
+ return diff <= 0 ? 'Today' : diff === 1 ? 'Yesterday' : diff < 7 ? 'This week' : 'Earlier';
+ };
+ const list = h('div', 'ovh-hist-list'); list.setAttribute('role', 'list'); list.setAttribute('aria-label', 'Where you were');
+ const more = h('button', 'ovh-all ovh-hist-more', 'Show more'); more.type = 'button';
+ let shown = 12;
+ const draw = () => {
+ list.textContent = '';
+ let last = '';
+ items.slice(0, shown).forEach((x, i) => {
+ const d = dayOf(x.at);
+ if (d !== last) { last = d; list.append(h('div', 'ovh-hist-day', d)); }
+ const live = x.tag === 'Live';
+ const c = h('a', `ovh-hrow${live ? ' is-live' : ''}`); c.href = safe(x.url); c.setAttribute('role', 'listitem'); c.style.setProperty('--i', String(i % 12));
+ const ic = h('span', 'ovh-ic'); ic.style.setProperty('--ovh-h', String(hueOf(x.site || 'ov'))); ic.append(siteIcon(x.site || 'ov', 44));
+ const t = h('span', 'ovh-htxt');
+ t.append(h('b', '', x.title));
+ const meta = h('small');
+ if (live) meta.append(h('span', 'ovh-live', 'LIVE'));
+ const pre = String(x.metaPre || '').replace(/^Live now(?: · )?/, '');
+ const when = h('span', '', [pre, ago(x.at)].filter(Boolean).join(' · ') || x.meta || '');
+ if (x.at) { when.dataset.at = x.at; when.dataset.pre = pre; } // search.js keeps the times current
+ meta.append(when); t.append(meta);
+ c.append(ic, t);
+ list.append(c);
+ });
+ more.hidden = shown >= items.length;
+ };
+ more.addEventListener('click', () => { shown += 12; draw(); icons(); });
+ draw();
+ const head = sec('Where you were', 'history');
+ const full = h('a', 'ovh-link', 'Full history'); full.href = 'https://openvibe.my/history'; head.append(full);
+ wrap.append(head, list, more);
+ return wrap;
+ }
+
/** Jump back in: the last places, as cards you swipe; a stream that is live now says so. */
/** Jump back in: the last places, as cards you swipe; a stream that is live now says so. */
function backRail() {
function backRail() {
const items = (o.back || []).filter((x) => x && safe(x.url)).slice(0, 8);
const items = (o.back || []).filter((x) => x && safe(x.url)).slice(0, 8);
@@ -490,7 +835,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -490,7 +835,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
const rows = (o.usage || []).map((u) => ({ a: L.byId.get(u.id), n: u.count })).filter((r) => r.a && r.n > 0).sort((x, y) => y.n - x.n).slice(0, 5);
const rows = (o.usage || []).map((u) => ({ a: L.byId.get(u.id), n: u.count })).filter((r) => r.a && r.n > 0).sort((x, y) => y.n - x.n).slice(0, 5);
if (rows.length < 2) return null;
if (rows.length < 2) return null;
const wrap = h('div');
const wrap = h('div');
- const head = sec('Most used');
+ const head = sec('Most used', 'star');
// Your stats on Report (private by default; you choose what is public), as the section's link.
// Your stats on Report (private by default; you choose what is public), as the section's link.
const more = h('a', 'ovh-link', 'Your stats'); more.href = 'https://openvibe.report/me';
const more = h('a', 'ovh-link', 'Your stats'); more.href = 'https://openvibe.report/me';
head.append(more);
head.append(more);
@@ -524,33 +869,41 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -524,33 +869,41 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
const calm = (x) => { x.classList.add('ovh-calm'); x.inert = state.edit; return x; };
const calm = (x) => { x.classList.add('ovh-calm'); x.inert = state.edit; return x; };
// The account's block wherever the bar has a person (a site with its own session may have no token for
// The account's block wherever the bar has a person (a site with its own session may have no token for
// the network's reads yet: sign out and the rest must still be there); else the short bar.
// the network's reads yet: sign out and the rest must still be there); else the short bar.
- if ((o.user && !o.user.is_anon) || signedIn(o)) el.append(calm(me()));
- if (signedIn(o)) { actorBox = actorBox || h('div'); el.append(actorBox); }
- const head = sec(L.arranged || signedIn(o) ? 'Your home' : 'Popular on OpenVibe');
- const edit = h('button', state.edit ? 'ovh-edit is-done' : 'ovh-edit');
- edit.type = 'button';
- edit.append(svgIcon(state.edit ? 'check' : 'pencil'));
- if (state.edit) edit.append(h('span', '', 'Done'));
- edit.setAttribute('aria-label', state.edit ? 'Done arranging Home' : 'Edit Home');
- edit.title = state.edit ? 'Done' : 'Edit Home: move apps, remove them, add folders';
- edit.setAttribute('aria-pressed', state.edit ? 'true' : 'false');
- edit.addEventListener('click', () => setEdit(!state.edit));
- // All apps sits beside Edit, small, with its count (the owner, 2026-11-10: "all apps should just be in a
- // button in the corner so it's more compact"): it was a bar the width of the sheet under the grid.
- const total = L.byId.size;
- const all = h('button', 'ovh-allbtn'); all.type = 'button';
- all.append(svgIcon('grid'), h('span', '', 'All'), h('b', '', String(total)));
- all.setAttribute('aria-label', `All apps (${total})`); all.title = `All apps (${total})`;
- all.addEventListener('click', () => { state.view = 'drawer'; paint(); });
- const tools = h('div', 'ovh-headtools'); tools.append(all, edit);
- head.append(tools);
- el.append(head);
+ const top = h('div', 'ovh-top');
+ if ((o.user && !o.user.is_anon) || signedIn(o)) top.append(calm(me()));
+ if (signedIn(o)) { actorBox = actorBox || h('div', 'ovh-notice'); top.append(actorBox); }
+ if (top.childNodes.length) el.append(top);
+ // 4.223.0 (the owner, 11-12: "at the top … the goosely Online bar. Under that is tabs … put the apps more front
+ // facing … the first tab"; then "compact … waste of space"): one row of tabs, and on Apps the All and Edit
+ // buttons at its end, so no head of its own. Edit stays pressable while the rest is quiet.
+ const tab = signedIn(o) ? state.tab : 'apps';
+ const tools = tab === 'apps' ? homeTools() : null;
+ if (signedIn(o)) {
+ const row = h('div', 'ovh-tabrow');
+ row.append(calm(youTabs()));
+ if (tools) row.append(tools);
+ el.append(row);
+ }
+ const later = () => { icons(); if (actorBox && !actorBox.childNodes.length) actor(actorBox, o); };
+ if (tab === 'notifications') { el.append(notes()); return later(); }
+ if (tab === 'recent') {
+ const u = usage(), b = history();
+ if (u) el.append(u);
+ if (b) el.append(b);
+ if (!u && !b) { const e = h('div', 'ovh-empty'); e.append(svgIcon('history')); const w = h('div'); w.append(h('b', '', 'Nothing here yet'), h('span', '', 'The places you open and the apps you use most show up here.')); e.append(w); el.append(e); }
+ return later();
+ }
+ if (!signedIn(o)) {
+ const head = sec(L.arranged ? 'Your home' : 'Popular on OpenVibe', 'home');
+ head.append(tools);
+ el.append(head);
+ }
const grid = h('div', 'ovh-grid'); grid.setAttribute('aria-label', 'Your home');
const grid = h('div', 'ovh-grid'); grid.setAttribute('aria-label', 'Your home');
L.home.forEach((id, i) => grid.append(homeTile(id, i)));
L.home.forEach((id, i) => grid.append(homeTile(id, i)));
el.append(grid);
el.append(grid);
if (state.edit) el.append(h('p', 'ovh-hint', 'Drag an app to move it, or use the arrow keys. − takes it off Home; All puts it back.'));
if (state.edit) el.append(h('p', 'ovh-hint', 'Drag an app to move it, or use the arrow keys. − takes it off Home; All puts it back.'));
- // Under your home: what you use most (shortcuts), then where you were last (history).
- { const u = usage(); if (u) el.append(calm(u)); const b = backRail(); if (b) el.append(calm(b)); }
+ // Signed out there are no tabs: what you use most and where you were stay under the apps.
+ if (!signedIn(o)) { const u = usage(); if (u) el.append(calm(u)); const b = backRail(); if (b) el.append(calm(b)); }
icons();
icons();
if (signedIn(o) && actorBox && !actorBox.childNodes.length) actor(actorBox, o);
if (signedIn(o) && actorBox && !actorBox.childNodes.length) actor(actorBox, o);
}
}
@@ -677,7 +1030,26 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -677,7 +1030,26 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
window.addEventListener('pointercancel', end);
window.addEventListener('pointercancel', end);
}
}
- function setEdit(on) { state.edit = on; paint(); const b = el.querySelector('.ovh-sec button'); if (b && !on) b.focus(); }
+ function homeTools() {
+ const edit = h('button', state.edit ? 'ovh-edit is-done' : 'ovh-edit');
+ edit.type = 'button';
+ edit.append(svgIcon(state.edit ? 'check' : 'pencil'));
+ if (state.edit) edit.append(h('span', '', 'Done'));
+ edit.setAttribute('aria-label', state.edit ? 'Done arranging Home' : 'Edit Home');
+ edit.title = state.edit ? 'Done' : 'Edit Home: move apps, remove them, add folders';
+ edit.setAttribute('aria-pressed', state.edit ? 'true' : 'false');
+ edit.addEventListener('click', () => setEdit(!state.edit));
+ // All apps sits beside Edit, small, with its count (the owner, 2026-11-10: "all apps should just be in a
+ // button in the corner so it's more compact").
+ const total = L.byId.size;
+ const all = h('button', 'ovh-allbtn'); all.type = 'button';
+ all.append(svgIcon('grid'), h('span', '', 'All'), h('b', '', String(total)));
+ all.setAttribute('aria-label', `All apps (${total})`); all.title = `All apps (${total})`;
+ all.addEventListener('click', () => { state.view = 'drawer'; paint(); });
+ const tools = h('div', 'ovh-headtools'); tools.append(all, edit);
+ return tools;
+ }
+ function setEdit(on) { state.edit = on; paint(); const b = el.querySelector('.ovh-edit'); if (b && !on) b.focus(); }
function paintFolder() {
function paintFolder() {
const f = L.folders.find((x) => x.id === state.folder);
const f = L.folders.find((x) => x.id === state.folder);
@@ -695,9 +1067,10 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -695,9 +1067,10 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
function paintDrawer() {
function paintDrawer() {
el.textContent = '';
el.textContent = '';
- el.append(backHead('All apps'));
- const field = h('input', 'ovh-filter'); field.type = 'search'; field.placeholder = 'Find an app'; field.setAttribute('aria-label', 'Find an app'); field.value = state.filter;
- el.append(field);
+ const head = backHead('All apps');
+ const field = h('input', 'ovh-filter'); field.type = 'search'; field.placeholder = `Find among ${L.byId.size} apps`; field.setAttribute('aria-label', 'Find an app'); field.value = state.filter;
+ head.querySelector('b').replaceWith(field);
+ el.append(head);
const body = h('div');
const body = h('div');
el.append(body);
el.append(body);
const groups = (o.groups && o.groups.length ? o.groups : [{ id: '', name: 'OpenVibe' }]).slice();
const groups = (o.groups && o.groups.length ? o.groups : [{ id: '', name: 'OpenVibe' }]).slice();
@@ -718,7 +1091,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -718,7 +1091,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
L.folders.forEach((f) => { if (!q || f.name.toLowerCase().includes(q)) sets.push([`${f.name} folder`, [{ folder: f }]]); });
L.folders.forEach((f) => { if (!q || f.name.toLowerCase().includes(q)) sets.push([`${f.name} folder`, [{ folder: f }]]); });
if (!sets.length) { body.append(h('p', 'ovh-note', 'No app has that name.')); return; }
if (!sets.length) { body.append(h('p', 'ovh-note', 'No app has that name.')); return; }
sets.forEach(([name, list]) => {
sets.forEach(([name, list]) => {
- body.append(sec(`${name} (${list.length})`));
+ body.append(sec(name, groupGlyph(name), list.length));
const grid = h('div', 'ovh-grid');
const grid = h('div', 'ovh-grid');
list.forEach((a) => {
list.forEach((a) => {
const id = a.folder ? `folder:${a.folder.id}` : a.id;
const id = a.folder ? `folder:${a.folder.id}` : a.id;
@@ -742,12 +1115,20 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -742,12 +1115,20 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
function backHead(title) {
function backHead(title) {
const head = h('div', 'ovh-head');
const head = h('div', 'ovh-head');
- const back = h('button', 'ovh-back', '← Home'); back.type = 'button';
+ const back = h('button', 'ovh-back'); back.type = 'button'; back.append(svgIcon('back'));
+ back.setAttribute('aria-label', 'Back to your home'); back.setAttribute('data-ov-tip', 'Back to your home');
back.addEventListener('click', () => { state.view = 'home'; state.folder = null; paint(); const b = el.querySelector('.ovh-allbtn'); if (b) b.focus(); });
back.addEventListener('click', () => { state.view = 'home'; state.folder = null; paint(); const b = el.querySelector('.ovh-allbtn'); if (b) b.focus(); });
head.append(back, h('b', '', title));
head.append(back, h('b', '', title));
return head;
return head;
}
}
- function sec(text) { const s = h('div', 'ovh-sec'); s.append(h('span', '', text)); return s; }
+ function sec(text, glyph, count) {
+ const s = h('div', 'ovh-sec');
+ if (glyph) { const i = h('i', 'ovh-sec-ic'); i.append(svgIcon(glyph)); s.append(i); }
+ s.append(h('span', '', text));
+ if (count != null) s.append(h('b', 'ovh-sec-n', String(count)));
+ s.append(h('i', 'ovh-sec-sp'));
+ return s;
+ }
function paint() { if (state.view === 'folder') return paintFolder(); if (state.view === 'drawer') return paintDrawer(); return paintHome(); }
function paint() { if (state.view === 'folder') return paintFolder(); if (state.view === 'drawer') return paintDrawer(); return paintHome(); }
paint();
paint();
@@ -769,6 +1150,23 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -769,6 +1150,23 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
}
}
// ── Your Actor, on top of You ──────────────────────────────────────────────────────────────────────────────
// ── Your Actor, on top of You ──────────────────────────────────────────────────────────────────────────────
+ /** The glyph for what an agent is doing now (the owner, 11-12: "put an icon depending on the agent status … custom for
+ * the agent"): its step's words first, then its state. A site or Actor may name one (`a.now.glyph`) from the registry. */
+ function doingGlyph(a, st) {
+ const named = a && a.now && a.now.glyph; if (named && ICON[named]) return named;
+ const t = String((a && a.now && a.now.current_step) || '').toLowerCase();
+ if (st === 'waiting') return 'chat';
+ if (st === 'paused') return 'clock';
+ if (/read|review|look|watch/.test(t)) return 'book';
+ if (/search|find|hunt|scan|brows/.test(t)) return 'compass';
+ if (/writ|draft|post|reply|edit/.test(t)) return 'pencil';
+ if (/code|build|fix|deploy|test/.test(t)) return 'code';
+ if (/stream|live|clip|video/.test(t)) return 'live';
+ if (/buy|deal|shop|price|sell/.test(t)) return 'store';
+ if (/game|play/.test(t)) return 'game';
+ if (/think|plan|idea/.test(t)) return 'spark';
+ return st === 'working' ? 'spark' : 'clock';
+ }
async function actor(box, o) {
async function actor(box, o) {
const base = String(o.actorBase || ACTOR).replace(/\/+$/, '');
const base = String(o.actorBase || ACTOR).replace(/\/+$/, '');
let a = null;
let a = null;
@@ -786,13 +1184,20 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -786,13 +1184,20 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
const face = h('span', 'ovh-face');
const face = h('span', 'ovh-face');
const pic = a.avatar && /^https:\/\//.test(String(a.avatar.url || '')) ? a.avatar.url : '';
const pic = a.avatar && /^https:\/\//.test(String(a.avatar.url || '')) ? a.avatar.url : '';
if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.append(img); } else face.textContent = String(a.name || 'A').trim().charAt(0).toUpperCase() || 'A';
if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.append(img); } else face.textContent = String(a.name || 'A').trim().charAt(0).toUpperCase() || 'A';
+ // What it is doing, as a glyph on its face (its whole face when it has no picture).
+ const doing = h('span', `ovh-doing is-${st}`); doing.append(svgIcon(doingGlyph(a, st))); doing.setAttribute('aria-hidden', 'true');
+ if (!pic) { face.textContent = ''; face.classList.add('is-glyph'); }
+ face.append(doing);
const txt = h('span', 'ovh-actor-txt');
const txt = h('span', 'ovh-actor-txt');
const name = h('b', '', a.name || 'Your Actor');
const name = h('b', '', a.name || 'Your Actor');
const chip = h('span', `ovh-chip is-${st}`); chip.append(h('span', 'ovh-dot'), document.createTextNode(label));
const chip = h('span', `ovh-chip is-${st}`); chip.append(h('span', 'ovh-dot'), document.createTextNode(label));
name.append(chip);
name.append(chip);
const now = a.now || {};
const now = a.now || {};
txt.append(name, h('small', '', asks.length ? `It asks you: ${asks[0].text || ''}` : now.current_step ? String(now.current_step) : st === 'paused' ? 'Paused until you resume it' : 'Resting until its next goal is due'));
txt.append(name, h('small', '', asks.length ? `It asks you: ${asks[0].text || ''}` : now.current_step ? String(now.current_step) : st === 'paused' ? 'Paused until you resume it' : 'Resting until its next goal is due'));
- b.append(face, txt, h('span', 'ovh-go', asks.length ? 'Answer' : 'Open'));
+ const go = h('span', asks.length ? 'ovh-go is-answer' : 'ovh-go is-ic'); go.append(svgIcon(asks.length ? 'chat' : 'arrow'));
+ if (asks.length) go.append(h('span', '', 'Answer'));
+ b.setAttribute('aria-label', `${a.name || 'Your Actor'}, ${label}: ${asks.length ? 'answer it' : 'open it'}`);
+ b.append(face, txt, go);
b.addEventListener('click', () => {
b.addEventListener('click', () => {
if (root.OpenVibeNavbar && typeof root.OpenVibeNavbar.openDock === 'function') { if (o.close) o.close(); try { root.OpenVibeNavbar.openDock('agent'); return; } catch { /* */ } }
if (root.OpenVibeNavbar && typeof root.OpenVibeNavbar.openDock === 'function') { if (o.close) o.close(); try { root.OpenVibeNavbar.openDock('agent'); return; } catch { /* */ } }
location.href = `${base}/you`;
location.href = `${base}/you`;
@@ -808,7 +1213,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -808,7 +1213,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
card.classList.add('is-invite');
card.classList.add('is-invite');
const face = h('span', 'ovh-face'); face.append(siteIcon('actor', 30));
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 and runs your channel.'));
+ txt.append(h('b', '', 'Your own AI'), h('small', '', 'It works for you while you watch.'));
const acts = h('span', 'ovh-acts');
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'); no.append(svgIcon('x')); no.type = 'button'; no.setAttribute('aria-label', 'Not now'); no.title = 'Not now';
const no = h('button', 'ovh-x'); no.append(svgIcon('x')); no.type = 'button'; no.setAttribute('aria-label', 'Not now'); no.title = 'Not now';
@@ -1135,8 +1540,18 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -1135,8 +1540,18 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
parts.forEach(([k, g, word]) => { const s = h('span', ''); s.setAttribute('aria-label', `${num(n[k])} ${word}`); s.title = `${num(n[k])} ${word}`; s.append(glyph(g), document.createTextNode(count(n[k]))); row.append(s); });
parts.forEach(([k, g, word]) => { const s = h('span', ''); s.setAttribute('aria-label', `${num(n[k])} ${word}`); s.title = `${num(n[k])} ${word}`; s.append(glyph(g), document.createTextNode(count(n[k]))); row.append(s); });
return row;
return row;
}
}
+ /** A length as people read it: 5203 → 1:26:43, 95 → 1:35; a length already written ("12:04") stays. */
+ function lenOf(v) {
+ const t = String(v == null ? '' : v).trim();
+ if (!/^\d+(\.\d+)?$/.test(t)) return t.slice(0, 10);
+ const n = Math.round(Number(t)); const hh = Math.floor(n / 3600), mm = Math.floor((n % 3600) / 60), ss = n % 60;
+ return hh ? `${hh}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}` : `${mm}:${String(ss).padStart(2, '0')}`;
+ }
+ /** The Rundown's episodes (Live writes them from what streamers said): Network's kind `rundown`, or the older items
+ * that came as "System" with a "Rundown:" title. */
+ function isRundown(c) { return c.kind === 'rundown' || (/^Rundown:\s*/i.test(String(c.title || '')) && (!c.actor || /^system$/i.test(String(c.actor.name || '')))); }
function feedCard(c) {
function feedCard(c) {
- const kind = String(c.kind || 'post');
+ const kind = isRundown(c) ? 'rundown' : String(c.kind || 'post');
const url = safe(c.url);
const url = safe(c.url);
if (!url) return null;
if (!url) return null;
if (ACTS.has(kind) && !(c.media && safe(c.media.thumb))) return activity(c);
if (ACTS.has(kind) && !(c.media && safe(c.media.thumb))) return activity(c);
@@ -1147,6 +1562,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -1147,6 +1562,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
a.append(ic, t); return a;
a.append(ic, t); return a;
}
}
const a = h('a', `ovfd-card ovfd-k-${kind.replace(/[^a-z]/g, '')}`); a.href = url;
const a = h('a', `ovfd-card ovfd-k-${kind.replace(/[^a-z]/g, '')}`); a.href = url;
+ if (kind === 'rundown') a.setAttribute('aria-label', `The Rundown: ${String(c.title || '').replace(/^Rundown:\s*/i, '').slice(0, 160)}`);
const head = h('span', 'ovfd-head');
const head = h('span', 'ovfd-head');
const face = h('span', 'ovfd-face');
const face = h('span', 'ovfd-face');
const av = c.actor && safe(c.actor.avatar);
const av = c.actor && safe(c.actor.avatar);
@@ -1155,9 +1571,19 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -1155,9 +1571,19 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
else if (!c.actor) { face.classList.add('is-site'); face.style.setProperty('--ovh-h', String(hueOf(c.site || 'ov'))); face.append(siteIcon(c.site || 'ov', 20)); }
else if (!c.actor) { face.classList.add('is-site'); face.style.setProperty('--ovh-h', String(hueOf(c.site || 'ov'))); face.append(siteIcon(c.site || 'ov', 20)); }
else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
const who = h('span', 'ovfd-who');
const who = h('span', 'ovfd-who');
- who.append(h('b', '', String((c.actor && c.actor.name) || c.label || c.site_name || '').slice(0, 60)));
const meta = h('small', '');
const meta = h('small', '');
- meta.append(siteIcon(c.site || 'ov', 14), document.createTextNode(` ${[c.site_name || '', c.actor ? when(c.at) : String(c.text || when(c.at) || '').slice(0, 60)].filter(Boolean).join(' · ')}`));
+ if (kind === 'rundown') {
+ // 4.223.0 (the owner, 11-12: "the rundown should look visually distinct versus the live feeds"): its own face and
+ // name, whom it is about, and its picture marked as the Rundown's.
+ face.textContent = ''; face.className = 'ovfd-face is-rundown'; face.append(glyph('page'));
+ const about = c.subject && c.subject.name ? String(c.subject.name) : (/^Rundown:\s*([^:]{1,40}?)\s*(?:prep|:)/i.exec(String(c.title || '')) || [])[1] || '';
+ who.append(h('b', '', 'The Rundown'));
+ meta.append(siteIcon('live', 18), document.createTextNode(` ${[about ? `about ${about}` : 'from today’s streams', when(c.at)].filter(Boolean).join(' · ')}`));
+ a.classList.add('ovfd-k-rundown');
+ } else {
+ who.append(h('b', '', String((c.actor && c.actor.name) || c.label || c.site_name || '').slice(0, 60)));
+ meta.append(siteIcon(c.site || 'ov', 18), document.createTextNode(` ${[c.site_name || '', c.actor ? when(c.at) : String(c.text || when(c.at) || '').slice(0, 60)].filter(Boolean).join(' · ')}`));
+ }
who.append(meta);
who.append(meta);
head.append(face, who);
head.append(face, who);
if (kind === 'live' && c.live_now) { a.classList.add('is-on'); head.append(h('span', 'ovfd-livechip', 'LIVE')); }
if (kind === 'live' && c.live_now) { a.classList.add('is-on'); head.append(h('span', 'ovfd-livechip', 'LIVE')); }
@@ -1169,7 +1595,9 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -1169,7 +1595,9 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
const r = Number(media.ratio) > 0 ? Math.max(0.8, Math.min(1.91, Number(media.ratio))) : (kind === 'pic' || kind === 'meme' ? 1 : 16 / 9);
const r = Number(media.ratio) > 0 ? Math.max(0.8, Math.min(1.91, Number(media.ratio))) : (kind === 'pic' || kind === 'meme' ? 1 : 16 / 9);
box.style.aspectRatio = String(r);
box.style.aspectRatio = String(r);
const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = thumb; box.append(img);
const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = thumb; box.append(img);
- if (media.length) box.append(h('span', 'ovfd-len', String(media.length).slice(0, 10)));
+ const len = media.length || c.duration_s;
+ if (len) box.append(h('span', 'ovfd-len', lenOf(len)));
+ if (kind === 'rundown') { const rib = h('span', 'ovfd-ribbon'); rib.append(glyph('page'), document.createTextNode('Rundown')); box.append(rib); }
if (kind === 'live' && c.counts && c.counts.viewers) box.append(h('span', 'ovfd-len ovfd-viewers', `${count(c.counts.viewers)} watching`));
if (kind === 'live' && c.counts && c.counts.viewers) box.append(h('span', 'ovfd-len ovfd-viewers', `${count(c.counts.viewers)} watching`));
a.append(box);
a.append(box);
if (kind === 'clip' || kind === 'video' || kind === 'live') watchPreview(box, c);
if (kind === 'clip' || kind === 'video' || kind === 'live') watchPreview(box, c);
@@ -1177,6 +1605,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -1177,6 +1605,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
let title = String(c.title || '');
let title = String(c.title || '');
if (kind === 'post' && /^Status:\s*/.test(title) && c.text && String(c.text).startsWith(title.replace(/^Status:\s*/, '').replace(/…$/, '').slice(0, 40))) title = '';
if (kind === 'post' && /^Status:\s*/.test(title) && c.text && String(c.text).startsWith(title.replace(/^Status:\s*/, '').replace(/…$/, '').slice(0, 40))) title = '';
if (kind === 'live') title = title.replace(/^.{1,60}? went live:?\s*/i, '') || title;
if (kind === 'live') title = title.replace(/^.{1,60}? went live:?\s*/i, '') || title;
+ if (kind === 'rundown') title = title.replace(/^Rundown:\s*/i, '') || title;
if (title) a.append(h('b', 'ovfd-title', title.slice(0, 200)));
if (title) a.append(h('b', 'ovfd-title', title.slice(0, 200)));
if (c.text && (kind === 'post' || !thumb)) a.append(h('p', 'ovfd-text', String(c.text).slice(0, 500)));
if (c.text && (kind === 'post' || !thumb)) a.append(h('p', 'ovfd-text', String(c.text).slice(0, 500)));
const counts = countsRow(c.counts);
const counts = countsRow(c.counts);
@@ -1405,6 +1834,29 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -1405,6 +1834,29 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
.ovfd-skel{display:block;height:180px;border-radius:16px;background:linear-gradient(90deg,color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent) 0,color-mix(in srgb,var(--text-primary,#e8ecf3) 9%,transparent) 50%,color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent) 100%);background-size:200% 100%;animation:ovfd-shimmer 1.4s ease-in-out infinite}
.ovfd-skel{display:block;height:180px;border-radius:16px;background:linear-gradient(90deg,color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent) 0,color-mix(in srgb,var(--text-primary,#e8ecf3) 9%,transparent) 50%,color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent) 100%);background-size:200% 100%;animation:ovfd-shimmer 1.4s ease-in-out infinite}
.ovfd-more{min-height:1px}.ovfd-caught{text-align:center;padding:10px 0}
.ovfd-more{min-height:1px}.ovfd-caught{text-align:center;padding:10px 0}
@keyframes ovfd-shimmer{to{background-position:-200% 0}}
@keyframes ovfd-shimmer{to{background-position:-200% 0}}
+
+/* 4.223.0 (the owner, 11-12: "these icons are tiny … fix this icon shit"): the kind glyph beside the time at 18 px and a
+ site's face drawn as an app icon; lengths read as times. The Rundown has its own card: its mark, whom it is about, a
+ ribbon on its picture, and the picture graded so it never reads as the stream's own card above it. */
+.ovfd-who small{gap:6px;font-size:12.5px}
+.ovfd-who small .ov-icon{position:relative;flex:none;width:18px;height:18px;overflow:hidden;border-radius:5px}
+.ovfd-who small .ov-icon .ovi-ring,.ovfd-who small .ov-icon .ovi-bg,.ovfd-who small .ov-icon .ovi-comet,.ovfd-who small .ov-icon .ovi-prog{display:none}
+.ovfd-who small .ov-icon svg{position:absolute;left:-26%;top:-26%;width:152%;height:152%;transform:none!important}
+.ovfd-face{width:36px;height:36px}
+.ovfd-face.is-site{position:relative}
+.ovfd-face.is-site .ov-icon{position:absolute;inset:0;width:100%;height:100%;display:block;--ovi-glyph:hsl(var(--ovh-h,215) 90% 82%)}
+.ovfd-face.is-site .ov-icon .ovi-ring,.ovfd-face.is-site .ov-icon .ovi-bg,.ovfd-face.is-site .ov-icon .ovi-comet,.ovfd-face.is-site .ov-icon .ovi-prog{display:none}
+.ovfd-face.is-site .ov-icon svg{position:absolute;left:-13%;top:-13%;width:126%;height:126%;transform:none!important}
+.ovfd-act .ovfd-face,.ovfd-tile,.ovfd-tile-wrap{width:44px;height:44px}
+.ovfd-face.is-rundown{border-radius:12px;background:linear-gradient(140deg,#f59e0b,#ef4444 55%,#a855f7);color:#fff;box-shadow:0 4px 14px -4px rgba(239,68,68,.55)}
+.ovfd-face.is-rundown svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2.1;stroke-linecap:round;stroke-linejoin:round}
+.ovfd-k-rundown{box-shadow:inset 0 0 0 1px color-mix(in srgb,#f59e0b 38%,transparent);background:linear-gradient(180deg,color-mix(in srgb,#f59e0b 7%,transparent),transparent 60%)}
+.ovfd-k-rundown .ovfd-media img{filter:saturate(1.35) contrast(1.06) sepia(.12)}
+.ovfd-k-rundown .ovfd-media::after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,transparent 55%,rgba(15,8,30,.55)),linear-gradient(120deg,rgba(245,158,11,.18),transparent 40%,rgba(168,85,247,.18))}
+.ovfd-ribbon{position:absolute;left:10px;top:10px;z-index:1;display:inline-flex;align-items:center;gap:6px;padding:5px 10px 5px 8px;border-radius:999px;font-size:12px;font-weight:800;letter-spacing:.02em;color:#fff;background:linear-gradient(140deg,#f59e0b,#ef4444 60%,#a855f7);box-shadow:0 4px 12px -2px rgba(0,0,0,.45)}
+.ovfd-ribbon svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round}
+.ovfd-media{position:relative}
+.ovfd-len{font-variant-numeric:tabular-nums;z-index:1}
`;
`;
document.head.appendChild(st);
document.head.appendChild(st);
}
}
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
- const THREAD_REV = { 'ov-composer.js': '203e770ec00f', 'ov-combo.js': '3e426e56fc75' };
+ const THREAD_REV = { 'ov-composer.js': '2d3782ec0b52', 'ov-combo.js': '3e426e56fc75' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
function loadCombo() {
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.222.0",
+ "version": "4.223.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.222.0",
+ "version": "4.223.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.222.0",
+ "version": "4.223.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": {
@@ -159,14 +159,14 @@
@@ -159,14 +159,14 @@
/** The person's history: one request per opening, kept two minutes (signed in only; anonymous sessions keep none). */
/** The person's history: one request per opening, kept two minutes (signed in only; anonymous sessions keep none). */
async function loadHistory() {
async function loadHistory() {
if (!ctx.token || !ctx.user || ctx.user.is_anon) return null;
if (!ctx.token || !ctx.user || ctx.user.is_anon) return null;
- const key = `ov_hist:${ctx.user.id || ctx.user.username || ''}`;
+ const key = `ov_hist2:${ctx.user.id || ctx.user.username || ''}`;
try { const c = JSON.parse(sessionStorage.getItem(key) || 'null'); if (c && Date.now() - c.at < 120000) return c.data; } catch { /* */ }
try { const c = JSON.parse(sessionStorage.getItem(key) || 'null'); if (c && Date.now() - c.at < 120000) return c.data; } catch { /* */ }
try {
try {
const r = await fetch(`${ctx.apiBase || 'https://openvibe.network'}/api/history?limit=60`, { headers: { Authorization: `Bearer ${ctx.token}` }, credentials: 'omit' });
const r = await fetch(`${ctx.apiBase || 'https://openvibe.network'}/api/history?limit=60`, { headers: { Authorization: `Bearer ${ctx.token}` }, credentials: 'omit' });
if (!r.ok) return null;
if (!r.ok) return null;
const j = await r.json();
const j = await r.json();
const data = {
const data = {
- items: (j.items || []).map((h) => ({ title: String(h.title || '').slice(0, 160), url: safeUrl(h.url), site: h.service_label || siteOf(h.url), service: h.service, at: h.updated_at })).filter((h) => h.url && h.title),
+ items: (j.items || []).map((h) => ({ title: String(h.title || '').slice(0, 160), url: safeUrl(h.url), site: h.service_label || siteOf(h.url), service: h.service, at: utcOf(h.updated_at) })).filter((h) => h.url && h.title),
services: (j.services || []).map((s) => ({ service: String(s.service || ''), count: Number(s.count) || 0 })),
services: (j.services || []).map((s) => ({ service: String(s.service || ''), count: Number(s.count) || 0 })),
};
};
try { sessionStorage.setItem(key, JSON.stringify({ at: Date.now(), data })); } catch { /* */ }
try { sessionStorage.setItem(key, JSON.stringify({ at: Date.now(), data })); } catch { /* */ }
@@ -200,8 +200,11 @@
@@ -200,8 +200,11 @@
el.querySelectorAll('[data-at]').forEach((m) => { const t = ago(m.dataset.at); if (t) m.textContent = [m.dataset.pre, t].filter(Boolean).join(' · '); });
el.querySelectorAll('[data-at]').forEach((m) => { const t = ago(m.dataset.at); if (t) m.textContent = [m.dataset.pre, t].filter(Boolean).join(' · '); });
}, 60000);
}, 60000);
}
}
+ /** A time without a zone is UTC (Network's history and older APIs send "YYYY-MM-DD HH:MM:SS"; a browser reads that as
+ * local time, and west of UTC every visit looked hours ahead: "just now" for all of them, 11-12). */
+ function utcOf(t) { const v = String(t || ''); return /^\d{4}-\d\d-\d\d[ T]\d\d:\d\d(:\d\d(\.\d+)?)?$/.test(v) ? `${v.replace(' ', 'T')}Z` : v; }
function ago(iso) {
function ago(iso) {
- const t = Date.parse(iso || ''); if (!isFinite(t)) return '';
+ const t = Date.parse(utcOf(iso)); if (!isFinite(t)) return '';
const m = Math.max(0, Math.round((Date.now() - t) / 60000));
const m = Math.max(0, Math.round((Date.now() - t) / 60000));
return m < 1 ? 'just now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`;
return m < 1 ? 'just now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`;
}
}
@@ -237,6 +240,18 @@
@@ -237,6 +240,18 @@
.ovs-x { flex: none; height: 36px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--text-muted, #93a0b4); font: inherit; font-size: 13px; cursor: pointer; }
.ovs-x { flex: none; height: 36px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--text-muted, #93a0b4); font: inherit; font-size: 13px; cursor: pointer; }
.ovs-x svg { display: none; width: 20px; height: 20px; }
.ovs-x svg { display: none; width: 20px; height: 20px; }
.ovs-x:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
.ovs-x:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+/* 4.223.0 (the owner, 11-12: "Close … ICON"; "these buttons and borders … get extra wide … make that more sleek"): Close
+ is a round × on every screen; the phone's orb keeps a hairline edge and a soft glass, never a thick ring. */
+.ovs-x { display: inline-grid; place-items: center; width: 36px; height: 36px; padding: 0; border-radius: 50%; color: var(--text-secondary, #a8b3c4); transition: background-color .15s, color .15s, transform .12s; }
+.ovs-x svg { display: block; width: 18px; height: 18px; }
+.ovs-x span { display: none; } /* the button's own aria-label (Close the menu) names it */
+.ovs-x:active { transform: scale(.9); }
+@media (hover: hover) { .ovs-x:hover { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 10%, transparent); } }
+@media (max-width: 640px) {
+ .ovs.ovs-anchored .ovs-orb { background: color-mix(in srgb, var(--bg-secondary, #121826) 72%, transparent); -webkit-backdrop-filter: blur(12px) saturate(1.4); backdrop-filter: blur(12px) saturate(1.4);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 14%, transparent), 0 8px 22px -10px rgba(0, 0, 0, .6); }
+ .ovs.ovs-anchored .ovs-orb.is-x::before { border-width: 1px; }
+}
/* Phones and small windows: the menu covers the screen (the bar under it too), so Close is always there, a round x at
/* Phones and small windows: the menu covers the screen (the bar under it too), so Close is always there, a round x at
the end of the search pill (the owner, 11-07: "This needs a close button"). The hit area is 48 px. */
the end of the search pill (the owner, 11-07: "This needs a close button"). The hit area is 48 px. */
@media (max-width: 860px) {
@media (max-width: 860px) {
@@ -297,6 +312,42 @@
@@ -297,6 +312,42 @@
/* 4.202.0: on a phone a site's name sits under its mark, so the whole name shows (Download, Community: not "Downlo…"). */
/* 4.202.0: on a phone a site's name sits under its mark, so the whole name shows (Download, Community: not "Downlo…"). */
@media (max-width: 480px) { .ovs-grid.is-mini .ovs-tile { flex-direction: column; justify-content: center; gap: 5px; min-height: 64px; padding: 6px 2px 5px; text-align: center; } .ovs-grid.is-mini .ovs-tile b { max-width: 100%; font-size: 12.5px; } }
@media (max-width: 480px) { .ovs-grid.is-mini .ovs-tile { flex-direction: column; justify-content: center; gap: 5px; min-height: 64px; padding: 6px 2px 5px; text-align: center; } .ovs-grid.is-mini .ovs-tile b { max-width: 100%; font-size: 12.5px; } }
.ovs-group[data-sec="all"] { display: flex; align-items: center; gap: 10px; }
.ovs-group[data-sec="all"] { display: flex; align-items: center; gap: 10px; }
+/* 4.223.0 (the owner, 11-12: "the icons are so small, they're hard to see … make the icons bigger so they actually take up
+ more of the square … fix the alignment"): a site's icon is an app icon in every tab — its own hue on a rounded square,
+ the glyph at 58% of it, placed absolutely so no hover transform can move it off centre. */
+.ovs .ovs-ic.ovs-site { position: relative; overflow: hidden; border-radius: 30%; background: linear-gradient(150deg, hsl(var(--ovs-h, 215) 72% 58% / .36), hsl(var(--ovs-h, 215) 70% 38% / .2)); box-shadow: inset 0 0 0 1px hsl(var(--ovs-h, 215) 70% 64% / .3); }
+.ovs .ovs-ic.ovs-site .ovi-ring, .ovs-ic.ovs-site .ovi-bg, .ovs-ic.ovs-site .ovi-comet, .ovs-ic.ovs-site .ovi-prog { display: none; }
+.ovs .ovs-ic.ovs-site > .ov-icon { position: absolute; inset: 0; width: 100%; height: 100%; display: block; --ovi-glyph: hsl(var(--ovs-h, 215) 92% 84%); color: hsl(var(--ovs-h, 215) 92% 84%); }
+:root[data-theme="light"] .ovs .ovs-ic.ovs-site > .ov-icon { --ovi-glyph: hsl(var(--ovs-h, 215) 70% 30%); color: hsl(var(--ovs-h, 215) 70% 30%); }
+.ovs .ovs-ic.ovs-site > .ov-icon svg { position: absolute; left: -13%; top: -13%; width: 126%; height: 126%; margin: 0; transform: none !important; }
+/* The site list: a grid of app icons, four across on a phone and as many as fit beyond, names on two balanced lines. */
+.ovs-grid.is-mini { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px; padding: 2px 0 6px; }
+@media (min-width: 560px) { .ovs-grid.is-mini { grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); } }
+.ovs-grid.is-mini .ovs-tile { flex-direction: column; justify-content: flex-start; align-items: center; gap: 7px; min-height: 0; padding: 6px 0 8px; border-radius: 16px; text-align: center; background: transparent; }
+.ovs-grid.is-mini .ovs-tile > span:last-child { max-width: 100%; }
+.ovs-grid.is-mini .ovs-ic { width: min(100% - 14px, 64px); height: auto; aspect-ratio: 1; border-radius: 29%; transition: transform .22s cubic-bezier(.2, 1.4, .4, 1), box-shadow .22s; }
+.ovs-grid.is-mini .ovs-tile b { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; max-width: 100%; white-space: normal; text-overflow: clip; font-size: 12px; font-weight: 650; line-height: 1.2; text-wrap: balance; }
+.ovs-grid.is-mini .ovs-tile.is-here { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
+.ovs-grid.is-mini .ovs-tile:active .ovs-ic { transform: scale(.93); transition-duration: .08s; }
+@media (hover: hover) { .ovs-grid.is-mini .ovs-tile:hover { background: transparent; } .ovs-grid.is-mini .ovs-tile:hover .ovs-ic { transform: translateY(-3px) scale(1.04); box-shadow: inset 0 0 0 1px hsl(var(--ovs-h, 215) 75% 70% / .5), 0 12px 26px -8px hsl(var(--ovs-h, 215) 75% 45% / .6); } }
+html[data-ov-motion=off] .ovs-grid.is-mini .ovs-ic { transition: none; }
+/* Each section of sites wears its glyph. */
+.ovs-group-ic { order: -1; flex: none; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 8px; background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
+.ovs-group-ic svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2.1; stroke-linecap: round; stroke-linejoin: round; }
+/* Names in the brand face (openvibe.design rule: a place's or an app's name in OV Brand 800; words you read in the UI
+ face): the site list, the tabs, Vibe's switch. */
+.ovs-grid.is-mini .ovs-tile b, .ovs-door-sw button, .ovs-starttabs .ovs-tab { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-weight: 800; letter-spacing: .005em; }
+.ovs-grid.is-mini .ovs-tile b { font-size: 13px; color: color-mix(in srgb, var(--text-primary, #e6edf7) 92%, transparent); }
+.ovs-group[data-sec="all"] { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-size: 13.5px; font-weight: 800; letter-spacing: .01em; text-transform: none; color: var(--text-primary, #e6edf7); }
+/* A slow light drifts behind the menu (Calm slows it, only Off stops it; openvibe.design: calm reduces, only off removes). */
+.ovs-panel { isolation: isolate; }
+.ovs-panel::before { content: ""; position: absolute; inset: -20%; z-index: -1; pointer-events: none; opacity: .5;
+ background: radial-gradient(38% 30% at 20% 10%, color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent), transparent 70%), radial-gradient(34% 28% at 85% 30%, color-mix(in srgb, #a855f7 16%, transparent), transparent 70%), radial-gradient(40% 32% at 50% 95%, color-mix(in srgb, #14b8a6 12%, transparent), transparent 70%);
+ animation: ovs-drift 26s ease-in-out infinite alternate; }
+@keyframes ovs-drift { from { transform: translate3d(-3%, -2%, 0) rotate(0deg); } to { transform: translate3d(3%, 2%, 0) rotate(8deg); } }
+html[data-ov-motion=reduced] .ovs-panel::before { animation-duration: 60s; }
+html[data-ov-motion=off] .ovs-panel::before { animation: none; }
+:root[data-theme="light"] .ovs-panel::before { opacity: .35; }
.ovs-group[data-sec="all"]::after { content: ""; flex: 1; height: 1px; background: var(--border, rgba(255,255,255,.1)); }
.ovs-group[data-sec="all"]::after { content: ""; flex: 1; height: 1px; background: var(--border, rgba(255,255,255,.1)); }
.ovs-tabs[hidden] { display: none; }
.ovs-tabs[hidden] { display: none; }
.ovs-tab { flex: none; height: 32px; padding: 0 14px; border: 0; border-radius: 999px; background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13.5px; font-weight: 650; cursor: pointer; }
.ovs-tab { flex: none; height: 32px; padding: 0 14px; border: 0; border-radius: 999px; background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13.5px; font-weight: 650; cursor: pointer; }
@@ -343,6 +394,24 @@
@@ -343,6 +394,24 @@
.ovs-door-sw button[aria-selected="true"] { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #121826); box-shadow: 0 1px 2px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
.ovs-door-sw button[aria-selected="true"] { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #121826); box-shadow: 0 1px 2px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
.ovs-door-sw button[aria-selected="true"] .ovs-tab-ic { color: var(--accent-light, var(--accent, #60a5fa)); }
.ovs-door-sw button[aria-selected="true"] .ovs-tab-ic { color: var(--accent-light, var(--accent, #60a5fa)); }
.ovs-door-sw button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
.ovs-door-sw button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+/* 4.223.0: one segmented control (You's tabs have the same): the ink slides to the chosen part, names at 15 px, glyphs at
+ 20 px, the chosen glyph a touch larger in the accent. */
+.ovs-door-sw { position: relative; isolation: isolate; gap: 4px; padding: 4px; border-radius: 16px; }
+.ovs-door-sw button { min-height: 46px; gap: 8px; font-size: 15px; font-weight: 700; border-radius: 12px; transition: color .18s, background-color .18s; -webkit-tap-highlight-color: transparent; }
+.ovs-door-sw button[aria-selected="true"] { background: transparent; box-shadow: none; }
+.ovs-door-sw .ovs-tab-ic, .ovs-door-sw .ovs-tab-ic > svg { width: 20px; height: 20px; }
+.ovs-door-sw .ovs-tab-ic { transition: transform .25s cubic-bezier(.3, 1.4, .4, 1); }
+.ovs-door-sw button[aria-selected="true"] .ovs-tab-ic { transform: scale(1.08); }
+.ovs-door-sw button:active .ovs-tab-ic { transform: scale(.9); }
+@media (hover: hover) and (pointer: fine) { .ovs-door-sw button:not([aria-selected="true"]):hover { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); } }
+.ovs-door-ink { position: absolute; left: 0; top: 4px; bottom: 4px; width: 0; z-index: -1; border-radius: 12px; opacity: 0; pointer-events: none;
+ background: var(--bg-secondary, #121826); box-shadow: 0 1px 2px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent), 0 6px 18px -8px color-mix(in srgb, var(--accent, #3b82f6) 60%, transparent);
+ transition: transform .34s cubic-bezier(.3, 1.3, .4, 1), width .34s cubic-bezier(.3, 1.3, .4, 1), opacity .2s; }
+.ovs-door-ink.is-on { opacity: 1; }
+:root[data-theme="light"] .ovs-door-ink { background: #fff; }
+html[data-ov-motion=reduced] .ovs-door-ink { transition: opacity .25s; }
+html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .ovs-tab-ic { transition: none; }
+@media (max-width: 380px) { .ovs-door-sw button { font-size: 14px; gap: 6px; } }
.ovs-in-door .ovs-foot { display: none; }
.ovs-in-door .ovs-foot { display: none; }
.ovs-group { display: flex; align-items: center; gap: 8px; padding: 10px 10px 4px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }
.ovs-group { display: flex; align-items: center; gap: 8px; padding: 10px 10px 4px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }
.ovs-group button { margin-left: auto; border: 0; background: none; color: var(--accent-light, #8ab4ff); font: inherit; font-size: 11.5px; font-weight: 650; text-transform: none; letter-spacing: 0; cursor: pointer; }
.ovs-group button { margin-left: auto; border: 0; background: none; color: var(--accent-light, #8ab4ff); font: inherit; font-size: 11.5px; font-weight: 650; text-transform: none; letter-spacing: 0; cursor: pointer; }
@@ -542,11 +611,30 @@
@@ -542,11 +611,30 @@
// chats with it, your coding tasks, and your agents.
// chats with it, your coding tasks, and your agents.
doorSw.innerHTML = [['vibe', 'Chat', 'talk'], ['code', 'Code', 'code'], ['agent', 'Agents', 'agent']].map(([id, label, ic]) => `<button type="button" role="tab" data-part="${id}" aria-selected="false"><span class="ovs-tab-ic">${BRIDGE_SVG[ic]}</span><span>${label}</span></button>`).join('');
doorSw.innerHTML = [['vibe', 'Chat', 'talk'], ['code', 'Code', 'code'], ['agent', 'Agents', 'agent']].map(([id, label, ic]) => `<button type="button" role="tab" data-part="${id}" aria-selected="false"><span class="ovs-tab-ic">${BRIDGE_SVG[ic]}</span><span>${label}</span></button>`).join('');
doorSw.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-part]'); if (b && door === 'vibe') { aiPart = b.getAttribute('data-part'); openDoor('vibe'); } });
doorSw.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-part]'); if (b && door === 'vibe') { aiPart = b.getAttribute('data-part'); openDoor('vibe'); } });
+ // 4.223.0: the switch's ink slides to the chosen part (the owner, 11-12: "animations, effects to the tabs"); it
+ // follows aria-selected wherever that is set, and the size of the row.
+ {
+ const ink = document.createElement('i'); ink.className = 'ovs-door-ink'; ink.setAttribute('aria-hidden', 'true'); doorSw.prepend(ink);
+ const place = () => {
+ const on = doorSw.querySelector('[data-part][aria-selected="true"]');
+ if (!on || !on.offsetWidth) { ink.classList.remove('is-on'); return; }
+ ink.style.transform = `translateX(${on.offsetLeft}px)`; ink.style.width = `${on.offsetWidth}px`; ink.classList.add('is-on');
+ };
+ if (typeof MutationObserver === 'function') new MutationObserver(() => requestAnimationFrame(place)).observe(doorSw, { subtree: true, attributes: true, attributeFilter: ['aria-selected', 'hidden'] });
+ if (typeof ResizeObserver === 'function') new ResizeObserver(() => { ink.style.transition = 'none'; place(); void ink.offsetWidth; ink.style.transition = ''; }).observe(doorSw);
+ doorSw.addEventListener('keydown', (e) => {
+ if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
+ const all = [...doorSw.querySelectorAll('[data-part]')]; const k = all.indexOf(document.activeElement); if (k < 0) return;
+ const next = all[(k + (e.key === 'ArrowRight' ? 1 : all.length - 1)) % all.length];
+ e.preventDefault(); next.focus(); next.click();
+ });
+ }
doorHost = document.createElement('div'); doorHost.className = 'ovs-door-host';
doorHost = document.createElement('div'); doorHost.className = 'ovs-door-host';
const timerButton = document.createElement('button'); timerButton.type = 'button'; timerButton.className = 'ovs-ai-timer'; timerButton.setAttribute('aria-label', 'Start a timer'); timerButton.setAttribute('data-ov-tip', 'Start a timer'); timerButton.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/></svg><span>Start a timer</span>'; timerButton.hidden = true;
const timerButton = document.createElement('button'); timerButton.type = 'button'; timerButton.className = 'ovs-ai-timer'; timerButton.setAttribute('aria-label', 'Start a timer'); timerButton.setAttribute('data-ov-tip', 'Start a timer'); timerButton.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/></svg><span>Start a timer</span>'; timerButton.hidden = true;
timerButton.addEventListener('click', () => { try { if (root.OpenVibeDock && root.OpenVibeDock.timer) root.OpenVibeDock.timer(timerButton); else if (ctx.dockMore) ctx.dockMore('timer'); } catch { /* not loaded */ } });
timerButton.addEventListener('click', () => { try { if (root.OpenVibeDock && root.OpenVibeDock.timer) root.OpenVibeDock.timer(timerButton); else if (ctx.dockMore) ctx.dockMore('timer'); } catch { /* not loaded */ } });
// Vibe's switch and its timer share one row (4.220.0): the timer is an icon at its end, never a row under the box.
// Vibe's switch and its timer share one row (4.220.0): the timer is an icon at its end, never a row under the box.
- const doorTop = document.createElement('div'); doorTop.className = 'ovs-door-top'; doorTop.append(doorSw, timerButton);
+ // 4.223.0: the timer is in every box's + (the owner: "the timer should be in the plus thing"), not in this row.
+ const doorTop = document.createElement('div'); doorTop.className = 'ovs-door-top'; doorTop.append(doorSw); void timerButton;
doorEl.append(doorTop, doorHost);
doorEl.append(doorTop, doorHost);
listEl.after(doorEl);
listEl.after(doorEl);
doorHost.addEventListener('openvibe:open-vibe', (e) => {
doorHost.addEventListener('openvibe:open-vibe', (e) => {
@@ -681,10 +769,30 @@
@@ -681,10 +769,30 @@
ghost.append(typed, document.createTextNode(completion.slice(q.length)));
ghost.append(typed, document.createTextNode(completion.slice(q.length)));
}
}
+ /** A steady hue per site (ov-home.js has the same), so an app wears one colour in every tab. */
+ 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; }
+ // The glyphs a section of sites can wear (openvibe-shared/glyphs.js), and which one a section's name asks for.
+ const GROUP_GLYPH = {
+ 'video': '<rect x="3.4" y="6.4" width="12.3" height="11.3" rx="1.9"/><path d="M15.6 10.2l4.7-2.8v9.4l-4.7-2.8"/>',
+ 'chat': '<path d="M3.8 4.3h16.8v11.7H8l-4.2 4.2Z"/>',
+ 'game': '<rect x="3.7" y="7.2" width="16.7" height="9.7" rx="4.4"/><path d="M7.6 10.7v2.7M6.4 12h2.7M15.1 11.6h0M17.4 12.9h0"/>',
+ 'book': '<path d="M12 6.3C10 4.8 7 4.3 3.5 4.9v13.2c3.5-0.6 6.5-0.1 8.5 1.4c2-1.5 5-2 8.5-1.4V4.9C17 4.3 14 4.8 12 6.3Z"/><path d="M12 6.3v13.2"/>',
+ 'store': '<path d="M3.7 8.8L5.3 3.7h13.4L20.3 8.8M3.7 8.8h16.5v1.6a2.8 2.8 0 0 1-5.4 0a2.8 2.8 0 0 1-5.6 0A2.8 2.8 0 0 1 3.7 10.4ZM4.8 12.9v7.2h14.5v-7.2M9.9 20.1v-4.1h4.1v4.1"/>',
+ 'wrench': '<path d="M14.6 3.6a5.2 5.2 0 0 0-4.6 7.3L3.7 17.3V20.3h3.1l6.4-6.4a5.2 5.2 0 0 0 7.3-4.6l-3.2 1.2l-2.5-2.5l1.2-3.2Z"/>',
+ 'spark': '<path d="M10.4 3.6l1.7 4.8L17 10.1l-4.8 1.7L10.4 16.7l-1.7-4.8L3.9 10.1l4.8-1.7ZM17 14.8l0.8 2.1L19.8 17.6l-2.1 0.8L17 20.3l-0.8-2.1L14.3 17.6l2.1-0.8Z"/>',
+ 'code': '<path d="M7.8 7.8l-4.2 4.2l4.2 4.2M16.2 7.8l4.2 4.2l-4.2 4.2M14.2 4.6l-4.2 14.8"/>',
+ 'compass': '<circle cx="12" cy="12" r="8.5"/><path d="m15.6 8.4-2.1 5.1-5.1 2.1 2.1-5.1Z"/>',
+ };
+ function groupGlyph(name) {
+ const n = String(name || '').toLowerCase();
+ return /watch|stream|video|live/.test(n) ? 'video' : /talk|belong|chat|social|community/.test(n) ? 'chat' : /play|game|fun/.test(n) ? 'game'
+ : /read|ask|news|learn/.test(n) ? 'book' : /shop|money|buy|market/.test(n) ? 'store' : /tool/.test(n) ? 'wrench'
+ : /\bai\b|make/.test(n) ? 'spark' : /develop|build|code/.test(n) ? 'code' : 'compass';
+ }
function icon(it) {
function icon(it) {
const ic = document.createElement('span');
const ic = document.createElement('span');
ic.className = 'ovs-ic';
ic.className = 'ovs-ic';
- if (it.site) { ic.classList.add('ovs-site'); const s = document.createElement('span'); s.className = 'ov-icon'; s.setAttribute('data-icon', it.site); s.setAttribute('data-size', '28'); s.setAttribute('data-fx', 'none'); ic.appendChild(s); }
+ if (it.site) { ic.classList.add('ovs-site'); ic.style.setProperty('--ovs-h', String(hueOf(it.site))); const s = document.createElement('span'); s.className = 'ov-icon'; s.setAttribute('data-icon', it.site); s.setAttribute('data-size', '28'); s.setAttribute('data-fx', 'none'); ic.appendChild(s); }
else if (ICON[it.icon]) ic.innerHTML = ICON[it.icon];
else if (ICON[it.icon]) ic.innerHTML = ICON[it.icon];
else ic.textContent = (it.icon || '•').slice(0, 2);
else ic.textContent = (it.icon || '•').slice(0, 2);
return ic;
return ic;
@@ -790,7 +898,10 @@
@@ -790,7 +898,10 @@
h.className = 'ovs-group';
h.className = 'ovs-group';
h.setAttribute('role', 'presentation');
h.setAttribute('role', 'presentation');
if (g.section) h.setAttribute('data-sec', g.section);
if (g.section) h.setAttribute('data-sec', g.section);
+ // A section of sites wears its glyph (4.223.0), drawn from the glyph registry's set.
h.appendChild(document.createTextNode(g.title));
h.appendChild(document.createTextNode(g.title));
+ // The name stays the first node (readers and the launcher read it there); the glyph is drawn before it (order -1).
+ if (g.section === 'all' && g.title) { const gi = document.createElement('span'); gi.className = 'ovs-group-ic'; gi.setAttribute('aria-hidden', 'true'); gi.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${GROUP_GLYPH[groupGlyph(g.title)]}</svg>`; h.appendChild(gi); }
if (g.clear) { const b = document.createElement('button'); b.type = 'button'; b.textContent = 'Clear'; b.setAttribute('data-ovs-clear', ''); h.appendChild(b); }
if (g.clear) { const b = document.createElement('button'); b.type = 'button'; b.textContent = 'Clear'; b.setAttribute('data-ovs-clear', ''); h.appendChild(b); }
if (g.more && g.more.href) { const m = document.createElement('a'); m.className = 'ovs-more'; m.href = safeUrl(g.more.href) || '#'; m.textContent = g.more.label; h.appendChild(m); }
if (g.more && g.more.href) { const m = document.createElement('a'); m.className = 'ovs-more'; m.href = safeUrl(g.more.href) || '#'; m.textContent = g.more.label; h.appendChild(m); }
else if (g.more) { const b = document.createElement('button'); b.type = 'button'; b.textContent = g.more.label; b.setAttribute('data-ovs-tab', g.more.tab); h.appendChild(b); }
else if (g.more) { const b = document.createElement('button'); b.type = 'button'; b.textContent = g.more.label; b.setAttribute('data-ovs-tab', g.more.tab); h.appendChild(b); }
@@ -874,7 +985,7 @@
@@ -874,7 +985,7 @@
if (as === 'sites' && it.meta) { const sm = document.createElement('small'); sm.textContent = it.meta; txt.appendChild(sm); }
if (as === 'sites' && it.meta) { const sm = document.createElement('small'); sm.textContent = it.meta; txt.appendChild(sm); }
if (it.host) { const hs = document.createElement('span'); hs.className = 'ovs-host'; hs.textContent = it.host; txt.appendChild(hs); }
if (it.host) { const hs = document.createElement('span'); hs.className = 'ovs-host'; hs.textContent = it.host; txt.appendChild(hs); }
const ic = icon(it);
const ic = icon(it);
- if (as === 'mini') { const sp = ic.querySelector('.ov-icon'); if (sp) sp.setAttribute('data-size', '26'); }
+ if (as === 'mini') { const sp = ic.querySelector('.ov-icon'); if (sp) sp.setAttribute('data-size', '44'); }
a.append(ic, txt);
a.append(ic, txt);
box.appendChild(a);
box.appendChild(a);
});
});
@@ -1437,8 +1548,10 @@
@@ -1437,8 +1548,10 @@
function youGroups() {
function youGroups() {
const { take, ranked } = basis();
const { take, ranked } = basis();
const groups = [];
const groups = [];
- if (root.OpenVibeNotifications && ctx.token) groups.push({ title: 'Notifications', as: 'notifications', items: [] });
- if (homeLib()) {
+ const H = homeLib();
+ // With the home screen, Notifications is one of You's tabs (4.223.0); without it, the section stays at the top.
+ if (root.OpenVibeNotifications && ctx.token && !H) groups.push({ title: 'Notifications', as: 'notifications', items: [] });
+ if (H) {
groups.push({ as: 'home', items: [], section: 'home' });
groups.push({ as: 'home', items: [], section: 'home' });
// Signed out, Display stays at the foot (a signed-in person has it beside their name, in ov-home).
// Signed out, Display stays at the foot (a signed-in person has it beside their name, in ov-home).
if (!(ctx.token && ctx.user && !ctx.user.is_anon) && typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });
if (!(ctx.token && ctx.user && !ctx.user.is_anon) && typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });
@@ -1492,16 +1605,47 @@
@@ -1492,16 +1605,47 @@
}
}
// Jump back in (the last places, newest first) and what this person uses most (pages opened per site).
// Jump back in (the last places, newest first) and what this person uses most (pages opened per site).
const seenB = new Set();
const seenB = new Set();
- const back = mine ? mine.items.filter((x) => { if (seenB.has(x.url)) return false; seenB.add(x.url); return true; }).slice(0, 8).map(backRow) : [];
+ const back = mine ? mine.items.filter((x) => { if (seenB.has(x.url)) return false; seenB.add(x.url); return true; }).slice(0, 60).map(backRow) : [];
const usage = mine ? mine.services.filter((x) => x && x.count > 0).map((x) => ({ id: x.service === 'media' ? 'media-hub' : x.service, count: Number(x.count) || 0 })) : [];
const usage = mine ? mine.services.filter((x) => x && x.count > 0).map((x) => ({ id: x.service === 'media' ? 'media-hub' : x.service, count: Number(x.count) || 0 })) : [];
return { apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, back, usage, groups: f && Array.isArray(f.groups) ? f.groups : [],
return { apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, back, usage, groups: f && Array.isArray(f.groups) ? f.groups : [],
- token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, look: ctx.look, me: ctx.me, sibling: ctx.sibling, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,
+ token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, look: ctx.look, me: ctx.me, sibling: ctx.sibling,
+ // You's Notifications tab: the whole list with Mark all read, mounted once; the count rides on the tab.
+ notifications: (host) => mountNotes(host), unread: () => { try { return root.OpenVibeNotifications ? Number(root.OpenVibeNotifications.unread()) || 0 : 0; } catch { return 0; } },
+ startTab: ['notifications', 'recent', 'apps'].includes(ctx.section) ? ctx.section : null, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,
onSearch: (q) => { input.value = q; render(q); input.focus(); },
onSearch: (q) => { input.value = q; render(q); input.focus(); },
pulseReady: pulseReady || (pulseReady = root.OpenVibeHome.loadPulse({ token: ctx.token, user: ctx.user })),
pulseReady: pulseReady || (pulseReady = root.OpenVibeHome.loadPulse({ token: ctx.token, user: ctx.user })),
// The feed answered: the Feed tab is the stream (its sections would sit after an endless list); it did not: as before.
// The feed answered: the Feed tab is the stream (its sections would sit after an endless list); it did not: as before.
feedLive: () => { if (!feedV2) { feedV2 = true; if (el && el.classList.contains('is-open') && !input.value && tab === 'feed') render(''); } } };
feedLive: () => { if (!feedV2) { feedV2 = true; if (el && el.classList.contains('is-open') && !input.value && tab === 'feed') render(''); } } };
}
}
+ /** The whole notifications list for You's tab: a head with how many are new and Mark all read, then every notice. */
+ function mountNotes(host) {
+ const N = root.OpenVibeNotifications;
+ if (!N || typeof N.renderMenuNotifications !== 'function') {
+ // The navbar fetches notification-ui.js once you are signed in; the tab can open first. Wait for it (or fetch it
+ // here), start it with your token, then draw. It said "unavailable" for good before (11-12).
+ host.textContent = '';
+ const wait = document.createElement('p'); wait.className = 'ovs-note'; wait.textContent = 'Loading your notifications…'; host.appendChild(wait);
+ const ready = () => {
+ const M = root.OpenVibeNotifications;
+ if (!M || typeof M.renderMenuNotifications !== 'function') { wait.textContent = 'Notifications did not load. Close the menu and open it again.'; return; }
+ try { if (!M.__ovNavInit && ctx.token) { M.init({ token: ctx.token, apiBase: ctx.apiBase || 'https://openvibe.network' }); M.__ovNavInit = true; } } catch { /* the navbar's own init may follow */ }
+ host.textContent = ''; mountNotes(host);
+ };
+ let sc = document.getElementById('ov-notify-loader');
+ if (!sc && typeof ctx.sibling === 'function') { sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = ctx.sibling('notification-ui.js'); sc.async = true; document.head.appendChild(sc); }
+ if (!sc) { wait.textContent = 'Notifications are on openvibe.network/notifications.'; return; }
+ sc.addEventListener('load', () => setTimeout(ready, 0), { once: true });
+ sc.addEventListener('error', () => { wait.textContent = 'Notifications did not load. Check your connection and open the menu again.'; }, { once: true });
+ setTimeout(() => { if (root.OpenVibeNotifications && host.contains(wait)) ready(); }, 1200);
+ return;
+ }
+ // The list draws its own search, tabs and Mark all read: the tab needs no second heading.
+ const list = document.createElement('div'); list.className = 'ovs-notifications-host';
+ host.append(list);
+ try { const view = N.renderMenuNotifications(list, () => {}); if (view && view.showAll) view.showAll(); } catch { list.textContent = 'Notifications did not load.'; }
+ }
+
function renderTab() {
function renderTab() {
paint(tab === 'feed' ? feedGroups() : tab === 'you' ? youGroups() : hereGroups());
paint(tab === 'feed' ? feedGroups() : tab === 'you' ? youGroups() : hereGroups());
}
}
@@ -51,7 +51,7 @@ window.bigPicture = async (w, h) => { const c = document.createElement('canvas')
@@ -51,7 +51,7 @@ window.bigPicture = async (w, h) => { const c = document.createElement('canvas')
assert.ok(await until(`!!window.post`), 'the composers mount');
assert.ok(await until(`!!window.post`), 'the composers mount');
// The camera: an agent's box and a post have it.
// The camera: an agent's box and a post have it.
- const tools = await page.evaluate(`[...document.querySelectorAll('.ovc-v2')].map((f) => [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')))`);
+ const tools = await page.evaluate(`[...document.querySelectorAll('.ovc-v2')].map((f) => [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')).filter((l) => l !== 'Speak' && l !== 'More: attach, timer and tools'))`);
assert.deepEqual(tools[0].slice(0, 2), ['Attach', 'Camera'], 'an agent: attach and the camera');
assert.deepEqual(tools[0].slice(0, 2), ['Attach', 'Camera'], 'an agent: attach and the camera');
assert.ok(tools[1].includes('Camera'), 'a post: the camera beside photos');
assert.ok(tools[1].includes('Camera'), 'a post: the camera beside photos');
assert.equal(await page.evaluate(`document.getElementById('g1').getAttribute('data-ovc-resize')`), '1568');
assert.equal(await page.evaluate(`document.getElementById('g1').getAttribute('data-ovc-resize')`), '1568');
@@ -49,10 +49,13 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'r
@@ -49,10 +49,13 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'r
// Each preset draws its own tools, chips and send.
// Each preset draws its own tools, chips and send.
const shape = await page.evaluate(`(() => [...document.querySelectorAll('.ovc-v2')].map((f) => ({ preset: f.dataset.preset,
const shape = await page.evaluate(`(() => [...document.querySelectorAll('.ovc-v2')].map((f) => ({ preset: f.dataset.preset,
- tools: [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')),
+ // 4.223.0: the mic and the + lead the row; the box's own tools follow.
+ lead: [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')).filter((l) => l === 'Speak' || l === 'More: attach, timer and tools'),
+ tools: [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')).filter((l) => l !== 'Speak' && l !== 'More: attach, timer and tools'),
avatar: !!f.querySelector('.ovc-head .ovc-av'), full: Math.abs(f.querySelector('textarea').getBoundingClientRect().left - f.querySelector('.ovc-body').getBoundingClientRect().left) < 4, round: f.querySelector('[data-ovt-send]').classList.contains('ovt-send--round'),
avatar: !!f.querySelector('.ovc-head .ovc-av'), full: Math.abs(f.querySelector('textarea').getBoundingClientRect().left - f.querySelector('.ovc-body').getBoundingClientRect().left) < 4, round: f.querySelector('[data-ovt-send]').classList.contains('ovt-send--round'),
send: f.querySelector('[data-ovt-send]').textContent.trim(), chips: [...f.querySelectorAll('.ovc-chip-v')].map((c) => c.textContent) })))()`);
send: f.querySelector('[data-ovt-send]').textContent.trim(), chips: [...f.querySelectorAll('.ovc-chip-v')].map((c) => c.textContent) })))()`);
assert.deepEqual(shape.map((s) => s.preset), ['post', 'chat', 'agent', 'comment']);
assert.deepEqual(shape.map((s) => s.preset), ['post', 'chat', 'agent', 'comment']);
+ assert.ok(shape.every((s) => s.lead[s.lead.length - 1] === 'More: attach, timer and tools' && (s.lead.length === 1 || s.lead[0] === 'Speak')), `the mic, then the +, lead every row (${JSON.stringify(shape.map((s) => s.lead))})`);
assert.deepEqual(shape[0].tools.slice(0, 4), ['Photos and video', 'Files', 'Emoji', 'Poll'], 'a post: photos, files, emoji and the site\'s own tool');
assert.deepEqual(shape[0].tools.slice(0, 4), ['Photos and video', 'Files', 'Emoji', 'Poll'], 'a post: photos, files, emoji and the site\'s own tool');
assert.ok(shape[0].avatar && shape[0].send === 'Post' && !shape[0].round);
assert.ok(shape[0].avatar && shape[0].send === 'Post' && !shape[0].round);
assert.ok(shape.every((x) => x.full), 'the words use the full width: no column kept for the picture');
assert.ok(shape.every((x) => x.full), 'the words use the full width: no column kept for the picture');
@@ -587,7 +587,12 @@ const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...do
@@ -587,7 +587,12 @@ const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...do
await until(p, "!!window.OpenVibeDock", 'the panels');
await until(p, "!!window.OpenVibeDock", 'the panels');
await p.evaluate(OPEN('vibe'));
await p.evaluate(OPEN('vibe'));
await until(p, PLACE, 'the menu on Vibe');
await until(p, PLACE, 'the menu on Vibe');
- await p.evaluate("document.querySelector('.ovs .ovs-ai-timer').click()");
+ // 4.223.0: the timer is in the box's + (every composer's), not a button of Vibe's own.
+ await p.evaluate("(document.querySelector('.ovs .ovdk-vibe-new') || { click() {} }).click()");
+ await until(p, "!!document.querySelector('.ovs .ovc-more')", 'the box and its +');
+ await p.evaluate("document.querySelector('.ovs .ovc-more').click()");
+ await until(p, "[...document.querySelectorAll('.ovs .ovc-tray-i')].some((b) => b.textContent === 'Timer')", 'Timer in the +');
+ await p.evaluate("[...document.querySelectorAll('.ovs .ovc-tray-i')].find((b) => b.textContent === 'Timer').click()");
await until(p, "!!document.querySelector('.ovdk .ovdk-tmenu')", 'the timer menu');
await until(p, "!!document.querySelector('.ovdk .ovdk-tmenu')", 'the timer menu');
await p.evaluate("document.querySelector('.ovdk .ovdk-tmenu [data-min=\"5\"]').click()");
await p.evaluate("document.querySelector('.ovdk .ovdk-tmenu [data-min=\"5\"]').click()");
const strip = await until(p, "(() => { const s = document.querySelector('.ovdk .ovdk-timer'); return s ? s.innerText : null; })()", 'the timer strip in the Dock');
const strip = await until(p, "(() => { const s = document.querySelector('.ovdk .ovdk-timer'); return s ? s.innerText : null; })()", 'the timer strip in the Dock');
@@ -51,7 +51,7 @@ const server = http.createServer((req, res) => {
@@ -51,7 +51,7 @@ const server = http.createServer((req, res) => {
assert.ok(await until(`!!document.querySelector('form.ovc-v2[data-preset="comment"]')`), 'the reply is the shared comment composer');
assert.ok(await until(`!!document.querySelector('form.ovc-v2[data-preset="comment"]')`), 'the reply is the shared comment composer');
assert.equal(await page.evaluate(`document.querySelectorAll('[data-ovt-composer]').length`), 0, 'the server form is replaced, not doubled');
assert.equal(await page.evaluate(`document.querySelectorAll('[data-ovt-composer]').length`), 0, 'the server form is replaced, not doubled');
const shape = await page.evaluate(`(() => { const f = document.querySelector('form.ovc-v2[data-preset="comment"]'); return { action: f.getAttribute('action'), method: f.method, parent: (f.querySelector('input[name=parent_id]') || {}).value, send: f.querySelector('[data-ovt-send]').textContent.trim(), tools: [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')) }; })()`);
const shape = await page.evaluate(`(() => { const f = document.querySelector('form.ovc-v2[data-preset="comment"]'); return { action: f.getAttribute('action'), method: f.method, parent: (f.querySelector('input[name=parent_id]') || {}).value, send: f.querySelector('[data-ovt-send]').textContent.trim(), tools: [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')) }; })()`);
- assert.deepEqual(shape, { action: '/p/x/comments', method: 'post', parent: '1', send: 'Reply', tools: ['Photos and video', 'Emoji'] });
+ assert.deepEqual(shape, { action: '/p/x/comments', method: 'post', parent: '1', send: 'Reply', tools: ['More: attach, timer and tools', 'Photos and video', 'Emoji'] });
assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390, 'nothing is wider than a phone');
assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390, 'nothing is wider than a phone');
await page.evaluate(`(() => { const t = document.querySelector('form.ovc-v2 textarea'); t.value = 'Hello from the shared box'; t.dispatchEvent(new Event('input', { bubbles: true })); document.querySelector('form.ovc-v2 [data-ovt-send]').click(); })()`);
await page.evaluate(`(() => { const t = document.querySelector('form.ovc-v2 textarea'); t.value = 'Hello from the shared box'; t.dispatchEvent(new Event('input', { bubbles: true })); document.querySelector('form.ovc-v2 [data-ovt-send]').click(); })()`);
const landed = await until(`!!document.getElementById('posted')`);
const landed = await until(`!!document.getElementById('posted')`);
@@ -91,8 +91,9 @@ async function openMenu(p) {
@@ -91,8 +91,9 @@ async function openMenu(p) {
await wait(200);
await wait(200);
await openMenu(wide);
await openMenu(wide);
const d = await wide.evaluate(BUTTON);
const d = await wide.evaluate(BUTTON);
- assert.ok(d && d.shown && d.word && !d.icon, `a wide screen keeps the word Close: ${JSON.stringify(d)}`);
- console.log('menu-close-chrome: a phone has a round close button (40 px, 48 px hit area) that closes the menu; wide screens keep Close');
+ // 4.223.0 (the owner, 11-12: "Close … ICON"): a wide screen has the round × too, named for readers.
+ assert.ok(d && d.shown && d.icon && !d.word && d.label === 'Close the menu', `a wide screen has a round × named Close the menu: ${JSON.stringify(d)}`);
+ console.log('menu-close-chrome: a phone has a round close button (40 px, 48 px hit area) that closes the menu; wide screens a round × too');
} finally {
} finally {
await browser.close();
await browser.close();
server.close();
server.close();
@@ -190,32 +190,37 @@ const server = http.createServer((req, res) => {
@@ -190,32 +190,37 @@ const server = http.createServer((req, res) => {
await openTab(s, 'you');
await openTab(s, 'you');
assert.ok(await until(s, `!!document.querySelector('.ovh-actor')`), 'the Actor strip is on top');
assert.ok(await until(s, `!!document.querySelector('.ovh-actor')`), 'the Actor strip is on top');
await eventually(async () => assert.match(await s.evaluate(`document.querySelector('.ovh-actor').textContent`), /Juniper.*Working.*Reading job posts/, 'it names the Actor, its state and its step'));
await eventually(async () => assert.match(await s.evaluate(`document.querySelector('.ovh-actor').textContent`), /Juniper.*Working.*Reading job posts/, 'it names the Actor, its state and its step'));
- // You on top: the name, Display (the Look panel) and Settings beside it; then your home, Most used and Jump back in.
- // 4.195.0 (the owner, the old block crossed out: "get rid of this thing … we didn't really need [it] here, other than
- // like adding your account"): one slim row of its own, and the old account block is not set in the menu at all.
+ // 4.223.0 (the owner, 11-12: "at the top … the goosely Online bar. Under that is tabs … move the notifications into a
+ // tab … put the apps more front facing … the first tab"; "waste of space"; "compact"): your row holds every round
+ // button (this site's own first, as icons), one AI notice, then one row of tabs with All and Edit at its end.
await eventually(async () => assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-me .ovh-who-txt b').textContent`), 'Ada', 'your name on top'));
await eventually(async () => assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-me .ovh-who-txt b').textContent`), 'Ada', 'your name on top'));
await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovs .ovh-account, .ovs .openvibe-navbar-dropdown, .ovs .ud-grid')`), 'the old account block and its grid of tiles are not in the menu'));
await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovs .ovh-account, .ovs .openvibe-navbar-dropdown, .ovs .ud-grid')`), 'the old account block and its grid of tiles are not in the menu'));
- await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-me > .ovh-ib')].map((b) => b.getAttribute('aria-label'))`), ['Add or switch account', 'Display: theme, text size, motion', 'Account and settings', 'Sign out'], 'add or switch account, Display, Settings, Sign out, in that order'));
- await eventually(async () => assert.ok(await s.evaluate(`/[?&]add_account=1/.test(document.querySelector('.ovh-me > a.ovh-ib').getAttribute('href')) && !!document.querySelector('.ovh-me [data-ov-look-open]') && !!document.querySelector('.ovh-me a[href="https://openvibe.network/my"]')`), 'the add-account address is the bar\'s; Display and Settings are in the row'));
+ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-me .ovh-acts > .ovh-ib:not(.is-own)')].map((b) => b.getAttribute('aria-label'))`), ['Add or switch account', 'Display: theme, text size, motion', 'Account and settings', 'Sign out'], 'add or switch account, Display, Settings, Sign out, in that order'));
+ await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovh-own') && [...document.querySelectorAll('.ovh-me .ovh-acts > .is-own')].every((b) => !!b.getAttribute('aria-label'))`), 'this site\'s own items are buttons in your row, never a row of their own'));
+ await eventually(async () => assert.ok(await s.evaluate(`/[?&]add_account=1/.test(document.querySelector('.ovh-me a.ovh-ib[aria-label="Add or switch account"]').getAttribute('href')) && !!document.querySelector('.ovh-me [data-ov-look-open]') && !!document.querySelector('.ovh-me a[href="https://openvibe.network/my"]')`), 'the add-account address is the bar\'s; Display and Settings are in the row'));
await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovs-displayrow')`), 'Display is not at the foot any more'));
await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovs-displayrow')`), 'Display is not at the foot any more'));
- assert.ok(await until(s, `document.querySelectorAll('.ovh-rail .ovh-card').length === 2`), 'Jump back in shows the places opened lately');
- await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-rail .ovh-card')].map((a) => a.getAttribute('href'))`), ['https://openvibe.live/ada', 'https://openvibe.pics/1']));
+ await eventually(async () => assert.ok(await s.evaluate(`document.querySelector('.ovh-me').getBoundingClientRect().height <= 112`), 'your row is one card: you, then your buttons'));
+ await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-me .ovh-ib')].every((b) => { const r = b.getBoundingClientRect(); return r.height >= 44 && r.width >= 40; })`), 'and its buttons are full touch targets'));
+ await eventually(async () => assert.ok(await s.evaluate(`(() => { const m = document.querySelector('.ovh-me').getBoundingClientRect(); return m.right <= innerWidth && [...document.querySelectorAll('.ovh-me .ovh-ib')].every((x) => x.getBoundingClientRect().right <= m.right + 1); })()`), 'nothing in your row runs off the screen'));
+ // The tab row: Apps first and chosen; All and Edit at its end; the tabs move with the arrow keys.
+ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-tabrow .ovh-tab')].map((t) => t.dataset.tab + (t.getAttribute('aria-selected') === 'true' ? '*' : ''))`), ['apps*', 'notifications', 'recent'], 'Apps, Notifications, Recent; Apps first'));
+ await eventually(async () => assert.ok(await s.evaluate(`!!document.querySelector('.ovh-tabrow .ovh-headtools .ovh-allbtn') && !!document.querySelector('.ovh-tabrow .ovh-headtools .ovh-edit') && !document.querySelector('.ovh-sec .ovh-edit')`), 'All and Edit sit at the end of the tab row'));
+ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons'));
+ // Recent: what you use most, then where you were, in days, each a card with its whole title and its time.
+ await s.evaluate(`document.querySelector('.ovh-tab[data-tab="recent"]').click(), true`);
+ assert.ok(await until(s, `document.querySelectorAll('.ovh-hrow').length === 2`), 'Recent lists the places opened lately');
+ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-hrow')].map((a) => a.getAttribute('href'))`), ['https://openvibe.live/ada', 'https://openvibe.pics/1']));
+ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-sec > span')].map((x) => x.textContent)`), ['Most used', 'Where you were'], 'most used, then where you were'));
await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => k.textContent)`), ['Live5', 'Pics2'], 'Most used: pages opened per site'));
await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => k.textContent)`), ['Live5', 'Pics2'], 'Most used: pages opened per site'));
await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec a.ovh-link[href="https://openvibe.report/me"]') || {}).textContent`), 'Your stats', 'Most used links to your stats on Report'));
await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec a.ovh-link[href="https://openvibe.report/me"]') || {}).textContent`), 'Your stats', 'Most used links to your stats on Report'));
- // The owner, 2026-11-10: "Your home should be above Jump back in… the thing at the top a little more compact… links
- // to whatever the user uses the most". Your home comes first, then what you use most, then where you were last;
- // the bar with your name is short; each most-used app is a way in (its icon, a full touch target), not a legend.
- await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-sec > span')].map((x) => x.textContent)`), ['Your home', 'Most used', 'Jump back in'], 'your home, then most used, then where you were'));
- await eventually(async () => assert.ok(await s.evaluate(`document.querySelector('.ovh-me').getBoundingClientRect().height <= 64`), 'the row with your name is one short row'));
- await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-me > .ovh-ib')].every((b) => { const r = b.getBoundingClientRect(); return r.height >= 44 && r.width >= 40; })`), 'and its buttons are full touch targets'));
- await eventually(async () => assert.ok(await s.evaluate(`(() => { const m = document.querySelector('.ovh-me').getBoundingClientRect(); return m.right <= innerWidth && [...document.querySelectorAll('.ovh-me > *')].every((x) => x.getBoundingClientRect().right <= m.right + 1); })()`), 'all of it on the screen'));
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => [k.getAttribute('href'), k.getAttribute('aria-label'), !!k.querySelector('.ovh-ic > *')].join('|'))`),
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => [k.getAttribute('href'), k.getAttribute('aria-label'), !!k.querySelector('.ovh-ic > *')].join('|'))`),
['https://openvibe.live/|Live, opened 5 times|true', 'https://openvibe.pics/|Pics, opened 2 times|true'], 'each most-used app is a link with its icon and says how often');
['https://openvibe.live/|Live, opened 5 times|true', 'https://openvibe.pics/|Pics, opened 2 times|true'], 'each most-used app is a link with its icon and says how often');
await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].every((k) => { const r = k.getBoundingClientRect(); return r.height >= 44 && r.width >= 44; })`), 'each a full touch target'));
await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].every((k) => { const r = k.getBoundingClientRect(); return r.height >= 44 && r.width >= 44; })`), 'each a full touch target'));
- await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-rail').parentElement.querySelector('.ovh-sec small') || {}).textContent`), 'where you were last', 'Jump back in says what it is'));
- await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start'));
- await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons'));
- await s.evaluate(`document.querySelector('.ovh-sec .ovh-edit').click(), true`);
+ await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-hrow small span:last-child')].every((t) => /now|min ago|h ago|yesterday|days ago/.test(t.textContent))`), 'each place says when'));
+ await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-hist .ovh-link') || {}).getAttribute && document.querySelector('.ovh-hist .ovh-link').getAttribute('href')`), 'https://openvibe.my/history', 'Full history opens the whole list'));
+ await s.evaluate(`document.querySelector('.ovh-tab[data-tab="apps"]').click(), true`);
+ await eventually(async () => assert.ok(await s.evaluate(`!!document.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app')`), 'back on Apps'));
+ await s.evaluate(`document.querySelector('.ovh-tabrow .ovh-edit').click(), true`);
await s.evaluate(`(() => { const t = document.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); t.focus(); t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); })()`);
await s.evaluate(`(() => { const t = document.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); t.focus(); t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); })()`);
assert.ok(await until(s, `window.__calls.some((c) => c.m === 'PUT' && c.u.endsWith('/api/v1/me/home-screen') && c.auth === 'Bearer tok-1')`, 3000), 'the arrangement goes to Network with the bearer');
assert.ok(await until(s, `window.__calls.some((c) => c.m === 'PUT' && c.u.endsWith('/api/v1/me/home-screen') && c.auth === 'Bearer tok-1')`, 3000), 'the arrangement goes to Network with the bearer');
await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
@@ -92,8 +92,8 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
@@ -92,8 +92,8 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat Code* Agents', chosen: 'Vibe' }, `${name}: the switch goes to Code`));
await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat Code* Agents', chosen: 'Vibe' }, `${name}: the switch goes to Code`));
await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="agent"]').click()`); await wait(80);
await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="agent"]').click()`); await wait(80);
await eventually(async () => assert.equal((await inside()).sw, 'Chat Code Agents*', `${name}: the switch goes to Agents`));
await eventually(async () => assert.equal((await inside()).sw, 'Chat Code Agents*', `${name}: the switch goes to Agents`));
- await page.evaluate(`document.querySelector('.ovs-ai-timer').click()`);
- await eventually(async () => assert.equal(await page.evaluate('window.timerCalled'), 'timer', `${name}: AI timer entry`));
+ // 4.223.0: the timer is in every box's + (the owner: "the timer should be in the plus thing"), not in this row.
+ assert.equal(await page.evaluate(`!!document.querySelector('.ovs-door-top .ovs-ai-timer')`), false, `${name}: no timer button beside the switch`);
await page.evaluate(`document.querySelector('.ovs-input').value = 'find this'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(170);
await page.evaluate(`document.querySelector('.ovs-input').value = 'find this'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(170);
await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));`);
await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));`);
await eventually(async () => assert.equal(await page.evaluate('window.asked'), 'find this', `${name}: AI Enter hands its text to Vibe`));
await eventually(async () => assert.equal(await page.evaluate('window.asked'), 'find this', `${name}: AI Enter hands its text to Vibe`));
@@ -143,7 +143,7 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
@@ -143,7 +143,7 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
await eventually(async () => assert.equal(await page.evaluate("document.querySelector('.ovnav-avatar-count')?.textContent"), '7', `${width}px: avatar carries the unread count`));
await eventually(async () => assert.equal(await page.evaluate("document.querySelector('.ovnav-avatar-count')?.textContent"), '7', `${width}px: avatar carries the unread count`));
await page.evaluate("document.querySelector('#openvibe-avatar-btn').click()"); await wait(500);
await page.evaluate("document.querySelector('#openvibe-avatar-btn').click()"); await wait(500);
await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open .ovs-starttabs [data-tab=you][aria-selected=true]')"), true, `${width}px avatar opens You`));
await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open .ovs-starttabs [data-tab=you][aria-selected=true]')"), true, `${width}px avatar opens You`));
- await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open [data-sec=notifications]')"), true, `${width}px avatar opens at Notifications`));
+ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open [data-sec=notifications], .ovs.is-open .ovh-tab[data-tab=notifications][aria-selected=true]')"), true, `${width}px avatar opens at Notifications`));
await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.openvibe-navbar .ovs-feed-dot:not([hidden])')"), true, `${width}px menu button carries the calm Feed dot`));
await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.openvibe-navbar .ovs-feed-dot:not([hidden])')"), true, `${width}px menu button carries the calm Feed dot`));
await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovdk.is-open')"), false, `${width}px avatar does not open the floating Dock`));
await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovdk.is-open')"), false, `${width}px avatar does not open the floating Dock`));
await page.close();
await page.close();