openvibers/openvibe.shared Commits
Shared 4.248.0: every time in the reader's own clock (#536)
Changes
@@@ -1,5 +1,99 @@# Changelog++## 4.248.0 — 2026-11-14++++**Every time in the reader's own clock** (the owner, 11-14: "make it so areas like this show the time based on the++users computer by default or configurable setting if logged in and changed").++++- theme-loader's `time`: `format(value, 'time'|'date'|'datetime'|'day'|'ago'|'full')`, `zone()`, `device()`,++ `hour12()`, `zones()`, `upgrade(scope)`. Two new display settings, synced to the account like the rest: `clock`++ (auto, 12, 24) and `tz` (auto, or any IANA zone the browser knows). Auto is this device's zone and clock.++- `<time datetime="…" data-ov-time="…">` anywhere on a page is rewritten in the reader's time (its title says the full++ moment with the zone), again when the setting changes and for any such element added later. A server keeps writing++ UTC as the fallback for crawlers.++- The Look panel has Clock (Auto, 12-hour, 24-hour) and Time zone.++- Comments (thread.js): the time's title is the reader's moment, not "UTC".++- The feed: a card from before today says its day and time ("Yesterday 9:14 PM", "Tue 3:05 PM"), not a bare time as if++ it were today's; an event's start too. A report card (feed.item@1 1.2.0 `stat`) says when its number happened ("The++ most people on OpenVibe at once today, at 3:47 AM") and today's side numbers as chips (people today, signed in, page++ views, new accounts, live now).++++**Pictures that do not load leave no empty frame** (the owner, 11-14: "broken images"): a feed card drops its picture++box, a group row (a run of deals) shows its placeholder.++++**The footer's online box opens into a column** (the owner, 11-14: "see the split of where the guests are … which sites++they're on and what they're up to", "make them red" when live, "these cards also start stacking vertically"):++someone live now wears a red ring and a LIVE badge with their viewers and links to their channel; the guests are one bar++split by site (hover or focus a part for what people do there) with the parts in words; a Today line says the day's++people and its peak, the peak's time in the reader's clock; open, the people are one per line and the tiles stack in++two columns. An open box stays open across the minute's repaint. Reads Network's /api/frame/online `guest_sites`,++`people[].live` and `today` (OpenVibe.Network #937).++++**`.ov-places`, a page's own places in one shape** (kit-layout.css; the owner, 11-14, on Download: "move our tab to the++top … a fixed tab system … icons … like our main menu area … so the layout can be reused for pages like this"): icon++rows in a side column on a computer, a row of icon tabs that stays under the bar on a phone, counts as badges, an++extra part (a meter) on the computer only.++++**Signed in is the person the bar shows** (the owner, 11-14, on openvibe.download: the menu said "Sign in to make++OpenVibe yours" under their own avatar): the guest card, the workspace and the menu's "me" follow the signed-in person,++not a token; a site that signs in by its own session cookie has no token in the page.++++**Feature cards and questions, remade** (showcase; the owner, 11-14, on OpenRestream: "redesign all these icons … use the++full space … modern and minimalist … stands out"; the questions have "so much padding for no reason" and no space++before the next block): a feature's icon is a filled tile on its own hue (six in turn down a grid), its glyph large, a++ring icon without its ring; on a phone the tile sits beside the title and the words take the card's whole width. A++question is a compact row (48 px) with a glyph (its own third item, or one its words suggest), whatever a site's own++details rule says, and the block after a showcase section is spaced like a section.++++**Notifications read like a social feed** (the owner, 11-14: "no spacing between them … confusing to have an X. Like,++what does that do? … more like social media … rich content … the approval system can go through here"): every row the++same card with room between, unread a tint and a dot at the right, a ⋯ menu (Mark as read, Remove this notice,++Notification settings) instead of the bare X, and up to three buttons a notice brings in `rich_content.actions`++([{ label, url, style: primary|danger }], https only: Approve and Deny on an approval, Watch on a live notice).++++**Vibe is a conversation** (ov-run.js and the menu; the owner, 11-14: "you see how it says now your agent's idle? You++don't even need that … more like a conversation view … move this history … to a history button in the corner … the tabs++… need to be above the actor. And the actor … a fixed thing that's on top, kind of like how Muse is"): All, Chats, Tasks++and Code on top, your agent fixed under them (face, state, today's spend, History and Pause), and All opens today's++conversation with it in place, with "Message Vibe" below. History lists every conversation, task and code run; pressed++again, it is back to the conversation. The menu no longer lends the Dock's Now / Needs you / timeline above Vibe.++The footer no longer names a live streamer twice (their row says LIVE; the channel tile goes).++++**One button system in every composer** (ov-composer.js; the owner, 11-14: "How is the centering and offset of icons++still such a problem?", "give them that more 3D look … the post button … completely customized … an animation", "the++attached file should be just in the plus button"): the tools a + already holds (photos, files, the camera) were still++drawn in the row wherever a box sat outside a thread page, because `[hidden]` lost to the button's own display; hidden++now means gone everywhere. Every icon button is a 40 px disk lit from above, its glyph centred on a grid and never++nudged; Post, Reply and the round send are their own lit buttons with a shine that crosses on hover, a press you can++feel and the arrow lifting; nothing to send is a dim disk (not an outline); a one-line box centres its buttons. Motion++Off keeps them still.++++**Signed in on one site, signed in on the next** (navbar.js; the owner, 11-14: openvibe.tours showed Sign in though every++other site had them signed in): a round trip that once found no session there (the 'guest' this script keeps) no longer++stops the next one when the person arrives from another OpenVibe site; a sign-out (the site's own 'guest' cookie)++still does. Once per tab per 10 minutes, as before.++++**The bar** (navbar.js; the owner, 11-14): the menu button keeps the little input (its dot on the left and a typed line;++the spark beside the dot, which read as two dots, and its pink are gone: every colour is the theme's) over its three++tabs; signing in, a comet of light runs around the Sign in button's rim instead of a ring inside its glyph; and on every++page a row that scrolls sideways (a rail, chips, tabs, pills, a strip, or `data-ov-peek`) peeks a little way and back++the first time it is on screen, so it is plain that it scrolls (once per row; never under a finger; Calm less, Off++never; `data-ov-peek="off"` opts out).++++**The menu's field and close** (search.js; the owner, 11-14: "the close button's offset … more consistent with the++search input", "the top thing … needs to be less round"): the field and its close share the kit's radius scale (a++rounded rectangle, not a pill), the close is as tall as the field, centred on it, measured again once the sheet has++slid in and when the window changes, and wears the field's surface and line. Vibe's strip above its list is quiet:++only a timer, an agent at work or something that needs you shows (no idle line, no "Nothing needs you", no folds).++++**The menu's close** (the owner, 11-14): closing no longer moves the page (the browser restores no scroll for the++menu's own Back, and focus returns without scrolling); the close button folds home into the menu button, and a tab++flies back into the button that opened it only while the menu still shows that tab.++++**A pressed button keeps its place** (the owner, 11-14, Live's Chat button "shifts way down … makes you un-click it"):++the forms press is `scale: .97`, not `transform: scale(.97)`, which replaced any transform a site positioned a button++with (centered, slid in). Checkboxes and Motion off likewise.++## 4.247.0 — 2026-11-13**Any video in a comment** (the owner, 11-13: "full support for our composer to support all video file types"; plan@@@ -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.247.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.248.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.247.0",++ "version": "4.248.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)",@@@ -135,6 +135,11 @@const chips = el(doc, 'div', 'ovdt-chips');const list = el(doc, 'div', 'ovdt-list');wrap.append(nowHead, nowBox, needsHead, needsBox, chips, list);++ // 4.248.0, quiet (the menu's Vibe; the owner, 11-14: "Your agent's idle? You don't even need that"): only what is++ // happening shows (a timer, an agent at work, something that needs you); no idle line, no "Nothing needs you",++ // no timeline folds (those are History's). The Dock's own Agents panel keeps everything.++ const quiet = !!opts.quiet;++ if (quiet) { wrap.classList.add('is-quiet'); chips.hidden = true; list.hidden = true; }container.appendChild(wrap);async function api(path, options = {}) {@@@ -166,7 +171,9 @@function renderNow(now) {nowBox.textContent = '';++ nowHead.hidden = false;if (!now) {++ if (quiet) { nowHead.hidden = true; return; }nowBox.appendChild(el(doc, 'div', 'ovdt-card ovdt-empty', 'Your agent is idle. Ask it something in Talk.'));return;}@@@ -216,7 +223,9 @@function renderNeeds(needs) {needsBox.textContent = '';++ needsHead.hidden = false;if (!needs.length) {++ if (quiet) { needsHead.hidden = true; return; }needsBox.appendChild(el(doc, 'div', 'ovdt-empty', 'Nothing needs you.'));return;}@@@ -114,7 +114,7 @@// 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': '9b2a27f70f5f' };++ const VIBE_REV = { 'ov-composer.js': 'c29a98f058e6' };function joinComposer(form) {if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain boxform.setAttribute('data-ovc-no-files', '');@@@ -84,7 +84,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': 'e1c7c07dca1f', 'dock-code.js': 'cb97274524d0', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '759f07eebc81', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '1b6262204e91', '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': '4071b3c54334', 'dock-code.js': 'cb97274524d0', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '759f07eebc81', 'dock-timeline.js': '893dbfa4c5e3', 'dock-actor.js': '1b6262204e91', '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);@@@ -310,7 +310,7 @@* agent's live view (K-D3; it hides itself when there is none). Each loads on its own. */function agentTop(id, panel, epoch) {load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {-- if (on && epoch === panelEpoch && !mounts[id + 'Timeline'] && panel.isConnected && panelActive(id)) mounts[id + 'Timeline'] = root.OpenVibeDockTimeline.mount(panel.querySelector('.ovdk-timeline'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), active: () => panelActive('agent'), focus: () => o.task || null });++ if (on && epoch === panelEpoch && !mounts[id + 'Timeline'] && panel.isConnected && panelActive(id)) mounts[id + 'Timeline'] = root.OpenVibeDockTimeline.mount(panel.querySelector('.ovdk-timeline'), { quiet: part === 'live', token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), active: () => panelActive('agent'), focus: () => o.task || null });});if (panel.querySelector('.ovdk-actor')) load('dock-actor.js', () => !!(root.OpenVibeDockActor && root.OpenVibeDockActor.mount)).then((on) => {if (on && epoch === panelEpoch && !mounts[id + 'Actor'] && panel.isConnected && panelActive(id)) mounts[id + 'Actor'] = root.OpenVibeDockActor.mount(panel.querySelector('.ovdk-actor'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), networkBase: o.apiBase || 'https://openvibe.network', active: () => panelActive('agent') });@@@ -500,6 +500,24 @@.ovf-who-list img,.ovf-who-list i{flex:none;width:26px;height:26px;border-radius:50%;object-fit:cover}.ovf-who-list small{color:var(--text-muted,#8b93ad);font-weight:500}.ovf-who-more{margin:8px 2px 0;color:var(--text-muted,#8b93ad)}++/* 4.248.0 (the owner, 11-14): who is live wears red; where the guests are; today; open, everything stacks. */++.ovf-who img.is-live,.ovf-who i.is-live{box-shadow:0 0 0 2px var(--bg-secondary,#0f1420),0 0 0 4px var(--live-red-text,#ef4444)!important}++.ovf-who-list li.is-live a{background:color-mix(in srgb,var(--live-red-text,#ef4444) 14%,transparent)}++.ovf-who-live{font-style:normal;font-size:.72rem;font-weight:800;letter-spacing:.04em;text-transform:uppercase;padding:2px 7px;border-radius:999px;color:#fff;background:var(--live-red,#dc2626)}++.ovf-who-today{margin:4px 2px 0;color:var(--text-secondary,#b6bdd2)}.ovf-who-today b{color:var(--text-primary,#f1f4fb);font-variant-numeric:tabular-nums}++.ovf-split{display:flex;gap:2px;height:10px;margin:12px 0 0;border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--text-primary,#f1f4fb) 6%,transparent)}++.ovf-split-s{flex:1 1 0;min-width:4px;transition:filter .15s,flex-grow .4s cubic-bezier(.2,.9,.3,1)}.ovf-split-s:hover{filter:brightness(1.25)}++.ovf-split-l{list-style:none;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr));gap:4px 12px;margin:10px 0 0;padding:0}++.ovf-split-l a,.ovf-split-l li>span{display:flex;align-items:center;gap:7px;min-width:0;min-height:30px;padding:3px 8px;border-radius:9px;color:var(--text-secondary,#b6bdd2);text-decoration:none}++.ovf-split-l a:hover{background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent);color:var(--text-primary,#f1f4fb)}++.ovf-who .ovf-split-l i{display:block;flex:none;width:10px;height:10px;border-radius:3px;align-self:center}++.ovf-split-l b{color:var(--text-primary,#f1f4fb);font-variant-numeric:tabular-nums}.ovf-split-l small{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted,#8b93ad)}++.ovf-who .ovf-c0{background:var(--accent,#60a5fa)}.ovf-who .ovf-c1{background:var(--success-text,#34d399)}.ovf-who .ovf-c2{background:var(--warning-text,#fbbf24)}.ovf-who .ovf-c3{background:#a78bfa}.ovf-who .ovf-c4{background:#f472b6}.ovf-who .ovf-c5{background:#22d3ee}.ovf-who .ovf-c6{background:#fb923c}.ovf-who .ovf-c7{background:#94a3b8}.ovf-who .ovf-c8{background:#64748b}++/* Open, the box is a column: the people one per line, the tiles stacked (11-14: "start stacking vertically"). */++.ovf-who[open] .ovf-who-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))}++.ovf-who[open] .ovf-who-list a{display:flex;border-radius:12px}++.ovf-now:has(>.ovf-who[open])>.ovf-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,160px),1fr));margin:6px 0 0;padding:0;overflow:visible}++.ovf-now:has(>.ovf-who[open])>.ovf-tiles>.ovf-tile{border-radius:12px;white-space:normal}.ovf-tiles{display:flex;gap:6px;margin:0 -14px;padding:0 14px 2px;overflow-x:auto;overscroll-behavior-x:contain;scrollbar-width:none}.ovf-tiles::-webkit-scrollbar{display:none}.ovf-tile{flex:none;display:inline-flex;align-items:center;gap:6px;min-height:36px;box-sizing:border-box;padding:0 12px 0 10px;border-radius:999px;white-space:nowrap;font-weight:500!important;color:var(--text-secondary,#b6bdd2);background:color-mix(in srgb,var(--text-primary,#f1f4fb) 5%,transparent);transition:background-color .15s,transform .12s}@@@ -662,7 +680,8 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacityconst username = onlineText(p && p.username, 40);const avatar = onlineUrl(p && p.avatar);return username && /^[A-Za-z0-9_.-]+$/.test(username)-- ? { name: onlineText(p.name, 60) || username, username, avatar, site: onlineText(p.site, 40) }++ ? { name: onlineText(p.name, 60) || username, username, avatar, site: onlineText(p.site, 40),++ ...(p.live === true ? { live: true, liveUrl: onlineUrl(p.live_url), viewers: onlineN(p.viewers) } : {}) }: null;}).filter(Boolean);const row = (r, withLine) => {@@@ -683,6 +702,9 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacitycounts: (Array.isArray(o.counts) ? o.counts : []).map((c) => row({ ...c, link: c && (c.link || c.url) }, false)).filter(Boolean),items: (Array.isArray(o.items) ? o.items : []).map((i) => row(i, true)).filter(Boolean),sites: onlineN(o.sites), views_7d: onlineN(o.views_7d),++ // 4.248.0 (the owner, 11-14: "see the split of where the guests are"): a count per site with what people do there.++ guestSites: (Array.isArray(o.guest_sites) ? o.guest_sites : []).slice(0, 9).map((g) => ({ name: onlineText(g && g.name, 30), n: onlineN(g && g.n), doing: onlineText(g && g.doing, 40), url: onlineUrl(g && g.url) })).filter((g) => g.name && g.n > 0),++ today: o.today && typeof o.today === 'object' && onlineN(o.today.peak) > 0 ? { peak: onlineN(o.today.peak), peakAt: Number.isFinite(Date.parse(o.today.peak_at)) ? String(o.today.peak_at) : '', people: onlineN(o.today.people), signedIn: onlineN(o.today.signed_in) } : null,};}/** The strip from the one read: the old facts it carries, who is online, the counts and this site's own items. */@@@ -691,15 +713,26 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity// had under its pages: every member by name, then how many more and how many guests. A guest is a number only.let who = '';if (o.total > 0) {-- const face = (p, size) => (p.avatar ? `<img src="${esc(p.avatar)}" alt="" width="${size}" height="${size}" loading="lazy" decoding="async" data-ov-initial="${esc(p.name.charAt(0).toUpperCase())}">` : `<i aria-hidden="true">${esc(p.name.charAt(0).toUpperCase())}</i>`);++ // Someone live now wears a red ring (11-14, the owner: "detect if … a user's live and make them red").++ const face = (p, size) => (p.avatar ? `<img${p.live ? ' class="is-live"' : ''} src="${esc(p.avatar)}" alt="" width="${size}" height="${size}" loading="lazy" decoding="async" data-ov-initial="${esc(p.name.charAt(0).toUpperCase())}">` : `<i${p.live ? ' class="is-live"' : ''} aria-hidden="true">${esc(p.name.charAt(0).toUpperCase())}</i>`);const parts = [o.members > 0 ? `${fmtN(o.members)} member${o.members === 1 ? '' : 's'}` : '', o.guests > 0 ? `${fmtN(o.guests)} guest${o.guests === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ');// Shorthand (the owner, 2026-11-10: "Make it like shorthand '3 online'. If you put your mouse over it, it says// 'last 30 minutes'"): the count and the faces. When, and how many are members and guests, are the tooltip's// and the first line of what a press opens (a finger never sees a tooltip).const head = `<span class="ovf-who-dot" aria-hidden="true"></span><span class="ovf-who-n" data-ov-tip="In the last 30 minutes"${parts ? ` data-ov-tip-sub="${esc(parts)}"` : ''}><b>${fmtN(o.total)}</b> online</span>${o.people.length ? `<span class="ovf-who-faces" aria-hidden="true">${o.people.slice(0, 5).map((p) => face(p, 24)).join('')}</span>` : ''}`;const when = `<p class="ovf-who-when">In the last 30 minutes${parts ? ` · ${parts}` : ''}</p>`;-- const list = o.people.map((p) => `<li><a href="${NETWORK_URL}/@${encodeURIComponent(p.username)}">${face(p, 26)}<span${subjectAttr(p)}>${esc(p.name)}</span>${p.site ? `<small>${esc(p.site)}</small>` : ''}</a></li>`).join('');-- who = `<details class="ovf-who"><summary>${head}</summary>${when}${list ? `<ul class="ovf-who-list">${list}</ul>` : ''}${o.more > 0 ? `<p class="ovf-who-more">and ${fmtN(o.more)} more</p>` : ''}</details>`;++ const list = o.people.map((p) => `<li${p.live ? ' class="is-live"' : ''}><a href="${p.live && p.liveUrl ? esc(p.liveUrl) : `${NETWORK_URL}/@${encodeURIComponent(p.username)}`}">${face(p, 26)}<span${subjectAttr(p)}>${esc(p.name)}</span>${p.live ? `<em class="ovf-who-live">Live${p.viewers > 0 ? ` · ${fmtN(p.viewers)}` : ''}</em>` : p.site ? `<small>${esc(p.site)}</small>` : ''}</a></li>`).join('');++ // Today so far: the day's people and its peak, the peak's time in the reader's clock (theme-loader time).++ const t = o.today;++ const whole = (n) => (n < 100000 ? Number(n).toLocaleString('en-US') : fmtN(n)); // 1,043, not 1k++ const today = t ? `<p class="ovf-who-today">Today${t.people > 0 ? ` <b>${whole(t.people)}</b> people${t.signedIn > 0 ? ` (${whole(t.signedIn)} signed in)` : ''} ·` : ''} peak <b>${whole(t.peak)}</b>${t.peakAt ? ` at <time datetime="${esc(t.peakAt)}" data-ov-time="time">${esc(t.peakAt.slice(11, 16))} UTC</time>` : ''}</p>` : '';++ // Where the guests are: one bar split by site (hover or focus a part for what people do there), then the parts in++ // words. Counts only, never anything about a guest.++ const gs = o.guestSites || [];++ const gsum = gs.reduce((a, g) => a + g.n, 0);++ const split = gs.length && gsum > 0 ? `<div class="ovf-split" role="img" aria-label="Guests by site: ${esc(gs.map((g) => `${g.name} ${g.n}`).join(', '))}">${gs.map((g, i) => `<span class="ovf-split-s ovf-c${i}" data-grow="${g.n}" data-ov-tip="${esc(g.name)}: ${fmtN(g.n)} guest${g.n === 1 ? '' : 's'}" data-ov-tip-sub="${esc(g.doing)}"></span>`).join('')}</div>`++ + `<ul class="ovf-split-l">${gs.map((g, i) => { const inner = `<i class="ovf-c${i}" aria-hidden="true"></i><b>${fmtN(g.n)}</b><span>${esc(g.name)}</span><small>${esc(g.doing)}</small>`; return `<li>${g.url ? `<a href="${esc(g.url)}">${inner}</a>` : `<span>${inner}</span>`}</li>`; }).join('')}</ul>` : '';++ who = `<details class="ovf-who"><summary>${head}</summary>${when}${today}${split}${list ? `<ul class="ovf-who-list">${list}</ul>` : ''}${o.more > 0 ? `<p class="ovf-who-more">and ${fmtN(o.more)} more</p>` : ''}</details>`;}// Then a row of small tiles, each opening what it counts: live (only while someone is), the counts, this// site's own items, and the two facts the strip always had.@@@ -709,7 +742,10 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacityconst live = o.live;if (live && live.channels > 0) {tiles.push(tile('https://openvibe.live/', ' is-live', `<span class="ovf-dot" aria-hidden="true"></span><b>${fmtN(live.channels)}</b><span>live now</span>${live.watching > 0 ? `<small>${fmtN(live.watching)} watching</small>` : ''}`));-- live.first.forEach((c) => tiles.push(tile(c.url, ' is-channel', `<span>${esc(c.name)}</span>`))); // a channel is nobody: no effect to wear++ // A channel whose streamer is in the list already says so on their own row (LIVE, red ring): not twice++ // (11-14, the owner: "it shows Maticus twice").++ const named = new Set(o.people.filter((p) => p.live && p.liveUrl).map((p) => p.liveUrl.toLowerCase()));++ live.first.filter((c) => !named.has(String(c.url).toLowerCase())).forEach((c) => tiles.push(tile(c.url, ' is-channel', `<span>${esc(c.name)}</span>`))); // a channel is nobody: no effect to wear}o.counts.forEach((c) => tiles.push(row(c)));o.items.forEach((i) => tiles.push(row(i)));@@@ -721,7 +757,14 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacityfunction paintPulse() {if (!_mounted) return;const html = _online ? onlineHTML(_online) : (_pulse ? pulseHTML(_pulse) : '');-- for (const el of _mounted.querySelectorAll('[data-ovf-pulse]')) { el.innerHTML = html; el.hidden = !html; }++ for (const el of _mounted.querySelectorAll('[data-ovf-pulse]')) {++ // Keep an open box open across the 60 s repaint (it used to snap shut under a reading finger).++ const wasOpen = !!el.querySelector('details.ovf-who[open]');++ el.innerHTML = html; el.hidden = !html;++ if (wasOpen) { const d = el.querySelector('details.ovf-who'); if (d) d.open = true; }++ el.querySelectorAll('[data-grow]').forEach((seg) => { seg.style.flexGrow = String(Number(seg.getAttribute('data-grow')) || 1); });++ try { const L = typeof window !== 'undefined' && window.OpenVibeThemeLoader; if (L && L.time) L.time.upgrade(el); } catch { /* the UTC fallback stays */ }++ }}function readPulse() {if (typeof fetch === 'undefined' || (typeof document !== 'undefined' && document.hidden)) return;@@@ -17,6 +17,8 @@* .ov-rail one row that scrolls sideways, items --item wide, snapping* .ov-cover a box at least --min tall with one child (.is-centre) centred in it* .ov-frame a box of --ratio that crops a picture or a video to fill it++ * .ov-places a page's own places (My files, Shared links, Bin…): icon rows in a side column on a computer, a fixed++ * row of icon tabs at the top on a phone (4.248.0). Put it first in the page so a phone shows it on top.** The classless base (Pico's idea): put .ov-base on <body> or <main> and plain HTML (headings, text, links, lists,* quotes, code, tables, forms) looks finished on the site's theme, light or dark. Every selector is inside :where(),@@@ -134,3 +136,49 @@:where(.ov-base) :where(button:disabled, input:disabled, select:disabled, textarea:disabled) { opacity: .55; cursor: not-allowed; }:where(.ov-base) :where(fieldset) { margin: 0 0 1em; padding: var(--space-xs) var(--space-s); border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius-sm); }:where(.ov-base) :where(mark) { padding: 0 .2em; border-radius: 4px; background: color-mix(in srgb, var(--ovk-warn) 35%, transparent); color: inherit; }++++/* ── .ov-places (4.248.0; the owner, 11-14, on Download: "move our tab to the top … a fixed tab system … add icons …++ kind of like our main menu area … so the layout can be reused for pages like this"). One markup, two shapes:++ <nav class="ov-places" aria-label="Places">++ <a href="/files" aria-current="page"><svg …/><span>My files</span><em>4</em></a> …++ <div class="ov-places-extra">…a meter, a button…</div>++ </nav>++ A computer: a column of icon rows (beside the content in an .ov-sidebar). A phone (< 861 px): a row of icon tabs++ that stays under the bar while the page scrolls, the menu's tabs in small; the extra part is left for the page. */++.ov-places { display: flex; flex-direction: column; gap: 2px; min-width: 0; }++.ov-places > a, .ov-places > button {++ position: relative; display: flex; align-items: center; gap: 10px; min-height: 42px; box-sizing: border-box;++ padding: 8px 12px; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary, #b6bdd2);++ font: inherit; font-weight: 600; text-decoration: none; cursor: pointer; text-align: left;++ transition: background-color .15s ease, color .15s ease, scale .12s ease;++}++.ov-places > a:hover, .ov-places > button:hover { background: color-mix(in srgb, var(--text-primary, #f1f4fb) 6%, transparent); color: var(--text-primary, #f1f4fb); }++.ov-places > a:active, .ov-places > button:active { scale: .97; }++.ov-places > [aria-current="page"], .ov-places > [aria-selected="true"] {++ background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); color: var(--text-primary, #f1f4fb);++ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent);++}++.ov-places > a > svg, .ov-places > button > svg, .ov-places > a > .ov-ic, .ov-places > button > .ov-ic { flex: none; width: 20px; height: 20px; color: var(--accent-light, var(--accent, #60a5fa)); }++.ov-places > a > em, .ov-places > button > em {++ margin-left: auto; padding: 1px 7px; border-radius: 999px; font-style: normal; font-size: .74rem; font-weight: 800;++ font-variant-numeric: tabular-nums; color: var(--text-primary, #f1f4fb); background: color-mix(in srgb, var(--text-primary, #f1f4fb) 10%, transparent);++}++.ov-places-extra { margin-top: 12px; }++@media (max-width: 860px) {++ .ov-places {++ position: sticky; top: var(--ovnav-real-h, 52px); z-index: 20; flex-direction: row; gap: 6px;++ margin: 0 calc(-1 * var(--ov-gutter, 16px)); padding: 8px var(--ov-gutter, 16px);++ overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none;++ background: color-mix(in srgb, var(--bg-primary, #0b1220) 86%, transparent);++ -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px);++ }++ .ov-places::-webkit-scrollbar { display: none; }++ .ov-places > a, .ov-places > button {++ flex: 1 0 auto; flex-direction: column; justify-content: center; gap: 4px; min-width: 76px; min-height: 58px;++ padding: 8px 10px; font-size: .78rem; text-align: center; background: color-mix(in srgb, var(--text-primary, #f1f4fb) 5%, transparent);++ }++ .ov-places > a > em, .ov-places > button > em { position: absolute; top: 4px; right: 6px; margin: 0; font-size: .66rem; padding: 0 6px; }++ .ov-places-extra { display: none; }++}++html[data-ov-motion=off] :is(.ov-places > a, .ov-places > button) { transition: none; }++@@@ -321,8 +321,8 @@.ovmb { display: block; overflow: visible; transition: filter .3s; }.ovmb-bar { fill: transparent; stroke: currentColor; stroke-width: 1.7; }.ovmb-dot { fill: currentColor; }-- .ovmb-spark { fill: var(--ovo-b, #e879f9); }-- .ovmb-words { fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; opacity: .55; }++ .ovmb-spark { fill: none; stroke: var(--ovo-a, var(--accent-light, #60a5fa)); stroke-width: 0; }++ .ovmb-words { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; opacity: .6; }.ovmb-app { fill: currentColor; opacity: .42; }.ovmb-lit { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }.ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-words, .ovmb-spark, .ovmb-app, .ovmb-lit) { transform-box: fill-box; transform-origin: center; transition: transform .5s cubic-bezier(.2,1.3,.3,1), fill .3s, stroke-width .3s, opacity .25s; }@@@ -406,8 +406,20 @@:hover > .ovsi .ovsi-a { animation: none; opacity: 1; }html[data-ov-motion=off] .ovsi :is(.ovsi-p, .ovsi-a) { animation: none; }.ovsi-o { opacity: 0; stroke-dasharray: 30 39; }-- .is-going .ovsi-o { opacity: 1; animation: ovsi-go 1s linear infinite; }-- .is-going .ovsi-p { opacity: .55; }++ /* 4.248.0 (the owner, 11-14: "the spinning circle should be on the outer rim … animated better"): signing in, a++ comet of light runs around the button's rim (not inside its glyph), the glyph breathing within. */++ .openvibe-navbar-login { position: relative; }++ .openvibe-navbar-login.is-going::before { content: ''; position: absolute; inset: -4px; border-radius: 50%; pointer-events: none;++ background: conic-gradient(from 0deg, transparent 0 52%, color-mix(in srgb, var(--accent-light, #93c5fd) 40%, transparent) 70%, var(--accent-light, #93c5fd) 92%, #fff 100%);++ -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2.5px)); mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2.5px));++ animation: ovsi-rim .85s linear infinite; filter: drop-shadow(0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent)); }++ .is-going .ovsi-o { display: none; }++ .is-going .ovsi-p { opacity: .85; animation: ovsi-breathe 1.1s ease-in-out infinite; transform-origin: 12px 12px; }++ @keyframes ovsi-rim { to { transform: rotate(1turn); } }++ @keyframes ovsi-breathe { 50% { opacity: .5; transform: scale(.9); } }++ html[data-ov-motion=reduced] .openvibe-navbar-login.is-going::before { animation-duration: 1.6s; }++ html[data-ov-motion=off] .openvibe-navbar-login.is-going::before { animation: none; background: color-mix(in srgb, var(--accent-light, #93c5fd) 70%, transparent); }++ html[data-ov-motion=off] .is-going .ovsi-p { animation: none; }@keyframes ovsi-p { 0%, 62%, 100% { opacity: 1; transform: none; } 70%, 92% { opacity: 0; transform: translateX(4px); } }@keyframes ovsi-a { 0%, 62%, 100% { opacity: 0; transform: translateX(-5px); } 70%, 92% { opacity: 1; transform: none; } }@keyframes ovsi-go { to { stroke-dashoffset: -69; } }@@@ -624,7 +636,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': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': '0a397ea380cc', 'ov-share.css': 'd609a71f611a', 'search.js': 'dc2028f84893', 'dock.js': '0a657bac86b2', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '0f6074850291', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'f2e7c61e0934', 'soft.js': '98ab9b8080b1', 'ov-run.js': 'a9008daa5af5', 'ov-composer.js': '9b2a27f70f5f', 'items.js': 'a38f01f90c40', 'items.css': 'e917ec14cd4d' };++ const SIBLING_REV = { 'ov-mark.js': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'd44fe4443e56', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '38d7cab3b420', 'ov-share.js': '0a397ea380cc', 'ov-share.css': 'd609a71f611a', 'search.js': '89f2b938b3ae', 'dock.js': '5f29e88f48dc', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'aebc7358ae27', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '7b0c89b39ac8', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'f2e7c61e0934', 'soft.js': '98ab9b8080b1', 'ov-run.js': '60b6e6de9f97', 'ov-composer.js': 'c29a98f058e6', 'items.js': 'a38f01f90c40', 'items.css': 'e917ec14cd4d' };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.@@@ -1098,9 +1110,12 @@// the same place, the one that closes it (rule opens-it-closes-it). One drawing for the bar, the menu's sheet and// the Dock's (root.OpenVibeOrb); its styles are above (.ovmb). Class names kept from M57 (.ovmb-bar, .ovmb-dot for// the face, .ovmb-app .ovmb-a0..2 for the tabs) so every caller and test still finds its parts.++ // 4.248.0 (the owner, 11-14): the bar is the little input again, its dot on the left and a typed line in it (the++ // spark that sat beside the dot read as two dots, and its pink is gone: every colour is the theme's); three tabs++ // under it, the first lit.const ORB_PARTS = '<rect class="ovmb-bar" x="3.2" y="4.3" width="17.6" height="7.2" rx="3.6"/>'-- + '<circle class="ovmb-dot" cx="6.9" cy="7.9" r="1.5"/><path class="ovmb-words" d="M10 7.9h3.4"/>'-- + '<path class="ovmb-spark" d="M16.6 5.75l.62 1.53 1.53.62-1.53.62-.62 1.53-.62-1.53-1.53-.62 1.53-.62z"/>'++ + '<circle class="ovmb-dot" cx="6.9" cy="7.9" r="1.5"/><path class="ovmb-words" d="M10 7.9h7.2"/>'++ + '<path class="ovmb-spark" d="M17.2 7.9h0"/>'+ '<rect class="ovmb-app ovmb-a0" x="3" y="14.7" width="5.2" height="5" rx="1.6"/><rect class="ovmb-app ovmb-a1" x="9.4" y="14.7" width="5.2" height="5" rx="1.6"/><rect class="ovmb-app ovmb-a2" x="15.8" y="14.7" width="5.2" height="5" rx="1.6"/>'+ '<rect class="ovmb-lit" x="3" y="14.7" width="5.2" height="5" rx="1.6"/>';function orbSVG(size = 22, open = false) {@@@ -1969,6 +1984,11 @@let _authInFlight = null;let _hintPainted = false, _hintSettled = false;++ /** The hint the site's own server set (a sign-out writes 'guest' here), as opposed to one this script kept. */++ function cookieHint() {++ const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_sso_hint=([^;]*)/);++ return m ? m[1] : null;++ }function ssoHint() {const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_sso_hint=([^;]*)/);if (m) return m[1];@@@ -2021,7 +2041,12 @@*/function maybeSilentLogin() {if (!_config.silentLogin || typeof location === 'undefined') return false;-- if (ssoHint() === 'guest') return false;++ // A sign-out here (the 'guest' cookie) is final. A round trip that once found no session (the 'guest' kept in++ // storage: a visit before signing in) is not: arriving from another OpenVibe site, where the person may well be++ // signed in now, earns one quiet round trip (11-14, the owner: openvibe.tours showed Sign in though every other++ // site had them signed in). The 10-minute limit below still holds.++ if (cookieHint() === 'guest') return false;++ if (ssoHint() === 'guest' && !fromOpenVibe()) return false;if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false;if (/[?&]sso=none\b/.test(location.search)) return false;try {@@@ -3059,6 +3084,51 @@_defaults: { linksFor },};++ // ── Rows that scroll sideways say so (4.248.0; the owner, 11-14: "it waits a sec and then … does a little animation++ // so that it's apparent that it can scroll"). Every OpenVibe page: a row wider than its box (a rail, chips, tabs,++ // pills, a strip, or anything marked data-ov-peek), the first time most of it is on screen, waits, then peeks a++ // little way and back. Once per row per page; never while a finger or the pointer is on it; Calm peeks less, Off++ // not at all; data-ov-peek="off" opts a row out. Design rule: a row that scrolls sideways shows it (a peek and a fade).++ function startPeeks() {++ if (typeof document === 'undefined' || typeof IntersectionObserver !== 'function' || root.__ovPeeks) return;++ root.__ovPeeks = true;++ const seen = new WeakSet();++ const motion = () => document.documentElement.getAttribute('data-ov-motion') || '';++ const wide = (el) => { try { if (el.scrollWidth - el.clientWidth < 24 || el.scrollLeft > 4) return false; const o = getComputedStyle(el).overflowX; return o === 'auto' || o === 'scroll'; } catch { return false; } };++ const io = new IntersectionObserver((entries) => {++ for (const e of entries) {++ if (!e.isIntersecting || e.intersectionRatio < 0.6) continue;++ const el = e.target; io.unobserve(el);++ if (motion() === 'off' || !wide(el)) continue;++ let touched = false; const stop = () => { touched = true; };++ el.addEventListener('pointerdown', stop, { once: true, passive: true }); el.addEventListener('wheel', stop, { once: true, passive: true });++ setTimeout(() => {++ if (touched || !el.isConnected || el.scrollLeft > 4 || el.matches(':hover')) return;++ const far = Math.min(motion() === 'reduced' ? 22 : 56, el.scrollWidth - el.clientWidth);++ try { el.scrollTo({ left: far, behavior: 'smooth' }); } catch { el.scrollLeft = far; return; }++ setTimeout(() => { if (!touched && Math.abs(el.scrollLeft - far) < 6) { try { el.scrollTo({ left: 0, behavior: 'smooth' }); } catch { el.scrollLeft = 0; } } }, 520);++ }, 700);++ }++ }, { threshold: [0.6] });++ const SEL = '[data-ov-peek]:not([data-ov-peek="off"]), [class*="rail"], [class*="chips"], [class*="tabs"], [class*="pills"], [class*="strip"], [class*="carousel"], [class*="scroller"]';++ let queued = false;++ const scan = () => {++ queued = false;++ let n = 0;++ document.querySelectorAll(SEL).forEach((el) => {++ if (n > 60 || seen.has(el) || el.getAttribute('data-ov-peek') === 'off' || el.closest('.openvibe-navbar, .ovs-panel, [role=dialog]')) return;++ seen.add(el); n++;++ if (wide(el)) io.observe(el);++ });++ };++ const later = () => { if (queued) return; queued = true; (root.requestIdleCallback || ((f) => setTimeout(f, 300)))(scan, { timeout: 1500 }); };++ later();++ if (typeof MutationObserver === 'function' && document.body) new MutationObserver(later).observe(document.body, { childList: true, subtree: true });++ }++ if (typeof document !== 'undefined' && typeof window !== 'undefined' && !(typeof module !== 'undefined' && module.exports)) {++ if (document.readyState === 'complete') setTimeout(startPeeks, 1200); else window.addEventListener('load', () => setTimeout(startPeeks, 1200), { once: true });++ }++if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;else {root.OpenVibeNavbar = OpenVibeNavbar;@@@ -236,8 +236,16 @@.openvibe-notif-dock .openvibe-nv-unread svg { width: 18px; height: 18px; }.openvibe-notif-dock .openvibe-nv-unread[aria-pressed="true"], .openvibe-notif-dock .openvibe-nv-unread:hover, .openvibe-notif-dock .openvibe-nv-search:hover { opacity: 1; }html[data-ov-motion=off] .openvibe-notif-dock .openvibe-nv-search.is-open { animation: none; }-- .openvibe-notif-dock .openvibe-nv-find .openvibe-nv-search:not(.is-open), .openvibe-notif-dock .openvibe-nv-unread { width: 44px; height: 44px; }-- .openvibe-notif-dock .openvibe-nv-search:not(.is-open) input { width: 44px; }++ /* 4.248.0 (the owner, 11-14: "these icons are offset … waste of space"): two 40 px disks, their glyphs centred on a++ grid, on the tabs' own line; the row sits right under the switch above it. */++ .openvibe-notif-dock .openvibe-nv-find .openvibe-nv-search:not(.is-open), .openvibe-notif-dock .openvibe-nv-unread { width: 40px; height: 40px; }++ .openvibe-notif-dock .openvibe-nv-search:not(.is-open) input { width: 40px; }++ .openvibe-notif-dock .openvibe-nv-search:not(.is-open) > svg { left: 50%; transform: translate(-50%, -50%); }++ .openvibe-notif-dock .openvibe-nv-unread { display: grid; place-items: center; }++ .openvibe-notif-dock .openvibe-nv-unread svg { display: block; margin: 0; }++ .openvibe-notif-dock .openvibe-nv-find { padding-bottom: 0; align-self: center; gap: 4px; }++ .openvibe-notif-dock .openvibe-nv-tools { padding: 0 8px 0 10px; gap: 4px; min-height: 44px; }++ .openvibe-notif-dock .openvibe-nv-search, .openvibe-notif-dock .openvibe-nv-unread { box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 3px 8px -5px rgba(0,0,0,.6); }.openvibe-notif-dock .openvibe-nv-search > svg { left: 11px; width: 22px; height: 22px; }.openvibe-notif-dock .openvibe-nv-unread svg { width: 22px; height: 22px; }.openvibe-notif-dock .openvibe-nv-unread { gap: 0; } /* its word is hidden (font-size 0): no gap may push the glyph off centre */@@@ -295,7 +303,22 @@.openvibe-nv-list { flex: 1; overflow-y: auto; padding: 2px 0 6px; overscroll-behavior: contain; }.openvibe-nv-day { position: sticky; top: 0; z-index: 1; padding: 10px 16px 6px; font-size: 11.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted, #8b8b96); background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }/* A row: the opaque .ovn-row over .ovn-under, which a swipe uncovers (Read on the left, Clear on the right). */-- .openvibe-notif-item { position: relative; margin: 0 6px; border-radius: 14px; overflow: hidden; cursor: pointer; }++ .openvibe-notif-item { position: relative; margin: 0 6px 6px; border-radius: 14px; overflow: hidden; cursor: pointer; }++ /* 4.248.0 (the owner, 11-14: "no spacing between them … confusing to have an X … more like social media"): every row++ the same card with room between, the face larger, unread a tint and a dot at the right, a ⋯ menu instead of a++ bare X, buttons a notice brings (Approve, Deny, Watch). */++ .openvibe-notif-item .ovn-row { background: color-mix(in srgb, var(--text-primary, #ececf1) 3%, var(--ovn-bg, #1d1f27)); }++ .openvibe-notif-item.is-menu { overflow: visible; z-index: 3; }++ .openvibe-notif-item .ovn-menu { position: absolute; right: 8px; top: 42px; z-index: 4; min-width: 220px; padding: 6px; border-radius: 14px; background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 14px 40px -12px rgba(0,0,0,.6); display: grid; gap: 2px; }++ .openvibe-notif-item .ovn-menu[hidden] { display: none; }++ .openvibe-notif-item .ovn-menu button { display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 8px 10px; border: 0; border-radius: 10px; background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 13.5px; font-weight: 600; text-align: left; cursor: pointer; }++ .openvibe-notif-item .ovn-menu button:hover, .openvibe-notif-item .ovn-menu button:focus-visible { background: var(--bg-hover, rgba(255,255,255,.08)); outline: 0; }++ .openvibe-notif-item .ovn-menu button[data-row="dismiss"] { color: var(--danger-text, #f87171); }++ .openvibe-notif-item .ovn-menu svg { width: 17px; height: 17px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }++ .openvibe-notif-item .ovn-acts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }++ .openvibe-notif-item .ovn-act { font: inherit; font-size: 12.5px; font-weight: 700; min-height: 32px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.14)); background: transparent; color: var(--text-primary, #ececf1); cursor: pointer; }++ .openvibe-notif-item .ovn-act.is-primary { border-color: transparent; background: var(--accent-strong, var(--accent, #3b82f6)); color: var(--on-accent-strong, #fff); }++ .openvibe-notif-item .ovn-act.is-danger { color: var(--danger-text, #f87171); border-color: color-mix(in srgb, var(--danger, #ef4444) 40%, transparent); }.openvibe-notif-item .ovn-row { position: relative; z-index: 1; display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; padding: 10px 14px 10px 16px; border-radius: 14px;background: var(--ovn-bg, var(--bg-elevated, #1d1f27)); transition: background .15s, transform .2s ease; touch-action: pan-y; outline: none; }.openvibe-notif-item .ovn-row.has-thumb { grid-template-columns: 38px minmax(0, 1fr) 56px; }@@@ -312,7 +335,7 @@.openvibe-notif-item[data-swipe="read"] .ovn-under .clear, .openvibe-notif-item[data-swipe="clear"] .ovn-under .read { display: none; }.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 .content { min-width: 0; padding-right: 26px; }/* 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; }@@@ -322,8 +345,10 @@.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 .meta { font-size: 11.5px; color: var(--text-muted, #8b8b96); margin-top: 5px; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }.openvibe-notif-item .meta .dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .6; }-- .openvibe-notif-item .unread-dot { position: absolute; left: 5px; top: 26px; width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); }-- .openvibe-notif-item .row-actions { margin-left: auto; display: flex; gap: 2px; opacity: 0; transition: opacity .15s; }++ .openvibe-notif-item .unread-dot { position: absolute; right: 14px; top: 50%; margin-top: -5px; width: 10px; height: 10px; border-radius: 50%; background: var(--accent, #3b82f6); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }++ .openvibe-notif-item.unread .ovn-row { padding-right: 34px; }++ .openvibe-notif-item .row-actions { position: absolute; right: 6px; top: 6px; z-index: 2; display: flex; gap: 2px; opacity: 0; transition: opacity .15s; }++ .openvibe-notif-item.unread .row-actions { right: 30px; }.openvibe-notif-item:hover .row-actions, .openvibe-notif-item:focus-within .row-actions { opacity: 1; }/* The row's buttons sit on its time line, never over its text; touch screens always show them. */@media (hover: none), (pointer: coarse) { .openvibe-notif-item .row-actions { opacity: 1; } }@@@ -532,6 +557,14 @@if (img) return `<span class="ovn-glyph ovn-avatar" style="--ovn-tone:${tone}"><img src="${esc(img)}" alt="" loading="lazy" onerror="this.remove()"><span class="ovn-badge">${svg(GLYPH[p.glyph] || GLYPH.bell)}</span></span>`;return `<span class="ovn-glyph" style="--ovn-tone:${tone}">${svg(GLYPH[p.glyph] || GLYPH.bell)}</span>`;}++ /** Buttons a notice carries (rich_content.actions: [{ label, url, style: 'primary'|'danger'|'' }], https only, at most++ * three): Approve and Deny on an approval, Watch on a live notice (4.248.0; the owner, 11-14: "the approval system can++ * go through here"). A press opens its address; the service on the other end does the action. */++ function actsHtml(n) {++ const acts = (Array.isArray(richOf(n).actions) ? richOf(n).actions : []).filter((a) => a && typeof a.label === 'string' && a.label.trim() && /^https:\/\//.test(String(a.url || ''))).slice(0, 3);++ if (!acts.length) return '';++ return `<div class="ovn-acts">${acts.map((a) => `<button type="button" class="ovn-act${a.style === 'primary' ? ' is-primary' : a.style === 'danger' ? ' is-danger' : ''}" data-row="go" data-url="${esc(a.url)}">${esc(String(a.label).slice(0, 28))}</button>`).join('')}</div>`;++ }function chipsHtml(p, extra = []) {const sev = p.severity ? [`<span class="ovn-chip sev" style="--ovn-tone:${TONE[p.tone] || TONE.accent}">${esc(p.severity)}</span>`] : [];const all = sev.concat((p.chips || []).concat(extra).map(c => `<span class="ovn-chip">${esc(c)}</span>`));@@@ -930,6 +963,8 @@gear: GLYPH.gear,open: '<path d="M14 4h6v6M20 4l-9 9M19 14v5a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 4 19V7a1.5 1.5 0 0 1 1.5-1.5H10"/>',read: '<path d="M5 12.6l4.6 4.6L19 7.6"/>',++ more: '<circle cx="5.5" cy="12" r="1.2"/><circle cx="12" cy="12" r="1.2"/><circle cx="18.5" cy="12" r="1.2"/>',++ trash: '<path d="M5 7h14M10 7V5h4v2M7 7l1 12h8l1-12"/>',close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',empty: '<path d="M12 3.5a1.22 1.22 0 0 1 1.22 1.22v0.47a5.44 5.44 0 0 1 4.22 5.25v3.38c0 0.84 0.28 1.59 0.84 2.25l0.75 0.84a0.75 0.75 0 0 1 -0.56 1.31H5.53a0.75 0.75 0 0 1 -0.56 -1.31l0.75 -0.84c0.56 -0.66 0.84 -1.41 0.84 -2.25v-3.38a5.44 5.44 0 0 1 4.22 -5.25v-0.47A1.22 1.22 0 0 1 12 3.5ZM9.94 18.97a2.16 2.16 0 0 0 4.13 0"/><path class="ovn-wave" d="M3.84 9.31a8.63 8.63 0 0 1 2.34 -4.6M20.16 9.31a8.63 8.63 0 0 0 -2.34 -4.6"/>',};@@@ -1026,6 +1061,16 @@const act = e.target.closest('[data-row]');if (act) {e.stopPropagation();++ const menu = item.querySelector('.ovn-menu');++ if (act.dataset.row === 'more') {++ const open = menu && menu.hidden;++ v.root.querySelectorAll('.openvibe-notif-item.is-menu').forEach((x) => { x.classList.remove('is-menu'); const m = x.querySelector('.ovn-menu'); if (m) m.hidden = true; });++ if (menu && open) { menu.hidden = false; item.classList.add('is-menu'); const first = menu.querySelector('button'); if (first) first.focus({ preventScroll: true }); }++ return;++ }++ if (menu) { menu.hidden = true; item.classList.remove('is-menu'); }++ if (act.dataset.row === 'go') { if (ids.length) markRead(ids); navigateTo(act.dataset.url); return; }++ if (act.dataset.row === 'settings') { navigateTo('https://openvibe.network/settings#notifications'); return; }if (act.dataset.row === 'dismiss') ids.forEach(x => dismiss(v, x));else if (act.dataset.row === 'read') markRead(ids);else if (act.dataset.row === 'copy') copyText(act.dataset.cmd || '', act);@@@ -1137,7 +1182,10 @@${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"><button type="button" data-row="dismiss" title="Clear this notice" aria-label="Clear this notice">${svg(ICON.close)}</button></div></div>++ ${actsHtml(n)}++ <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${when}</span>${label}</div>++ <div class="row-actions"><button type="button" data-row="more" title="More" aria-label="More for this notice" aria-haspopup="menu">${svg(ICON.more)}</button></div>++ <div class="ovn-menu" role="menu" hidden>${unread ? '<button type="button" role="menuitem" data-row="read">' + svg(ICON.read) + '<span>Mark as read</span></button>' : ''}<button type="button" role="menuitem" data-row="dismiss">${svg(ICON.trash)}<span>Remove this notice</span></button><button type="button" role="menuitem" data-row="settings">${svg(ICON.gear)}<span>Notification settings</span></button></div></div>${thumb ? `<img class="thumb" src="${esc(thumb)}" alt="" loading="lazy">` : ''}</div>@@@ -169,6 +169,37 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data.ovc-v2 .ovt-send:disabled { opacity: .45; cursor: default; }.ovc-v2 .ovt-send--round { display: grid; place-items: center; width: 40px; min-width: 40px; height: 40px; padding: 0; border-radius: 50%; }.ovc-v2 .ovt-send--round svg { width: 19px; height: 19px; }++/* ── 4.248.0, one button system for every box (the owner, 11-14: "How is the centering and offset of icons still such a++ problem?", "give them that more 3D look … the post button … completely customized … an animation"). Design rule++ (openvibe.design/rules): an icon button is a square box, display:grid and place-items:center, its glyph a block of++ a fixed size with no margin, never nudged; a hidden tool is gone (display:none) wherever the box sits. ── */++.ovc-v2 [hidden], .ovt-composer [hidden] { display: none !important; }++.ovc-v2 .ovt-c-tools .ovt-btn { box-sizing: border-box; width: 40px; min-width: 40px; height: 40px; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center;++ color: var(--accent-light, var(--ovt-accent, #60a5fa));++ background: linear-gradient(180deg, color-mix(in srgb, var(--text-primary, #e6edf7) 12%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent));++ box-shadow: inset 0 1px 0 rgba(255,255,255,.16), inset 0 -1px 0 rgba(0,0,0,.3), 0 4px 10px -5px rgba(0,0,0,.6);++ transition: translate .15s ease, box-shadow .15s ease, color .15s ease; }++.ovc-v2 .ovt-c-tools .ovt-btn > svg, .ovc-v2 .ovt-c-tools .ovt-btn > .ovt-ic { display: block; width: 20px; height: 20px; margin: 0; flex: none; }++.ovc-v2 .ovt-c-tools .ovt-btn:hover { translate: 0 -1px; color: var(--text-primary, #e6edf7); box-shadow: inset 0 1px 0 rgba(255,255,255,.22), inset 0 -1px 0 rgba(0,0,0,.3), 0 8px 16px -7px rgba(0,0,0,.65); }++.ovc-v2 .ovt-c-tools .ovt-btn:active { translate: 0 1px; box-shadow: inset 0 2px 5px rgba(0,0,0,.4); }++html[data-theme-mode=light] .ovc-v2 .ovt-c-tools .ovt-btn { background: linear-gradient(180deg, #fff, color-mix(in srgb, var(--text-primary, #0f172a) 6%, #fff)); box-shadow: inset 0 -1px 0 rgba(15,23,42,.08), 0 1px 2px rgba(15,23,42,.12), 0 4px 10px -6px rgba(15,23,42,.25); color: var(--accent, #2563eb); }++/* Send: a button of its own, lit from above, a shine that crosses it on hover, a press you can feel, the arrow lifting. */++.ovc-v2 .ovt-send { position: relative; isolation: isolate; overflow: hidden; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; padding: 0 20px;++ color: #fff; letter-spacing: .01em;++ background: linear-gradient(160deg, color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 72%, #fff), var(--ovt-accent, var(--accent, #3b82f6)) 48%, color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 78%, #000));++ box-shadow: inset 0 1px 0 rgba(255,255,255,.38), inset 0 -2px 0 rgba(0,0,0,.22), 0 10px 22px -10px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 85%, transparent);++ transition: translate .15s ease, scale .12s ease, box-shadow .2s ease, filter .2s ease; }++.ovc-v2 .ovt-send::after { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; background: linear-gradient(110deg, transparent 30%, rgba(255,255,255,.38) 50%, transparent 70%); translate: -130% 0; transition: translate .7s cubic-bezier(.2,.8,.2,1); }++.ovc-v2 .ovt-send:not(:disabled):hover { translate: 0 -1px; box-shadow: inset 0 1px 0 rgba(255,255,255,.45), inset 0 -2px 0 rgba(0,0,0,.22), 0 14px 26px -10px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 95%, transparent); }++.ovc-v2 .ovt-send:not(:disabled):hover::after { translate: 130% 0; }++.ovc-v2 .ovt-send:not(:disabled):hover svg { translate: 0 -2px; }++.ovc-v2 .ovt-send svg { transition: translate .2s cubic-bezier(.3,1.6,.4,1); }++.ovc-v2 .ovt-send:not(:disabled):active { translate: 0 1px; scale: .97; transform: none; box-shadow: inset 0 2px 6px rgba(0,0,0,.3); }++.ovc-v2 .ovt-send:disabled { opacity: 1; filter: saturate(.25) brightness(.72); box-shadow: inset 0 1px 0 rgba(255,255,255,.14); cursor: default; }++.ovc-v2 .ovt-send--round { width: 40px; min-width: 40px; height: 40px; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center; }++.ovc-v2 .ovt-send--round svg { display: block; width: 18px; height: 18px; margin: 0; }++.ovc-v2[data-preset="chat"] .ovt-c-row { margin-top: 0; } /* one line: the buttons sit in its middle, not 2 px low */++html[data-ov-motion=off] .ovc-v2 .ovt-send::after { display: none; } html[data-ov-motion=off] .ovc-v2 :is(.ovt-send, .ovt-btn, .ovt-send svg) { transition: none; translate: none; }.ovc-cam { padding: 0; border: 0; border-radius: 22px; width: min(560px, 96vw); max-height: 94vh; overflow: hidden; background: #05070c; color: #fff; display: flex; flex-direction: column; }.ovc-cam::backdrop { background: rgba(2, 6, 14, .72); }.ovc-cam-stage { position: relative; flex: 1; min-height: 0; background: #000; }@@@ -443,7 +474,7 @@ html[data-ov-motion=off] .ovc-tray { animation: none; transition: none; }/* 4.236.0 (the owner, 11-13: "improve the send button … the icon's all screwed up"): with nothing to send it is a quietoutline round its arrow, not a grey disc; the moment there is, it fills with the accent on a spring, and the arrowlifts under a pointer and on a press. */--.ovc-v2 .ovt-send--round:disabled { opacity: 1; background: transparent; color: var(--text-muted, #7386a3); box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--text-primary, #e6edf7) 16%, transparent); }++.ovc-v2 .ovt-send--round:disabled { opacity: 1; filter: none; background: linear-gradient(180deg, color-mix(in srgb, var(--text-primary, #e6edf7) 12%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent)); color: var(--text-muted, #7386a3); box-shadow: inset 0 1px 0 rgba(255,255,255,.14), inset 0 -1px 0 rgba(0,0,0,.3), 0 4px 10px -5px rgba(0,0,0,.6); } /* 4.248.0: a dim disk like its neighbours, never an outline */.ovc-v2 .ovt-send--round:not(:disabled) { animation: ovcSendReady .32s cubic-bezier(.34, 1.56, .64, 1); }@keyframes ovcSendReady { from { transform: scale(.8); } }@media (hover: hover) and (pointer: fine) { .ovc-v2 .ovt-send--round:not(:disabled):hover svg { transform: translateY(-2px); } }@@@ -190,16 +190,16 @@ html.ov-sel-locked, html.ov-sel-locked body { overflow: hidden; overscroll-behavborder-color: color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent);box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent);}--:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"], input[type="radio"]):where(:not([data-ov-native], [data-ov-native] *, :disabled)):active { transform: scale(.88); }++:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"], input[type="radio"]):where(:not([data-ov-native], [data-ov-native] *, :disabled)):active { scale: .88; }:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"]:not([role="switch"]), input[type="radio"]):where(:not([data-ov-native], [data-ov-native] *)):focus-visible {box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 28%, transparent);}:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"]:not([role="switch"]), input[type="radio"]):where(:not([data-ov-native], [data-ov-native] *)):disabled { opacity: .5; cursor: not-allowed; }/* Buttons answer a press; text fields glow with focus (a site's own button or field rule wins over these). */:where(html:not([data-ov-forms="off"])) :where(button, input[type="button"], input[type="submit"], input[type="reset"], a.btn, .btn, [role="button"]):where(:not([data-ov-native], [data-ov-native] *, :disabled)) {-- transition: transform .12s ease, box-shadow .18s ease, background-color .16s ease, border-color .16s ease, color .16s ease;++ transition: scale .12s ease, box-shadow .18s ease, background-color .16s ease, border-color .16s ease, color .16s ease;}--:where(html:not([data-ov-forms="off"])) :where(button, input[type="button"], input[type="submit"], input[type="reset"], a.btn, .btn, [role="button"]):where(:not([data-ov-native], [data-ov-native] *, :disabled)):active { transform: scale(.97); }++:where(html:not([data-ov-forms="off"])) :where(button, input[type="button"], input[type="submit"], input[type="reset"], a.btn, .btn, [role="button"]):where(:not([data-ov-native], [data-ov-native] *, :disabled)):active { scale: .97; } /* `scale`, not `transform`: a button placed with a transform (centered, slid in) keeps its place while pressed (4.248.0; the owner, 11-14: Live's Chat button dropped 19 px and lost the click) */:where(html:not([data-ov-forms="off"])) :where(input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"], [type="color"], [type="hidden"], [type="button"], [type="submit"], [type="reset"]), textarea, select):where(:not([data-ov-native], [data-ov-native] *)) {transition: border-color .15s ease, box-shadow .2s ease, background-color .15s ease;}@@@ -239,7 +239,7 @@ html.ov-sel-locked, html.ov-sel-locked body { overflow: hidden; overscroll-behavhtml[data-ov-motion="calm"] :where(input[type="checkbox"], input[type="radio"], button, .btn)::before { transition-duration: .32s; }html[data-ov-motion="off"] :where(input[type="checkbox"], input[type="radio"], button, input[type="button"], input[type="submit"], .btn, [role="button"], input, textarea, select),html[data-ov-motion="off"] :where(input[type="checkbox"], input[type="radio"])::before { transition: none !important; animation: none !important; }--html[data-ov-motion="off"] :where(input[type="checkbox"], input[type="radio"], button, .btn, [role="button"]):active { transform: none !important; }++html[data-ov-motion="off"] :where(input[type="checkbox"], input[type="radio"], button, .btn, [role="button"]):active { scale: none !important; }:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"][role="switch"]:disabled) { opacity: .5; cursor: not-allowed; }:where(html:not([data-ov-forms="off"])) :where(input[type="file"]:not([data-ov-native]))::file-selector-button {font: inherit; font-weight: 600; margin-inline-end: .75em; padding: .38em .85em; cursor: pointer;@@@ -1691,17 +1691,25 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }player.observe(box);}const glyph = (name) => { const g = svgIcon(name); g.setAttribute('class', 'ovfd-gl'); return g; };++ // A moment in the reader's own zone and clock (theme-loader's time, 4.248.0; the owner, 11-14: "show the time based on the++ // users computer by default or configurable setting"). Without the loader: this device's.++ const tf = (v, kind) => {++ const L = root.OpenVibeThemeLoader;++ if (L && L.time && typeof L.time.format === 'function') return L.time.format(v, kind);++ const d = new Date(typeof v === 'string' ? Date.parse(v) : v); if (!Number.isFinite(d.getTime())) return '';++ return kind === 'time' ? d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : d.toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });++ };const when = (iso) => {const t = Date.parse(iso || ''); if (!t) return '';-- const d = new Date(t);-- return d.toDateString() === new Date().toDateString() ? ago(iso) : d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });++ // Today: how long ago; before that, the day and the time (it used to print a bare time, as if it were today's).++ return Date.now() - t < 12 * 3600000 ? ago(iso) : tf(t, 'day');};/** "Starts 2026-11-12 18:00 UTC" (Today's line when an event has no description) in the reader's own time. */const startsLocal = (text) => {const m = /^Starts (\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}) UTC$/.exec(String(text || ''));if (!m) return text;const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]));-- return `Starts ${d.toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short' })}, ${d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}`;++ return `Starts ${tf(d.getTime(), 'day')}`;};const tidy = (v) => String(v || '').replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim();const cap = (v) => (v ? v.charAt(0).toUpperCase() + v.slice(1) : v);@@@ -1886,6 +1894,8 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }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);const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = thumb; box.append(img);++ // A picture that does not load leaves no empty frame (11-14, the owner: "broken images").++ img.onerror = () => { box.classList.add(kind === 'clip' || kind === 'video' || kind === 'live' ? 'is-empty' : 'is-broken'); };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); }@@@ -1922,10 +1932,13 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }function statOf(c) {const n = c.counts && Number(c.counts.people);const t = c.trend && typeof c.trend === 'object' ? c.trend : null;-- if (!Number.isFinite(n) && !t) return null;++ if (!Number.isFinite(n) && !t && !(c.stat && typeof c.stat === 'object')) return null;const box = h('span', 'ovfd-stat');if (Number.isFinite(n)) box.append(h('b', 'ovfd-stat-n', num(n)));-- if (c.text) box.append(h('small', 'ovfd-stat-l', String(c.text).slice(0, 120)));++ // feed.item@1 1.2.0 stat.at: when it happened, in the reader's time (the words no longer carry "01:10 UTC").++ const st = c.stat && typeof c.stat === 'object' ? c.stat : null;++ const at = st && Date.parse(String(st.at || ''));++ if (c.text || at) box.append(h('small', 'ovfd-stat-l', [String(c.text || '').slice(0, 120), at ? `at ${tf(at, 'time')}` : ''].filter(Boolean).join(', ')));if (t && ['up', 'down', 'flat'].includes(t.direction)) {const pct = Number.isFinite(Number(t.pct)) ? `${Math.round(Number(t.pct))}%` : '';const was = Number.isFinite(Number(t.previous)) ? `was ${num(Number(t.previous))}` : '';@@@ -1934,6 +1947,13 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }box.append(pill);if (was) box.append(h('small', 'ovfd-stat-was', was));}++ // Today's side numbers (stat.facts; the owner, 11-14: "add total users per day or other cool metrics").++ const facts = st && Array.isArray(st.facts) ? st.facts.filter((x) => x && Number.isFinite(Number(x.n)) && x.label).slice(0, 6) : [];++ if (facts.length) {++ const row = h('span', 'ovfd-facts');++ facts.forEach((x) => { const f = h('span', 'ovfd-fact'); f.append(h('b', '', num(Number(x.n))), document.createTextNode(` ${String(x.label).slice(0, 40)}`)); row.append(f); });++ box.append(row);++ }return box;}/** A folded run of the same kind (new jobs, new deals): its first three, each to its own page. */@@@ -1950,7 +1970,10 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }const row = h('span', 'ovfd-group-i');const th = pic(x);// Rows line up: where one has a picture, the others hold its place (a deal's: its store's first letter).-- if (th) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = th; row.append(img); }++ if (th) {++ const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = th; row.append(img);++ img.onerror = () => { const ph = h('i', `ovfd-group-ph${isDeal ? ' is-deal' : ''}`); if (isDeal) ph.append(glyph('tag')); img.classList.add('is-broken'); img.before(ph); };++ }else if (isDeal) { const ph = h('i', 'ovfd-group-ph is-deal'); ph.append(glyph('tag')); row.append(ph); }else if (anyPic) row.append(h('i', 'ovfd-group-ph'));const t = h('span', 'ovfd-group-t'); t.append(h('span', 'ovfd-group-n', String(x.title || '').replace(/\s+[—-]\s+[^—-]{2,40}$/, '').slice(0, 120)));@@@ -2156,6 +2179,10 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }.ovfd-stat{display:grid;grid-template-columns:auto 1fr;align-items:baseline;column-gap:10px;row-gap:2px;padding:4px 2px}.ovfd-stat-n{font-family:'OV Brand',Nunito,Inter,sans-serif;font-size:34px;font-weight:800;line-height:1.05;font-variant-numeric:tabular-nums;letter-spacing:-.01em}.ovfd-stat-l{font-size:13px;color:var(--text-secondary,#a8b3c4);align-self:center}++.ovfd-facts{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}++.ovfd-fact{display:inline-flex;align-items:baseline;gap:4px;padding:4px 10px;border-radius:999px;font-size:12.5px;color:var(--text-secondary,#a8b3c4);background:color-mix(in srgb,var(--text-primary,#e8ecf3) 6%,transparent)}++.ovfd-fact b{color:var(--text-primary,#e8ecf3);font-weight:750;font-variant-numeric:tabular-nums}++.ovfd-media.is-empty{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 6%,transparent)}.ovfd-media.is-empty img{visibility:hidden}.ovfd-media.is-broken{display:none}.ovfd-group-i img.is-broken{display:none}.ovfd-trend{grid-column:1;justify-self:start;font-size:12.5px;font-weight:750;padding:1px 8px;border-radius:999px;font-variant-numeric:tabular-nums}.ovfd-trend.is-up{color:#34d399;background:color-mix(in srgb,#10b981 16%,transparent)}.ovfd-trend.is-down{color:#f87171;background:color-mix(in srgb,#ef4444 14%,transparent)}.ovfd-trend.is-flat{color:var(--text-secondary,#a8b3c4);background:color-mix(in srgb,var(--text-primary,#e8ecf3) 8%,transparent)}.ovfd-stat-was{font-size:12px;color:var(--text-muted,#93a0b4);align-self:center}@@@ -38,6 +38,7 @@+ '.ovlk-icons{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 6px 6px 10px;border-radius:12px;background:var(--bg-tertiary,rgba(255,255,255,.05))}'+ '.ovlk-icons>b{color:var(--text-primary,#e6edf7);font-weight:650}'+ '.ovlk-seg{display:inline-flex;gap:2px;padding:2px;border-radius:999px;background:var(--bg-primary,rgba(0,0,0,.25))}'++ + '.ovlk-tz{min-width:0;max-width:62%;padding:6px 10px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.12));background:var(--bg-primary,rgba(0,0,0,.25));color:var(--text-primary,#e6edf7);font:inherit;font-size:.86rem}'+ '.ovlk .ovlk-seg button{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;gap:5px;min-height:30px;padding:0 10px;border-radius:999px;cursor:pointer;font-size:12.5px;color:var(--text-secondary,#a8b3c4)}'+ '.ovlk .ovlk-seg button[aria-checked=true]{background:var(--accent-strong,var(--accent,#3472d8));color:var(--on-accent-strong,#fff)}'+ '.ovlk .ovlk-seg button:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:1px}'@@@ -109,6 +110,18 @@panel.append(row('motion', 'Motion', [['auto', 'Auto'], ['full', 'Full'], ['reduced', 'Calm'], ['off', 'Off']]));// Windows (4.202.0, asks 304 and 318): every window at once (ov-window.js), glass, solid or outlined.panel.append(row('window', 'Windows', [['glass', 'Glass'], ['solid', 'Solid'], ['outline', 'Outline']]));++ // Time (4.248.0; the owner, 11-14): every time on every site in this zone and clock. Auto is this device's.++ if (L.time && L.display.options.clock) {++ panel.append(row('clock', 'Clock', [['auto', 'Auto'], ['12', '12-hour'], ['24', '24-hour']]));++ const dev = L.time.device();++ const cur = d.tz && d.tz !== 'auto' ? d.tz : 'auto';++ const sel = el('select', { class: 'ovlk-tz', 'data-look-tz': '', 'aria-label': 'Time zone' }, [el('option', { value: 'auto' }, [`Auto (${dev.replace(/_/g, ' ')})`])]);++ const zones = L.time.zones();++ if (cur !== 'auto' && zones.indexOf(cur) < 0) zones.unshift(cur);++ zones.forEach((z) => sel.append(el('option', { value: z }, [z.replace(/_/g, ' ')])));++ sel.value = cur;++ panel.append(el('div', { class: 'ovlk-icons' }, [el('b', {}, ['Time zone']), sel]));++ }}const signedIn = opts.signedIn != null ? !!opts.signedIn : /(?:^|; )ov_sso_hint=account/.test(document.cookie || '') || !!(root.localStorage && (() => { try { return root.localStorage.getItem('ov_token'); } catch (e) { return null; } })());const named = showing ? showing.name : ((PICKS.find(([id]) => id === cur) || [0, cur])[1]);@@@ -161,7 +174,11 @@else L.save(id);draw(box, opts);});-- ['ov:theme', 'ov:display'].forEach((ev) => root.addEventListener(ev, () => { if (box.isConnected) draw(box, opts); }));++ box.addEventListener('change', (e) => {++ const L = root.OpenVibeThemeLoader; const t = e.target;++ if (L && t && t.matches && t.matches('[data-look-tz]')) { e.stopPropagation(); L.display.set({ tz: t.value || 'auto' }); }++ });++ ['ov:theme', 'ov:display'].forEach((ev) => root.addEventListener(ev, () => { if (box.isConnected && !(document.activeElement && document.activeElement.matches && document.activeElement.matches('[data-look-tz]'))) draw(box, opts); }));}root.OVLook = { mount, PICKS, shareLink };@@@ -318,8 +318,15 @@const top = el('span', 'ovr-agent-top'); top.append(el('b', '', agentName()), el('i', 'ovr-agent-tag', 'your agent'));t.append(top, el('span', 'ovr-agent-line', [AGENT_WORD[st], step, agent ? `${money(now.cost_today)}${cap} today` : ''].filter(Boolean).join(' · ')));who.append(agentFace('m', st), t);-- who.addEventListener('click', () => list('task'));++ who.addEventListener('click', () => list('task', { history: true }));card.append(who);++ // 4.248.0 (the owner, 11-14: "move this history … to a history button in the corner"): every conversation, task++ // and code run behind one button; the view itself is the conversation.++ const hist = el('button', `ovr-agent-go ovr-agent-hist${state.view === 'list' ? ' is-on' : ''}`); hist.type = 'button';++ hist.setAttribute('aria-label', 'History: your conversations, tasks and code'); hist.title = 'History';++ hist.append(svg('clock'));++ hist.addEventListener('click', () => { if (state.view === 'list') list('all'); else list(state.filter, { history: true }); }); // pressed again: back to the conversation++ card.append(hist);if (agent && agent.id) {const paused = st === 'paused';const go = el('button', `ovr-agent-go${paused ? ' is-main' : ''}`); go.type = 'button';@@@ -411,8 +418,40 @@function typing() { const t = el('div', 'ovr-typing'); t.setAttribute('aria-label', `${agentName()} is writing`); t.append(el('i'), el('i'), el('i')); return t; }++ // ── the home head (4.248.0; the owner, 11-14: the filters "need to be above the actor … the actor … a fixed thing++ // that's on top, kind of like how Muse is") ──++ function homeHead(where) {++ const wrap = el('div', 'ovr-home');++ const head = el('div', 'ovr-head');++ const tabs = el('div', 'ovr-filters'); tabs.setAttribute('role', 'tablist'); tabs.setAttribute('aria-label', 'Show');++ for (const [id, label, ic] of FILTERS) {++ const b = el('button', 'ovr-filter'); b.type = 'button'; b.setAttribute('role', 'tab'); b.dataset.filter = id;++ b.append(svg(ic), el('span', '', label));++ const on = id === state.filter; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1;++ b.addEventListener('click', () => list(id, { fromTab: true }));++ tabs.append(b);++ }++ tabs.addEventListener('keydown', (e) => {++ if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;++ const all = [...tabs.children]; 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();++ });++ head.append(tabs);++ wrap.append(head);++ const me = el('div', 'ovr-agent-slot'); me.hidden = true;++ if (o.agent) { wrap.append(me); paintAgent(me); loadAgent().then(() => { if (me.isConnected) paintAgent(me); }); }++ where.append(wrap);++ return wrap;++ }++ /** All, for your agent: the conversation itself (your latest chat with it), not a list; the list is History. */++ function latestChat(runs) {++ const day = Date.now() - 36 * 3600e3;++ return runs.find((r) => r && r.kind === 'chat' && Date.parse(r.updated_at || r.created_at || '') > day && !needsPerson(r)) || null;++ }++// ── the list ──-- async function list(filter) {++ async function list(filter, how = {}) {if (filter && FILTER_IDS.includes(filter)) state.filter = filter;clearStream();const gen = ++state.gen;@@@ -420,28 +459,12 @@if (state.view !== 'list' || !box.querySelector('.ovr-rows')) {state.view = 'list'; state.run = null;box.textContent = ''; box.className = 'ovr is-list';-- const head = el('div', 'ovr-head');-- const tabs = el('div', 'ovr-filters'); tabs.setAttribute('role', 'tablist'); tabs.setAttribute('aria-label', 'Show');-- for (const [id, label, ic] of FILTERS) {-- const b = el('button', 'ovr-filter'); b.type = 'button'; b.setAttribute('role', 'tab'); b.dataset.filter = id;-- b.append(svg(ic), el('span', '', label));-- b.addEventListener('click', () => { if (state.filter !== id || state.view !== 'list') list(id); });-- tabs.append(b);-- }-- tabs.addEventListener('keydown', (e) => {-- if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;-- const all = [...tabs.children]; 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();-- });-- head.append(tabs);-- const me = el('div', 'ovr-agent-slot'); me.hidden = true;-- if (o.agent) { box.append(me); paintAgent(me); loadAgent().then(() => { if (me.isConnected) paintAgent(me); }); }++ homeHead(box);const rows = el('ul', 'ovr-rows'); rows.setAttribute('aria-label', 'Your runs');const foot = el('div', 'ovr-list-note');const top = el('div', 'ovr-top');const pane = el('div', 'ovr-pane'); pane.hidden = true;-- box.append(head, top, rows, foot, pane);++ box.append(top, rows, foot, pane);dropTop(); dropPane();if (Array.isArray(o.starters) && o.starters.length) {const st = el('div', 'ovr-starters'); st.setAttribute('role', 'group'); st.setAttribute('aria-label', 'Things to ask');@@@ -457,6 +480,7 @@}place({ filter: state.filter });box.querySelectorAll('.ovr-filter').forEach((b) => { const on = b.dataset.filter === state.filter; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });++ { const h = box.querySelector('.ovr-agent-hist'); if (h) h.classList.add('is-on'); }// A filter the host draws itself (a pane): it takes the place of the rows, the starters and the box. The host's// pieces may share one owner (the Dock lends one panel at a time), so the old one goes before the new one comes.const pf = paneFor(state.filter);@@@ -493,6 +517,12 @@const needs = (r) => (needsPerson(r) ? 1 : 0);state.runs = state.runs.concat(extra).sort((x, y) => needs(y) - needs(x) || String(y.updated_at || '').localeCompare(String(x.updated_at || '')));}++ // All, for your agent, is the conversation (Muse, dots): today's chat opens in place; History shows the list.++ // Anything waiting on you (an approval, a question) keeps the list first, where it can be answered in place.++ if (o.agent && state.filter === 'all' && !how.history && !how.stay && !state.runs.some(needsPerson)) {++ const last = latestChat(state.runs);++ if (last) { open(last.ref || last.id, last, { home: true }); return; }++ }rows.textContent = ''; rows.removeAttribute('aria-busy');// 4.242.0: how many are working right now (a coding agent's "2 running tasks"), above the rows.{ const busy = state.runs.filter((r) => r.status === 'running' || r.status === 'planning').length; let pill = box.querySelector('.ovr-busy');@@@ -553,7 +583,7 @@const gen = ++state.gen;state.view = 'thread'; state.run = known || null; state.steps = new Map();place({ ref });-- box.textContent = ''; box.className = 'ovr is-thread';++ box.textContent = ''; box.className = `ovr is-thread${how.home ? ' is-home' : ''}`;const head = el('div', 'ovr-thead');const back = el('button', 'ovr-ib'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your runs'); back.title = 'Your runs'; back.append(svg('back'));back.addEventListener('click', () => list());@@@ -563,7 +593,7 @@const faceSlot = el('span', 'ovr-face-slot');head.append(back, faceSlot, who, away);const thread = el('div', 'ovr-thread'); thread.setAttribute('role', 'log'); thread.setAttribute('aria-live', 'polite');-- box.append(head, thread);++ if (how.home && o.agent) { homeHead(box); box.append(thread); } else box.append(head, thread);const paintHead = (r) => {state.run = r;faceSlot.textContent = ''; faceSlot.append(face(r, 's'));@@@ -591,7 +621,7 @@const next = queue.shift();sendNow(next.text, next.node).catch((err) => { next.node.classList.add('is-failed'); next.node.querySelector('.ovr-queued').textContent = (err && err.message) || 'That did not send'; });};-- composer(box, { placeholder: 'Say the next thing', onSend: async (v) => {++ composer(box, { placeholder: how.home ? `Message ${agentName()}` : 'Say the next thing', onSend: async (v) => {if (state.run && ['running', 'planning'].includes(state.run.status)) {const q = el('div', 'ovr-step is-you is-queued'); const b = el('div', 'ovr-bubble'); b.append(document.createTextNode(v.text)); q.append(b, el('small', 'ovr-queued', 'Queued: it reads this after the step it is on'));thread.append(q); queue.push({ text: v.text, node: q });@@@ -892,6 +922,15 @@ a.ovr-row { color: inherit; text-decoration: none; }.ovr-filter[aria-selected="true"] { color: var(--accent-light, #93c5fd) !important; border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent) !important; }.ovr-filter:active { transform: scale(.94); }.ovr-agent-slot { flex: none; width: 100%; max-width: 780px; margin-inline: auto; box-sizing: border-box; }++/* 4.248.0: the filters, then your agent, fixed at the top of Vibe while the conversation scrolls under them. */++.ovr-home { position: sticky; top: 0; z-index: 4; flex: none; display: grid; gap: 6px; width: 100%; max-width: 780px; margin-inline: auto; padding: 2px 0 8px; box-sizing: border-box;++ background: linear-gradient(to bottom, var(--ovs-sheet-bg, var(--bg-primary, #0b1220)) 82%, transparent); }++.ovr-home .ovr-head { padding: 0 2px; }++.ovr-home .ovr-agent { padding: 6px 6px 6px 4px; border-radius: 18px; }++.ovr-home .ovr-agent-who { min-height: 46px; gap: 10px; }++.ovr-home .ovr-face.is-m { width: 40px; height: 40px; border-radius: 50%; }++.ovr-agent-hist.is-on { background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); color: var(--text-primary, #e6edf7); }++.ovr.is-home > .ovr-thread { padding-top: 4px; width: 100%; max-width: 780px; margin-inline: auto; box-sizing: border-box; }.ovr-agent-slot[hidden] { display: none; }.ovr-agent { display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 6px; border-radius: 20px; background: linear-gradient(135deg, color-mix(in srgb, hsl(var(--ovr-ah, 220) 70% 55%) 14%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 3%, transparent)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); animation: ovr-rise .32s cubic-bezier(.2,.9,.3,1.1) both; }.ovr-agent-who { all: unset; box-sizing: border-box; flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 2px 6px; border-radius: 14px; cursor: pointer; }@@@ -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': '9b2a27f70f5f', 'ov-combo.js': '8001a3b19c2f', 'player.js': '8f4b6892e572', 'player.css': '699a7c4927d5' };++ const THREAD_REV = { 'ov-composer.js': 'c29a98f058e6', 'ov-combo.js': '8001a3b19c2f', 'player.js': '8f4b6892e572', 'player.css': '699a7c4927d5' };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,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.247.0",++ "version": "4.248.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -226,7 +226,7 @@.ovs-fly-part--count { width: auto !important; height: auto !important; }.ovs-fly-count { display: inline-grid; place-items: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: #ef4444; color: #fff; font: 800 11px/1 system-ui, sans-serif; }.ovs-panel { position: absolute; left: 50%; top: 8vh; width: min(720px, calc(100vw - 32px)); max-height: 80vh; transform: translateX(-50%); display: flex; flex-direction: column; border-radius: 18px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-secondary, #121826); box-shadow: 0 30px 80px rgba(0,0,0,.55); overflow: hidden; }--.ovs-bar { display: flex; align-items: center; gap: 10px; margin: 10px 10px 6px; padding: 0 6px 0 14px; border-radius: 999px; background: var(--bg-primary, rgba(0,0,0,.25)); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.12)); transition: box-shadow .2s, background .2s; }++.ovs-bar { display: flex; align-items: center; gap: 10px; margin: 10px 10px 6px; padding: 0 6px 0 14px; border-radius: calc(var(--ov-radius-2, 14px) + 2px); background: var(--bg-primary, rgba(0,0,0,.25)); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.12)); transition: box-shadow .2s, background .2s; }.ovs-bar:focus-within { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }/* A site's own focus ring (the orange box on openvibe.download, 10-29) never draws on the field: the pill shows focus. */.ovs .ovs-input, .ovs .ovs-input:focus, .ovs .ovs-input:focus-visible { outline: none !important; box-shadow: none !important; border: 0 !important; background: transparent !important; }@@@ -672,7 +672,14 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }@keyframes ovs-orb-in { from { opacity: 0; transform: scale(.45) rotate(-90deg); } }@keyframes ovs-orb-out { to { opacity: 0; transform: scale(.5) rotate(90deg); } }.ovs.ovs-anchored .ovs-orb svg { display: block; overflow: visible; width: 24px; height: 24px; }-- .ovs.ovs-anchored .ovs-orb.is-x { background: var(--bg-secondary, #121826); box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 14%, transparent), 0 8px 22px -8px rgba(0, 0, 0, .7); }++ .ovs.ovs-anchored .ovs-orb.is-x { background: var(--bg-primary, rgba(0,0,0,.25)); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.12)), 0 8px 22px -10px rgba(0, 0, 0, .7); }++ /* 4.248.0 (the owner, 11-14: "the top thing … needs to be less round … make everything more consistent"): the field and++ its close share the kit's radius scale (--ov-radius-2 + 2, the tabs' own), so the close is a rounded square beside it. */++ .ovs.ovs-anchored .ovs-orb.is-x { border-radius: calc(var(--ov-radius-2, 14px) + 2px); }++ .ovs.ovs-anchored .ovs-orb.is-x::after { border-radius: inherit; }++ /* 4.248.0: as tall as the search field beside it, centred on it (placeOrb measures both). */++ .ovs.ovs-anchored .ovs-orb.is-x { width: var(--orb-size, 48px); height: var(--orb-size, 48px); transition: transform .42s cubic-bezier(.2, 1.1, .3, 1), background-color .3s, box-shadow .3s, top .25s ease, left .25s ease; }++ .ovs.ovs-anchored .ovs-orb.is-x:hover { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 55%, var(--border, rgba(255,255,255,.12))), 0 8px 22px -10px rgba(0, 0, 0, .7); }.ovs.ovs-anchored .ovs-orb.is-x::before { display: none; }.ovs.ovs-anchored .ovs-orb { width: 48px; height: 48px; }.ovs.ovs-anchored .ovs-orb:focus { outline: none; }@@@ -1040,7 +1047,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }if (g.as === 'card') {const body = document.createElement('div'); body.className = 'ovs-cardbody';try {-- g.card.render(body, { close, here: here(), user: ctx.user || null, signedIn: !!(ctx.token && ctx.user && !ctx.user.is_anon) });++ g.card.render(body, { close, here: here(), user: ctx.user || null, signedIn: !!(ctx.user && !ctx.user.is_anon) });} catch { return; }if (!body.childNodes.length) return;if (g.title) { const h = document.createElement('li'); h.className = 'ovs-group'; h.setAttribute('role', 'presentation'); h.setAttribute('data-sec', g.section); h.textContent = g.title; listEl.appendChild(h); }@@@ -1713,7 +1720,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }try { return root.OpenVibeDock && root.OpenVibeDock.mountInto ? root.OpenVibeDock.mountInto(slot, how) : null; } catch { return null; }}function mountRuns() {-- const me = ctx.token && ctx.user && !ctx.user.is_anon ? ctx.user : null;++ const me = ctx.user && !ctx.user.is_anon ? ctx.user : null;// Runs and the Action Center are the person's Network places, like the Dock's (navbar.js passes its base).const net = ctx.runsBase || ctx.apiBase || 'https://openvibe.network';const D = () => root.OpenVibeDock;@@@ -1724,6 +1731,8 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }// Dock's Vibe, Agents and Code panels are no longer lent here.actions: { base: net }, starters: VIBE_STARTERS, agent: true,// Above the list: Now (timers, what is on) and the live view of an agent at work, from the Dock's agent top.++ // Above the list: what is happening now (a timer, an agent at work, what needs you), quiet otherwise (4.248.0: no++ // idle line, no "Nothing needs you", no timeline folds; the owner, 11-14).top: (slot) => lend(slot, { tab: 'agent', part: 'live' }),onPlace: (p) => { if (p.filter) runFilter = p.filter; },};@@@ -2073,14 +2082,16 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }// 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: [] });// §Z.2 (4.247.0): a guest sees first what signing in adds, with the bar's own Sign in link.-- if (!(ctx.token && ctx.user && !ctx.user.is_anon)) groups.push({ as: 'signin', items: [], section: 'signin' });++ // Signed in is the person the bar shows, not a token: a site that signs in by its own session cookie (Download) has++ // no token in the page, and the menu said "Sign in to make OpenVibe yours" under their avatar (11-14).++ if (!(ctx.user && !ctx.user.is_anon)) groups.push({ as: 'signin', items: [], section: 'signin' });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' });++ if (!(ctx.user && !ctx.user.is_anon) && typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });return groups;}-- if (ctx.token && ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });++ if (ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });const yours = mine && mine.services.some((x) => x.count > 0);// Without their own history, developer services are not places to visit: they come up here once used.const dockPool = yours ? ranked : ranked.filter((x) => x.group !== 'build' || useOf(x.id));@@@ -2124,7 +2135,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }function homeOpts() {const { all, ranked, f } = basis();const apps = ranked.map((x) => ({ id: x.id, title: x.name, url: x.url, site: x.icon || x.id, group: x.group })).filter((x) => x.url);-- const yours = ctx.token && ctx.user && !ctx.user.is_anon ? youItems().filter((y) => y.title !== 'Agents').map((y) => ({ id: `you:${slugOf(y.title)}`, title: y.title, url: y.url, site: y.site, group: 'you' })) : [];++ const yours = ctx.user && !ctx.user.is_anon ? youItems().filter((y) => y.title !== 'Agents').map((y) => ({ id: `you:${slugOf(y.title)}`, title: y.title, url: y.url, site: y.site, group: 'you' })) : [];const tools = catalog ? catalog.tools.map((t) => [toolUse(t), t]).sort((a, b) => b[0] - a[0]).map(([, t]) => ({ id: `tool:${slugOf(t.name)}`, title: t.name, url: t.go || t.url, site: t.icon || 'tools' })) : [];const seen = new Set(), recentIds = [];for (const h of (mine ? mine.items : [])) {@@@ -2369,7 +2380,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }// - the menu's own circle grows from the button while it flies (it was cut short before), and the close orb turns// in once the button has left its place.// Motion Off skips it all; Calm plays the same path short, with no trail, tilt or glow (Calm reduces, only Off removes).-- let flyFrom = null, flyIdle = null;++ let flyFrom = null, flyIdle = null, fromTab = null;const motionLevel = () => (typeof document !== 'undefined' && document.documentElement.getAttribute('data-ov-motion')) || '';function tabFace(id) {const b = tabsEl && tabsEl.querySelector(`.ovs-tab[data-tab="${id}"], .ovs-tab[data-dock="${id}"]`);@@@ -2498,6 +2509,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }const asked = { here: 'here', sites: 'here', feed: 'feed', you: 'you', tools: 'you' }[opts.tab];// The menu opens on this site's tab unless a tab was asked (the picture asks You): no tab is remembered (4.189.0).tab = asked || 'here';++ fromTab = tab; // what the opener opened: only that tab flies back into itallOpen = false; hereOpen = true; leadHere = false; discoverPick = null;drainCards();syncFeeds();@@@ -2536,6 +2548,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }const bar = el.querySelector('.ovs-bar'), br = bar ? bar.getBoundingClientRect() : null;const tx = br ? br.right + 6 + 24 : innerWidth - 24, ty = br ? br.top + br.height / 2 : 28;orbEl.style.setProperty('--orb-x', `${Math.round(tx - 24)}px`); orbEl.style.setProperty('--orb-y', `${Math.round(ty - 24)}px`);++ settleOrb();if (home) { orbEl.style.setProperty('--orb-dx', `${Math.round(home.cx - tx)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(home.cy - ty)}px`); }// The button has flown to its tab, or no menu button is on screen: the close turns in at the pill.if (flyFrom || !home) orbEl.classList.add('is-x', 'is-pop');@@@ -2589,7 +2602,9 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }// on a phone, the orb on a computer). Measured each time, never remembered: the bar may have scrolled away or come// back since, and on a phone the computer's orb is hidden (zero size). Null when none is on screen.function barButtonCentre() {-- const list = [ctx && ctx.from, document.getElementById('openvibe-burger'), document.getElementById('openvibe-search-btn')];++ // The orb is the menu button turned into the close: it goes home into the menu button, never into the picture or++ // another opener (11-14, the owner: still went into the avatar).++ const list = [document.getElementById('openvibe-burger'), document.getElementById('openvibe-search-btn')];for (const b of list) {if (!b || typeof b.getBoundingClientRect !== 'function' || typeof b.closest !== 'function' || !b.closest('.openvibe-navbar')) continue;const r = b.getBoundingClientRect();@@@ -2598,9 +2613,32 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }}return null;}++ /** The close sits beside the search field, centred on it and as tall as it (4.248.0; the owner, 11-14: "the close++ * button's offset … more consistent with the search input"). Measured again once the sheet has slid in and when the++ * window changes, never only at the first frame (the field was still moving then). */++ let orbWatch = null;++ function placeOrb() {++ if (!orbEl || !el || !el.classList.contains('is-open') || !phoneSheet()) return;++ const bar = el.querySelector('.ovs-bar'); if (!bar) return;++ const r = bar.getBoundingClientRect(); if (!r.height) return;++ const size = Math.max(44, Math.min(56, Math.round(r.height)));++ orbEl.style.setProperty('--orb-size', `${size}px`);++ orbEl.style.setProperty('--orb-x', `${Math.round(Math.min(innerWidth - size - 4, r.right + 8))}px`);++ orbEl.style.setProperty('--orb-y', `${Math.round(r.top + (r.height - size) / 2)}px`);++ }++ function settleOrb() {++ [140, 360, 700].forEach((t) => setTimeout(placeOrb, t));++ if (!orbWatch && typeof window !== 'undefined') {++ orbWatch = () => requestAnimationFrame(placeOrb);++ window.addEventListener('resize', orbWatch, { passive: true });++ if (window.visualViewport) window.visualViewport.addEventListener('resize', orbWatch, { passive: true });++ }++ }function close({ navigating = false } = {}) {// The tab flies back into the bar button that opened it (not when a link was followed: the page is going).-- const backTo = flyFrom; flyFrom = null;++ // Only while the menu still shows what the opener opened (11-14, the owner: "it goes to the user avatar instead of++ // … going to the right icon"): opened from the picture and moved to Community, the close is the menu's own.++ const backTo = flyFrom && !door && tab === fromTab ? flyFrom : null; flyFrom = null; fromTab = null;if (backTo && !navigating && el && el.classList.contains('is-open') && !closing) fly(tabFace(door || tab), backTo, { back: true, toFace: flyIdle });flyIdle = null;if (view === 'results' && rtabsEl) leaveResults();@@@ -2608,7 +2646,9 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }if (!el || closing) return;if (navigating) { pushed = false; markBack(); }// Closed by the person (Close, Escape, a tap outside): take back the entry Back would have used.-- if (pushed && !popping) { pushed = false; try { if (history.state && history.state.ovsMenu) { ownBack = true; history.back(); } } catch { /* */ } }++ // The page stays where it is (11-14, the owner: closing "scrolls the page down"): the browser restores no scroll++ // for this Back, and focus returns without scrolling.++ if (pushed && !popping) { pushed = false; try { if (history.state && history.state.ovsMenu) { ownBack = true; keepScroll(); history.back(); } } catch { /* */ } }// A phone's menu folds back into the orb (its x turns back into dots) before it goes; elsewhere it closes at once.if (phoneSheet() && !calm() && el.classList.contains('is-open')) {el.classList.add('is-closing');@@@ -2641,7 +2681,20 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }if (inflight) { inflight.abort(); inflight = null; }if (ctx && typeof ctx.onClose === 'function') { try { ctx.onClose(); } catch { /* */ } }try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: false } })); } catch { /* */ }-- if (lastFocus && lastFocus.focus) lastFocus.focus();++ if (lastFocus && lastFocus.focus) { try { lastFocus.focus({ preventScroll: true }); } catch { /* */ } }++ }++ /** Back taken by the menu itself: no scroll restored by the browser, and the page put back where it was if anything++ * moved it (the scroll lock's release, a late restore). */++ function keepScroll() {++ try {++ const was = history.scrollRestoration;++ history.scrollRestoration = 'manual';++ const y = Number(document.body && document.body.style.position === 'fixed' ? -parseFloat(document.body.style.top || '0') : root.scrollY) || 0;++ let over = false;++ const done = () => { if (over) return; over = true; root.removeEventListener('popstate', done); setTimeout(() => { try { history.scrollRestoration = was; } catch { /* */ } if (Math.abs((root.scrollY || 0) - y) > 2) root.scrollTo(0, y); }, 0); };++ root.addEventListener('popstate', done);++ setTimeout(done, 600);++ } catch { /* */ }}// On a phone the empty menu keeps focus on its orb to avoid raising the keyboard. Escape must still close it.@@@ -119,8 +119,19 @@ a.sc-orb-node:hover .sc-orb-face>*:first-child,a.sc-orb-node:focus-visible .sc-o@media (min-width:900px){.sc-grid:has(>:nth-child(5):last-child),.sc-grid:has(>:nth-child(6):last-child),.sc-grid:has(>:nth-child(9):last-child){grid-template-columns:repeat(3,minmax(0,1fr))}}.sc-card{display:flex;gap:14px;align-items:flex-start;padding:16px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}--.sc-ic{width:40px;height:40px;flex:none;display:grid;place-items:center;border-radius:12px;font-size:17px;color:var(--accent-light,#60a5fa);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent)}--.sc-ic.sc-ic-ov{background:none;border-radius:50%}++/* 4.248.0 (the owner, 11-14: "redesign all these icons … use the full space … modern and minimalist … stands out++ unique"): a filled tile on its own hue (six, in turn down a grid), the glyph large in it; a ring icon loses its ring. */++.sc-card,.sc-grid>*{--sc-h:var(--accent,#3b82f6)}++.sc-grid>:nth-child(6n+2){--sc-h:#10b981}.sc-grid>:nth-child(6n+3){--sc-h:#f59e0b}.sc-grid>:nth-child(6n+4){--sc-h:#8b5cf6}.sc-grid>:nth-child(6n+5){--sc-h:#ec4899}.sc-grid>:nth-child(6n){--sc-h:#06b6d4}++.sc-ic{width:46px;height:46px;flex:none;display:grid;place-items:center;border-radius:14px;font-size:20px;overflow:hidden;color:color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 28%,#fff);background:linear-gradient(150deg,color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 42%,transparent),color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 16%,transparent));box-shadow:inset 0 1px 0 rgba(255,255,255,.14),0 8px 20px -10px color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 70%,transparent)}++html[data-theme-mode=light] .sc-ic{color:color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 78%,#0b1220);background:linear-gradient(150deg,color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 24%,#fff),color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 10%,#fff))}++.sc-ic.sc-ic-ov{border-radius:14px}++.sc-ic .ovi-bg,.sc-ic .ovi-ring,.sc-ic .ovi-comet,.sc-ic .ovi-prog{display:none}++.sc-ic .sc-ovi{--ovi-size:46px!important;display:grid;place-items:center;width:46px;height:46px;color:inherit}.sc-ic .sc-ovi svg{width:46px;height:46px;transform:scale(1.32)}.sc-ic .ovi-glyph{stroke:currentColor;stroke-width:2.3}++.sc-ic .sc-ovi--glyph{--ovi-size:26px!important;width:26px;height:26px}.sc-ic>i{font-size:21px}++a.sc-card:hover .sc-ic,a.sc-card:focus-visible .sc-ic{scale:1.06}.sc-ic{transition:scale .2s cubic-bezier(.2,.9,.3,1.2)}++/* A phone: the tile beside the title, the words under both, the card's whole width (no empty column under the icon). */++@media (max-width:600px){.sc-grid .sc-card{display:grid;grid-template-columns:auto minmax(0,1fr);column-gap:12px;align-items:center}.sc-grid .sc-card>span:last-child{display:contents}.sc-grid .sc-card b{grid-column:2}.sc-grid .sc-card small{grid-column:1/-1;margin-top:8px}}.sc-ovi--glyph{border-radius:50%;color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 13%,transparent);box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--accent,#3b82f6) 42%,transparent)}.sc-ovi--glyph svg{width:54%!important;height:54%!important}.sc-card b{display:block;font-size:15.5px}.sc-card small{display:block;margin-top:3px;color:var(--text-secondary,#96a7c2);font-size:13.5px;line-height:1.45}.sc-steps{list-style:none;margin:0;padding:0;counter-reset:sc;display:grid;gap:14px 24px;grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}@@@ -189,14 +200,24 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}.sc-spot-media img,.sc-spot-media video{display:block;width:100%;height:auto}.sc-spot h3{margin:0 0 8px;font-size:clamp(20px,2.2vw,26px);letter-spacing:-.01em}.sc-spot h3 a{color:inherit;text-decoration:none}.sc-spot h3 a:hover{color:var(--accent-light,#60a5fa)}.sc-spot p{margin:0;color:var(--text-secondary,#96a7c2);line-height:1.6;font-size:15.5px}.sc-spot-act{margin-top:16px}--.sc-faq{display:grid;gap:8px;max-width:820px}--.sc-faq details,.sc-sec>details:not([class]){border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}--.sc-faq summary,.sc-sec>details:not([class])>summary{cursor:pointer;list-style:none;padding:14px 44px 14px 16px;font-weight:700;position:relative}++/* 4.248.0 (the owner, 11-14: "so much padding for no reason … more compact … no space between your streams and the++ question section"): a compact row whatever a site's own details rule says, its glyph, the next block spaced. */++.sc-faq{display:grid;gap:6px;max-width:820px}++.sc-faq details,.sc-sec>details:not([class]){margin:0;padding:0!important;border-radius:12px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);transition:border-color .15s,background-color .15s}++.sc-faq details[open]{border-color:color-mix(in srgb,var(--accent,#3b82f6) 34%,var(--border,rgba(255,255,255,.08)))}++.sc-faq summary,.sc-sec>details:not([class])>summary{cursor:pointer;list-style:none;padding:12px 44px 12px 16px;font-weight:700;position:relative}++.sc-faq summary{display:flex;align-items:center;gap:10px;min-height:48px;box-sizing:border-box;padding:8px 40px 8px 10px;font-weight:650;line-height:1.35}++.sc-faq summary:hover{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 4%,transparent);border-radius:12px}++.sc-q-ic{flex:none;display:grid;place-items:center;width:30px;height:30px;border-radius:9px;color:var(--accent-light,#60a5fa);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent)}++.sc-q-ic svg{width:16px;height:16px}++.sc-sec:has(>.sc-faq)+:not(.sc-sec),.sc-sec+:not(.sc-sec):not(script):not(style):not(link){margin-top:clamp(28px,5vw,44px)}.sc-faq summary::-webkit-details-marker,.sc-sec>details:not([class])>summary::-webkit-details-marker{display:none}.sc-faq summary::after,.sc-sec>details:not([class])>summary::after{content:'';position:absolute;right:18px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}.sc-faq details[open] summary::after,.sc-sec>details[open]:not([class])>summary::after{transform:rotate(-135deg);margin-top:-2px}.sc-sec>details:not([class])+details:not([class]){margin-top:8px}.sc-faq p,.sc-sec>details:not([class])>p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}++.sc-faq p{padding:0 14px 12px 50px;font-size:14.5px}++@media (max-width:600px){.sc-faq p{padding-left:14px}}.sc-fold{border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}.sc-fold>summary{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:56px;padding:0 48px 0 18px;cursor:pointer;list-style:none;position:relative}.sc-fold>summary::-webkit-details-marker{display:none}@@@ -374,7 +374,20 @@ function faq({ id: sid, title = 'Questions', lede, items, icon: secIcon } = {})const usable = (items || []).filter((q) => Array.isArray(q) && q[0] && q[1]);if (!usable.length) return '';const h = `sc-${slug(title)}`;-- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'help')}<div class="sc-faq">${usable.map(([q, a], i) => `<details${i === 0 ? ' open' : ''}><summary>${esc(q)}</summary><p>${esc(a)}</p></details>`).join('')}</div></section>`;++ // 4.248.0 (the owner, 11-14: "so much padding for no reason … more compact … add icons"): each question wears a glyph,++ // its own third item or one its words suggest; the row is compact whatever the site's own details rule says.++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'help')}<div class="sc-faq">${usable.map(([q, a, ic], i) => `<details${i === 0 ? ' open' : ''}><summary><span class="sc-q-ic" aria-hidden="true">${qGlyph(ic, q)}</span><span>${esc(q)}</span></summary><p>${esc(a)}</p></details>`).join('')}</div></section>`;++}++/** The glyph a question wears: the one it names, else the first its words suggest, else a question mark. */++const Q_WORDS = [[/\b(free|cost|price|pay|paid|money|charge)/i, 'coin'], [/\b(key|safe|secure|privacy|private|password)/i, 'lock'],++ [/\b(platform|where|which site|countr|languag)/i, 'globe'], [/\b(encode|quality|bitrate|resolution|setting)/i, 'sliders'],++ [/\b(obs|camera|webcam|record|video|vod|clip)/i, 'video'], [/\b(appear|show|see|visible|public)/i, 'eye'], [/\b(delete|remove|cancel)/i, 'trash'],++ [/\b(account|sign|login|profile)/i, 'user'], [/\b(api|sdk|code|developer|bot)/i, 'code'], [/\b(live|stream|broadcast)/i, 'live'],++ [/\b(how long|when|time|schedule)/i, 'clock'], [/\b(download|export|backup)/i, 'download'], [/\b(ai|agent|model)/i, 'spark']];++function qGlyph(named, q) {++ headGlyphs = headGlyphs || require('./glyphs');++ const want = named && headGlyphs.NAMES.includes(String(named)) ? String(named) : ((Q_WORDS.find(([re]) => re.test(String(q || ''))) || [])[1] || 'help');++ return headGlyphs.svg(headGlyphs.NAMES.includes(want) ? want : 'help', { size: 16 });}/** A JSON-LD block that cannot close its script early. */const jsonLd = (obj) => `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;@@@ -654,8 +667,19 @@ a.sc-orb-node:hover .sc-orb-face>*:first-child,a.sc-orb-node:focus-visible .sc-o@media (min-width:900px){.sc-grid:has(>:nth-child(5):last-child),.sc-grid:has(>:nth-child(6):last-child),.sc-grid:has(>:nth-child(9):last-child){grid-template-columns:repeat(3,minmax(0,1fr))}}.sc-card{display:flex;gap:14px;align-items:flex-start;padding:16px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}--.sc-ic{width:40px;height:40px;flex:none;display:grid;place-items:center;border-radius:12px;font-size:17px;color:var(--accent-light,#60a5fa);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent)}--.sc-ic.sc-ic-ov{background:none;border-radius:50%}++/* 4.248.0 (the owner, 11-14: "redesign all these icons … use the full space … modern and minimalist … stands out++ unique"): a filled tile on its own hue (six, in turn down a grid), the glyph large in it; a ring icon loses its ring. */++.sc-card,.sc-grid>*{--sc-h:var(--accent,#3b82f6)}++.sc-grid>:nth-child(6n+2){--sc-h:#10b981}.sc-grid>:nth-child(6n+3){--sc-h:#f59e0b}.sc-grid>:nth-child(6n+4){--sc-h:#8b5cf6}.sc-grid>:nth-child(6n+5){--sc-h:#ec4899}.sc-grid>:nth-child(6n){--sc-h:#06b6d4}++.sc-ic{width:46px;height:46px;flex:none;display:grid;place-items:center;border-radius:14px;font-size:20px;overflow:hidden;color:color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 28%,#fff);background:linear-gradient(150deg,color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 42%,transparent),color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 16%,transparent));box-shadow:inset 0 1px 0 rgba(255,255,255,.14),0 8px 20px -10px color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 70%,transparent)}++html[data-theme-mode=light] .sc-ic{color:color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 78%,#0b1220);background:linear-gradient(150deg,color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 24%,#fff),color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 10%,#fff))}++.sc-ic.sc-ic-ov{border-radius:14px}++.sc-ic .ovi-bg,.sc-ic .ovi-ring,.sc-ic .ovi-comet,.sc-ic .ovi-prog{display:none}++.sc-ic .sc-ovi{--ovi-size:46px!important;display:grid;place-items:center;width:46px;height:46px;color:inherit}.sc-ic .sc-ovi svg{width:46px;height:46px;transform:scale(1.32)}.sc-ic .ovi-glyph{stroke:currentColor;stroke-width:2.3}++.sc-ic .sc-ovi--glyph{--ovi-size:26px!important;width:26px;height:26px}.sc-ic>i{font-size:21px}++a.sc-card:hover .sc-ic,a.sc-card:focus-visible .sc-ic{scale:1.06}.sc-ic{transition:scale .2s cubic-bezier(.2,.9,.3,1.2)}++/* A phone: the tile beside the title, the words under both, the card's whole width (no empty column under the icon). */++@media (max-width:600px){.sc-grid .sc-card{display:grid;grid-template-columns:auto minmax(0,1fr);column-gap:12px;align-items:center}.sc-grid .sc-card>span:last-child{display:contents}.sc-grid .sc-card b{grid-column:2}.sc-grid .sc-card small{grid-column:1/-1;margin-top:8px}}.sc-ovi--glyph{border-radius:50%;color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 13%,transparent);box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--accent,#3b82f6) 42%,transparent)}.sc-ovi--glyph svg{width:54%!important;height:54%!important}.sc-card b{display:block;font-size:15.5px}.sc-card small{display:block;margin-top:3px;color:var(--text-secondary,#96a7c2);font-size:13.5px;line-height:1.45}.sc-steps{list-style:none;margin:0;padding:0;counter-reset:sc;display:grid;gap:14px 24px;grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}@@@ -724,14 +748,24 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}.sc-spot-media img,.sc-spot-media video{display:block;width:100%;height:auto}.sc-spot h3{margin:0 0 8px;font-size:clamp(20px,2.2vw,26px);letter-spacing:-.01em}.sc-spot h3 a{color:inherit;text-decoration:none}.sc-spot h3 a:hover{color:var(--accent-light,#60a5fa)}.sc-spot p{margin:0;color:var(--text-secondary,#96a7c2);line-height:1.6;font-size:15.5px}.sc-spot-act{margin-top:16px}--.sc-faq{display:grid;gap:8px;max-width:820px}--.sc-faq details,.sc-sec>details:not([class]){border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}--.sc-faq summary,.sc-sec>details:not([class])>summary{cursor:pointer;list-style:none;padding:14px 44px 14px 16px;font-weight:700;position:relative}++/* 4.248.0 (the owner, 11-14: "so much padding for no reason … more compact … no space between your streams and the++ question section"): a compact row whatever a site's own details rule says, its glyph, the next block spaced. */++.sc-faq{display:grid;gap:6px;max-width:820px}++.sc-faq details,.sc-sec>details:not([class]){margin:0;padding:0!important;border-radius:12px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);transition:border-color .15s,background-color .15s}++.sc-faq details[open]{border-color:color-mix(in srgb,var(--accent,#3b82f6) 34%,var(--border,rgba(255,255,255,.08)))}++.sc-faq summary,.sc-sec>details:not([class])>summary{cursor:pointer;list-style:none;padding:12px 44px 12px 16px;font-weight:700;position:relative}++.sc-faq summary{display:flex;align-items:center;gap:10px;min-height:48px;box-sizing:border-box;padding:8px 40px 8px 10px;font-weight:650;line-height:1.35}++.sc-faq summary:hover{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 4%,transparent);border-radius:12px}++.sc-q-ic{flex:none;display:grid;place-items:center;width:30px;height:30px;border-radius:9px;color:var(--accent-light,#60a5fa);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent)}++.sc-q-ic svg{width:16px;height:16px}++.sc-sec:has(>.sc-faq)+:not(.sc-sec),.sc-sec+:not(.sc-sec):not(script):not(style):not(link){margin-top:clamp(28px,5vw,44px)}.sc-faq summary::-webkit-details-marker,.sc-sec>details:not([class])>summary::-webkit-details-marker{display:none}.sc-faq summary::after,.sc-sec>details:not([class])>summary::after{content:'';position:absolute;right:18px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}.sc-faq details[open] summary::after,.sc-sec>details[open]:not([class])>summary::after{transform:rotate(-135deg);margin-top:-2px}.sc-sec>details:not([class])+details:not([class]){margin-top:8px}.sc-faq p,.sc-sec>details:not([class])>p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}++.sc-faq p{padding:0 14px 12px 50px;font-size:14.5px}++@media (max-width:600px){.sc-faq p{padding-left:14px}}.sc-fold{border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}.sc-fold>summary{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:56px;padding:0 48px 0 18px;cursor:pointer;list-style:none;position:relative}.sc-fold>summary::-webkit-details-marker{display:none}@@@ -15,7 +15,9 @@ assert.ok(/const contextBar = \(spec = \{\}\) => \{[\s\S]{0,400}showBar\(PATH_OKassert.ok(/\}, 'edit'\);/.test(src) && /was\.onClear\(\); \}, 'reply'\);/.test(src), 'edit and reply say their kind');assert.ok(/\.ovc-v2 \.ovc-bar::before \{ content: ""; position: absolute; left: 6px;[^}]*background: var\(--ovt-accent/.test(src), 'a rail of the accent at its start');assert.ok(/\.ovc-v2 \.ovc-bar\[data-kind=edit\]::before \{ background: var\(--warning/.test(src), 'editing wears its own colour');--assert.ok(/\.ovc-v2 \.ovt-send--round:disabled \{ opacity: 1; background: transparent;[^}]*box-shadow: inset 0 0 0 1\.5px/.test(src), 'nothing to send: a quiet outline, not a grey disc');++// 4.248.0 (the owner, 11-14, circling the outlined send: "How is the centering and offset of icons still such a problem?";++// "give them that more 3D look"): nothing to send is a dim disk like the buttons beside it, never an outline.++assert.ok(/\.ovc-v2 \.ovt-send--round:disabled \{ opacity: 1; filter: none; background: linear-gradient/.test(src) && !/\.ovc-v2 \.ovt-send--round:disabled \{[^}]*inset 0 0 0 1\.5px/.test(src), 'nothing to send: a dim disk, not an outline');assert.ok(/\.ovc-v2 \.ovt-send--round:not\(:disabled\) \{ animation: ovcSendReady/.test(src), 'ready: it fills on a spring');assert.ok(/html\[data-ov-motion=off\] \.ovc-v2 \.ovt-send--round/.test(src) && /html\[data-ov-motion=off\] \.ovc-v2 \.ovc-bar \{ animation: none; \}/.test(src), 'and nothing moves with motion off');console.log('composer-bar-send: a context bar of any kind inside the box, a quiet Send until there is something to send');@@@ -13,7 +13,9 @@ const { brotli } = require('../scripts/size-report');const ROOT = path.join(__dirname, '..');const NAVBAR_PATH = path.join(ROOT, 'navbar.js');const ICONS = require('../nav-icons');--const NAVBAR_BROTLI_BUDGET = 54.6 * 1024; // 54.4 KB with the content hash of search.js's close that goes home (4.246.0, 2026-11-13: CI's brotli measured 54.4 against 54.4; budgets grow for a feature); before: 54.2 KB with the names wearing what their people wear (4.244.0, 2026-11-13, plan §Z.3: OV.names(scope) and the items loader); before: 53.3 KB with the viewer model (4.243.0, 2026-11-13, plan §Z.2: OV.viewer(), data-ov-viewer and the openvibe-viewer event); before, 52.3 KB with the menu button drawn from the menu (4.240.0, 2026-11-13: the owner's "regenerate this icon … based on what the menu actually looks like … glow"); before: // 51.6 KB with menu addresses (data-ov-menu, #ov-menu=, menuAddress; 4.229.0, 2026-11-12: the owner's "other tools and buttons open specific areas of the menu"; budgets grow for a feature); before that: 50.9 KB with Back to the menu, the links' warm-up and token() (4.229.0, 2026-11-12: the owner's back button and fast links; budgets grow for a feature); before that: 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)++// 56.4 KB since 4.248.0 (2026-11-14): rows that scroll sideways peek, the Sign in wait on its rim, a stored guest answer that++// no longer keeps a person signed out (the owner's asks of 11-14; budgets grow for a feature).++const NAVBAR_BROTLI_BUDGET = 56.4 * 1024; // 54.4 KB with the content hash of search.js's close that goes home (4.246.0, 2026-11-13: CI's brotli measured 54.4 against 54.4; budgets grow for a feature); before: 54.2 KB with the names wearing what their people wear (4.244.0, 2026-11-13, plan §Z.3: OV.names(scope) and the items loader); before: 53.3 KB with the viewer model (4.243.0, 2026-11-13, plan §Z.2: OV.viewer(), data-ov-viewer and the openvibe-viewer event); before, 52.3 KB with the menu button drawn from the menu (4.240.0, 2026-11-13: the owner's "regenerate this icon … based on what the menu actually looks like … glow"); before: // 51.6 KB with menu addresses (data-ov-menu, #ov-menu=, menuAddress; 4.229.0, 2026-11-12: the owner's "other tools and buttons open specific areas of the menu"; budgets grow for a feature); before that: 50.9 KB with Back to the menu, the links' warm-up and token() (4.229.0, 2026-11-12: the owner's back button and fast links; budgets grow for a feature); before that: 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)// ── size budget and generated block ──────────────────────────{@@@ -472,6 +472,17 @@ const USER = { id: 7, username: 'vibe', display_name: 'Vibe', avatar_url: null,assert.strictEqual(calls.filter(c => c.navigation).length, 0, 'guest hint: no silent navigation');}++ {++ // 11-14: a round trip that once found no session (the 'guest' kept in storage) does not stop the next one when the++ // person arrives from another OpenVibe site (openvibe.tours stayed signed out).++ const { navbar, calls } = loadNavbar({++ hostname: 'openvibe.tours', storage: { ov_sso_hint: 'guest' }, referrer: 'https://openvibe.network/',++ fetch: async () => jsonResponse(401, {}),++ });++ navbar.init({ service: 'tours', sessionUrl: '/auth/me', fedcm: false, silentLogin: '/auth/login?silent=1&next={url}' });++ await navbar.refreshAuth();++ assert.strictEqual(calls.filter(c => c.navigation).length, 1, 'stored guest + an OpenVibe referrer: one silent round trip');++ }console.log('navbar-auth: all tests passed');})().catch((err) => {console.error(err);@@@ -501,7 +501,9 @@ const created = (subject, over = {}) => ({// 26.5 → 26.8 KB the same day (4.238.0): the menu's foot as two OV buttons and a compact empty state (26.52 KB measured).// 27.1 KB (4.238.0, 2026-11-13): the ringing bell (its drawing, the swing and the waves), the drawn arrow on Full inbox,// 44 px round buttons with centred glyphs.-- assert.ok(size('notification-ui.js') <= 27.1 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 27.1 KB`);++ // 28.2 KB (4.248.0, 2026-11-14): the ⋯ menu instead of the X, the buttons a notice brings (rich_content.actions), the++ // social-feed rows (the owner: "no spacing … confusing to have an X … more like social media").++ assert.ok(size('notification-ui.js') <= 28.2 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 28.2 KB`);console.log('notification live: all checks passed');})().catch((err) => { console.error(err); process.exit(1); });@@@ -44,7 +44,8 @@ const server = http.createServer((req, res) => {assert.equal(await cs('sw', 'transform', '::before'), 'none', 'a switch has no check mark');assert.equal(await cs('mine', 'width'), '30px', 'a site\'s own rule wins');assert.notEqual(await cs('native', 'appearance'), 'none', 'data-ov-native keeps the browser\'s box');-- assert.ok(/transform/.test(await cs('b', 'transitionProperty')), 'a button answers a press');++ // 4.248.0: the press is `scale` (never `transform`, which a site may position a button with).++ assert.ok(/scale/.test(await cs('b', 'transitionProperty')), 'a button answers a press');await p.goto(`${base}/?motion=off`, { maxMs: 5000 });assert.equal(await cs('off', 'transitionDuration', '::before'), '0s', 'motion Off: the check does not move');assert.equal(await cs('b', 'transitionDuration'), '0s', 'motion Off: buttons do not move');@@@ -75,7 +75,7 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:haassert.ok(real.includes('data-icon="calendar"') && !real.includes('sc-ovi--glyph'), 'a name the ring set has is its own mark');assert.ok(!/data-icon="calendar"[^>]*>[^<]*<svg viewBox="0 0 48 48"/.test(cal), 'not the ring set\'s fallback');assert.ok(ring.includes('<span class="sc-ic"><i class="fa-solid fa-code"'), 'a Font Awesome icon keeps the plain slot');-- assert.ok(sc.CSS.includes('.sc-ic.sc-ic-ov{background:none'), 'the slot drops its tile for a ring icon');++ assert.ok(sc.CSS.includes('.sc-ic .ovi-ring') && sc.CSS.includes('.sc-ic.sc-ic-ov{border-radius:14px}'), 'a ring icon sits in the tile without its ring (4.248.0)');}// Next steps (Shared ADR 0002): safe links only, the referral tag, the target's ring icon and name, escaping, at most four.@@@ -121,7 +121,7 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:haassert.ok(sp.includes('<video src="/v.mp4" muted loop playsinline autoplay preload="none" poster="/p.jpg"'), 'a short muted loop, with its poster');assert.strictEqual((sp.match(/class="sc-spot"/g) || []).length, 2);const fq = sc.faq({ items: [['Do I need OBS?', 'No.'], ['Broken'], ['Free?', 'Yes.']] });-- assert.ok(fq.includes('<details open><summary>Do I need OBS?</summary>') && (fq.match(/<details/g) || []).length === 2, 'an accordion, the first open');++ assert.ok(fq.includes('<details open><summary><span class="sc-q-ic" aria-hidden="true">') && fq.includes('<span>Do I need OBS?</span></summary>') && (fq.match(/<details/g) || []).length === 2, 'an accordion, the first open');const ld = sc.faqJsonLd([['Q </script>', 'A']]);assert.ok(ld.startsWith('<script type="application/ld+json">') && !ld.slice(35, -9).includes('<'), 'JSON-LD cannot close its script early');assert.ok(JSON.parse(ld.slice(35, -9).replace(/\\u003c/g, '<')).mainEntity[0].name === 'Q </script>');@@@ -52,7 +52,9 @@ assert.ok(LT.arctic['--success'], 'loader entries carry the full token set');// 63,500 since 4.111.1 (2026-11-05): the FAVICON block draws the Pair (about 1.2 KB raw) instead of the retired ring.// 66,500 since 4.183.0 (2026-11-10): motion has four choices, --ov-move, and Calm paces endless loops (about 2.9 KB raw).// 67,600 since 4.186.0 (2026-11-10): a control nobody styled wears the theme (three rules, about 0.9 KB raw with their note).--assert.ok(loader.length < 67600, `theme-loader.js stays small for first paint (${loader.length} characters)`);++// 75,000 since 4.248.0 (2026-11-14): every time in the reader's own zone and clock (time.format, <time data-ov-time>,++// the clock and tz settings; about 7 KB raw with its notes; the owner: "show the time based on the users computer").++assert.ok(loader.length < 75000, `theme-loader.js stays small for first paint (${loader.length} characters)`);assert.ok(loader.includes('KNOWN_PROPS'), 'loader clears tokens the new theme does not set');// 5. Derived tokens: text on the accent always reads, on every theme.@@@ -353,7 +353,11 @@// ── Display settings, one record for every OpenVibe site: display.get() → DISPLAY_OPTS' values; display.set(patch)// applies here and in other tabs and saves to the account. <html data-ov-*> and --ov-text-scale before first paint.-- const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'full', 'reduced', 'off'], text: ['100', '112', '125'], seasonal: ['on', 'off'], scheme: ['manual', 'auto'], contrast: ['normal', 'more'], font: ['default', 'readable'], colors: ['default', 'cb-safe'], oled: ['off', 'on'], icons: ['auto', 'line', 'solid'], window: ['glass', 'solid', 'outline'] };++ const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'full', 'reduced', 'off'], text: ['100', '112', '125'], seasonal: ['on', 'off'], scheme: ['manual', 'auto'], contrast: ['normal', 'more'], font: ['default', 'readable'], colors: ['default', 'cb-safe'], oled: ['off', 'on'], icons: ['auto', 'line', 'solid'], window: ['glass', 'solid', 'outline'], clock: ['auto', '12', '24'], tz: ['auto'] };++ // Time (4.248.0; the owner, 11-14: "show the time based on the user's computer by default or configurable setting if++ // logged in and changed"): clock auto|12|24 and tz auto or an IANA zone (any zone this browser knows, not only 'auto').++ function zoneOk(z) { if (!/^[A-Za-z][A-Za-z0-9_+\-]*(\/[A-Za-z0-9_+\-]+){0,2}$/.test(String(z || '')) || String(z).length > 40) return false; try { new Intl.DateTimeFormat('en-US', { timeZone: String(z) }); return true; } catch (e) { return false; } }++ function okDisplay(k, v) { return !!DISPLAY_OPTS[k] && (DISPLAY_OPTS[k].indexOf(String(v)) >= 0 || (k === 'tz' && zoneOk(v))); }// Status names some sites use (--error, --error-text, --ok, --warn, --accent-text) that no theme sets: they resolve to the theme's// readable status colours instead of each site's fallback literal (pale on light themes). :where() keeps them// below any site's own value (4.86.0).@@@ -404,8 +408,8 @@document.addEventListener('animationstart', function (e) { if (document.documentElement.getAttribute('data-ov-motion') === 'reduced') paceLoops(e.target); }, true);}function readDisplay() {-- const out = { motion: 'auto', text: '100', seasonal: 'on', scheme: 'manual', contrast: 'normal', font: 'default', colors: 'default', oled: 'off', icons: 'auto', window: 'glass' };-- try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null') || {}; for (const k in DISPLAY_OPTS) if (DISPLAY_OPTS[k].indexOf(String(d[k])) >= 0) out[k] = String(d[k]); } catch (e) { /* */ }++ const out = { motion: 'auto', text: '100', seasonal: 'on', scheme: 'manual', contrast: 'normal', font: 'default', colors: 'default', oled: 'off', icons: 'auto', window: 'glass', clock: 'auto', tz: 'auto' };++ try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null') || {}; for (const k in DISPLAY_OPTS) if (okDisplay(k, d[k])) out[k] = String(d[k]); } catch (e) { /* */ }return out;}function applyDisplay(d) {@@@ -430,7 +434,7 @@function setDisplay(patch, opts) {const d = readDisplay();const was = d.seasonal + '|' + d.scheme;-- for (const k in DISPLAY_OPTS) if (patch && DISPLAY_OPTS[k].indexOf(String(patch[k])) >= 0) d[k] = String(patch[k]);++ for (const k in DISPLAY_OPTS) if (patch && okDisplay(k, patch[k])) d[k] = String(patch[k]);// A change made here is dated now; one adopted from the account or another site keeps its own date.const at = opts && Number(opts.at) > 0 ? Number(opts.at) : (opts && opts.local ? displayAt() : Date.now());try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(Object.assign({}, d, { at: at }))); } catch (e) { /* */ }@@@ -587,7 +591,7 @@const da = clamp(L.da), ta = clamp(L.ta);if (L.d && typeof L.d === 'object' && da > displayAt()) {const d = readDisplay();-- for (const k in DISPLAY_OPTS) if (DISPLAY_OPTS[k].indexOf(String(L.d[k])) >= 0) d[k] = String(L.d[k]);++ for (const k in DISPLAY_OPTS) if (okDisplay(k, L.d[k])) d[k] = String(L.d[k]);try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(Object.assign({}, d, { at: da }))); } catch (e) { /* */ }}const id = String(L.t || '');@@@ -704,10 +708,87 @@if (from && typeof location !== 'undefined' && from !== location.hostname) setTimeout(syncFromServer, 2500);}++ // ── Time, in the reader's own clock (4.248.0) ────────────++ // Every OpenVibe page says a moment in the reader's time zone and clock: the device's by default, the account's choice++ // when they made one (display tz and clock). A server writes <time datetime="…Z" data-ov-time="datetime|date|time|day">++ // with a UTC fallback for crawlers and no-script; this rewrites it in place, again when the choice changes and for any++ // such element added later. Script that draws a time calls time.format(value, kind).++ function deviceZone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch (e) { return 'UTC'; } }++ function timeZone() { const z = readDisplay().tz; return z && z !== 'auto' ? z : deviceZone(); }++ function hour12() { const c = readDisplay().clock; return c === '12' ? true : c === '24' ? false : undefined; }++ const KINDS = {++ time: { hour: 'numeric', minute: '2-digit' },++ date: { year: 'numeric', month: 'short', day: 'numeric' },++ datetime: { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' },++ full: { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' },++ };++ function dayKey(ms, zone) { try { return new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(ms); } catch (e) { return ''; } }++ /** One moment as words in the reader's time: kind time | date | datetime | day (Today 3:05 PM, Yesterday …) | full. */++ function formatTime(value, kind, opts) {++ const ms = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(String(value || ''));++ if (!Number.isFinite(ms)) return '';++ const zone = timeZone(), h12 = hour12(), lang = (opts && opts.locale) || undefined;++ const other = zone !== deviceZone(); // a zone the reader picked that is not this device's: say which++ const fmt = (o) => { try { return new Intl.DateTimeFormat(lang, Object.assign({ timeZone: zone }, o, h12 === undefined ? {} : { hour12: h12 })).format(ms); } catch (e) { return new Date(ms).toISOString().slice(0, 16).replace('T', ' ') + ' UTC'; } };++ const tzName = () => { try { const p = new Intl.DateTimeFormat(lang, { timeZone: zone, timeZoneName: 'short' }).formatToParts(ms).find((x) => x.type === 'timeZoneName'); return p ? ' ' + p.value : ''; } catch (e) { return ''; } };++ const k = String(kind || 'datetime');++ if (k === 'ago') {++ // How long ago, the same words in every zone; the title (upgradeTimes) says the moment in the reader's time.++ const sec = Math.max(0, Math.floor((Date.now() - ms) / 1000));++ if (sec < 60) return 'just now';++ const u = sec < 3600 ? ['minute', Math.floor(sec / 60)] : sec < 86400 ? ['hour', Math.floor(sec / 3600)] : sec < 2592000 ? ['day', Math.floor(sec / 86400)] : sec < 31536000 ? ['month', Math.floor(sec / 2592000)] : ['year', Math.floor(sec / 31536000)];++ return u[1] + ' ' + u[0] + (u[1] === 1 ? '' : 's') + ' ago';++ }++ if (k === 'day') {++ const now = Date.now(), today = dayKey(now, zone), that = dayKey(ms, zone), yesterday = dayKey(now - 86400000, zone);++ const t = fmt(KINDS.time) + (other ? tzName() : '');++ if (that === today) return 'Today ' + t;++ if (that === yesterday) return 'Yesterday ' + t;++ if (Math.abs(now - ms) < 6 * 86400000) return fmt({ weekday: 'short' }) + ' ' + t;++ return fmt(new Date(ms).getUTCFullYear() === new Date(now).getUTCFullYear() ? { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' } : KINDS.datetime) + (other ? tzName() : '');++ }++ if (k === 'full') return fmt(KINDS.full);++ return fmt(KINDS[k] || KINDS.datetime) + (other && k !== 'date' ? tzName() : '');++ }++ function upgradeTimes(scope) {++ if (typeof document === 'undefined') return;++ const list = (scope && scope.querySelectorAll ? scope : document).querySelectorAll('time[data-ov-time][datetime]');++ for (let i = 0; i < list.length; i++) {++ const el = list[i], words = formatTime(el.getAttribute('datetime'), el.getAttribute('data-ov-time'));++ if (!words) continue;++ if (el.textContent !== words) el.textContent = words;++ const full = formatTime(el.getAttribute('datetime'), 'full');++ if (full && el.getAttribute('title') !== full) el.setAttribute('title', full);++ }++ }++ if (typeof document !== 'undefined' && document.addEventListener && typeof window !== 'undefined') {++ let queued = null;++ const later = (node) => {++ if (!queued) { queued = []; (window.requestAnimationFrame || setTimeout)(function () { const q = queued; queued = null; for (let i = 0; i < q.length; i++) upgradeTimes(q[i]); }); }++ queued.push(node);++ };++ const watch = function () {++ upgradeTimes(document);++ if (typeof MutationObserver === 'undefined' || !document.body) return;++ new MutationObserver(function (records) {++ for (let i = 0; i < records.length; i++) {++ const added = records[i].addedNodes;++ for (let j = 0; j < added.length; j++) { const n = added[j]; if (n.nodeType === 1 && (n.matches('time[data-ov-time]') ? true : n.querySelector && n.querySelector('time[data-ov-time]'))) later(n.matches('time[data-ov-time]') ? n.parentNode || n : n); }++ }++ }).observe(document.body, { childList: true, subtree: true });++ };++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', watch); else setTimeout(watch, 0);++ window.addEventListener('ov:display', function () { upgradeTimes(document); });++ }++// ── Public API ───────────────────────────────────────────const OpenVibeThemeLoader = {THEMES: THEMES,display: { get: readDisplay, set: setDisplay, options: DISPLAY_OPTS, motion: function () { return motionOf(); }, move: moveOf },++ // The reader's time (4.248.0): format(value, 'time'|'date'|'datetime'|'day'|'full'), zone(), device(), hour12(),++ // upgrade(scope) for <time data-ov-time> drawn by script, zones() → every IANA zone this browser knows.++ time: { format: formatTime, zone: timeZone, device: deviceZone, hour12: hour12, upgrade: upgradeTimes, ok: zoneOk,++ zones: function () { try { return Intl.supportedValuesOf('timeZone'); } catch (e) { return ['UTC']; } } },apply: applyById,applyVars: applyVars,// Seasons: SEASONS, now() → the season today (or null), showing() → the season whose theme is on screen (or null)@@@ -356,6 +356,7 @@ const ovcNameMarkup = (who, words) => {const subject = ovcSubjectOf(who);return `<span${subject ? ` data-ov-subject="${subject}"` : ''}>${esc(words)}</span>`;};++// UTC is what the server knows; the reader's own zone and clock replace it in the page (theme-loader's time, 4.248.0).const date = (iso) => {const ms = Date.parse(String(iso || ''));if (!Number.isFinite(ms)) return { short: '', full: '' };@@@ -428,7 +429,7 @@ function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowedconst more = Math.max(0, Number(c.reply_count || 0) - rows.length);replies = `<div class="ovc-replies" data-ovc-replies="${id}">${rows.map((r) => comment(r, id)).join('')}${more ? `<a class="ovc-more ovk-btn ovk-btn--quiet" data-ovc-more="${id}" href="${query(base, sort, { replies: id })}">${more} more ${more === 1 ? 'reply' : 'replies'}</a>` : ''}</div>`;}-- return `<article class="ovc-comment" id="comment-${id}"><header>${avatar ? `<img src="${esc(avatar)}" alt="" width="32" height="32" loading="lazy">` : ''}${profile ? `<a href="${profile}">${ovcNameMarkup(c, name(c))}</a>` : ovcNameMarkup(c, name(c))}${when.short ? `<time datetime="${esc(c.created_at)}" title="${esc(when.full)}">${esc(when.short)}</time>` : ''}</header>${target}${gone ? '<p>Comment removed</p>' : `<p>${esc(c.message || '')}</p>${attachments(c)}`}${controls}${validReply === id ? `<div data-ovc-composer-slot>${composer_html(id)}</div>` : ''}${replies}</article>`;++ return `<article class="ovc-comment" id="comment-${id}"><header>${avatar ? `<img src="${esc(avatar)}" alt="" width="32" height="32" loading="lazy">` : ''}${profile ? `<a href="${profile}">${ovcNameMarkup(c, name(c))}</a>` : ovcNameMarkup(c, name(c))}${when.short ? `<time datetime="${esc(c.created_at)}" data-ov-time="ago" title="${esc(when.full)}">${esc(when.short)}</time>` : ''}</header>${target}${gone ? '<p>Comment removed</p>' : `<p>${esc(c.message || '')}</p>${attachments(c)}`}${controls}${validReply === id ? `<div data-ovc-composer-slot>${composer_html(id)}</div>` : ''}${replies}</article>`;};const hasReplySlot = validReply && comments.some((c) => String(c.id) === validReply || (validExpanded === String(c.id) && Array.isArray(expandedReplies) ? expandedReplies : c.replies || []).some((r) => String(r.id) === validReply));// Top and Newest only when there is something to put in order.@@@ -64,6 +64,10 @@.ovtip-dismiss{flex:0 0 auto;color:var(--text-muted,#8b93ad);background:none;border:1px solid var(--border,rgba(255,255,255,.12))}.ovtip-go:active,.ovtip-dismiss:active{transform:scale(.975)}.ovtip-go i{font-size:.72rem}++.ovtip-actions.is-build{flex-direction:column;align-items:stretch;gap:8px}++.ovtip-build{flex:none;min-height:44px;text-decoration:none;font-weight:750;gap:9px;background:linear-gradient(160deg,color-mix(in srgb,var(--ovtip-accent,var(--accent,#60a5fa)) 70%,#fff),var(--ovtip-accent,var(--accent,#3b82f6)) 50%,color-mix(in srgb,var(--ovtip-accent,var(--accent,#3b82f6)) 75%,#000));box-shadow:inset 0 1px 0 rgba(255,255,255,.35),0 10px 22px -10px var(--ovtip-accent,var(--accent,#3b82f6))}++.ovtip-build i{font-size:.9rem}++.ovtip-second{color:var(--text-primary,#e6edf7)}/* On a phone a 330px tip pinned beside a button is unreadable and often half off-screen. Dock itto the bottom of the viewport as a full-width card instead. */@media (hover:none){@@@ -97,10 +101,19 @@// On touch the first tap opens the tip and swallows the click, so without an explicit// action the visitor is left tapping a button that appears to do nothing. Give them the// destination as a real button inside the tip — and a way out that isn't "tap elsewhere".-- const act = isTouch() ? `<div class="ovtip-actions">++ // 4.248.0 (the owner, 11-14, on Live's Build tip: "bring users to openvibe.codes and … use their agent … right in the++ // UI … start working on OpenVibe yourself … make that more prevalent"): data-ovtip-build (its words) and++ // data-ovtip-build-href (https) put the main action first, wide, on every device; the trigger's own place second.++ const buildHref = /^https:\/\//.test(el.getAttribute('data-ovtip-build-href') || '') ? el.getAttribute('data-ovtip-build-href') : '';++ const build = el.getAttribute('data-ovtip-build') && buildHref ? `<div class="ovtip-actions is-build">++ <a class="ovtip-go ovtip-build" href="${esc(buildHref)}"><i class="fa-solid fa-wand-magic-sparkles"></i>${esc(el.getAttribute('data-ovtip-build'))}</a>++ <div class="ovtip-actions"><button type="button" class="ovtip-dismiss" data-ovtip-dismiss>Got it</button>++ <button type="button" class="ovtip-dismiss ovtip-second" data-ovtip-go>${esc(el.getAttribute('data-ovtip-go') || 'Open')}</button></div>++ </div>` : '';++ const act = build || (isTouch() ? `<div class="ovtip-actions"><button type="button" class="ovtip-dismiss" data-ovtip-dismiss>Got it</button><button type="button" class="ovtip-go" data-ovtip-go>${esc(el.getAttribute('data-ovtip-go') || 'Open')}<i class="fa-solid fa-arrow-right"></i></button>-- </div>` : '';++ </div>` : '');return `${title ? `<div class="ovtip-head">${icon ? `<span class="ovtip-ico"><i class="fa-solid ${icon}"></i></span>` : ''}<span class="ovtip-title">${title}</span></div>` : ''}<div class="ovtip-body">${body}</div>${foot ? `<div class="ovtip-foot">${foot}</div>` : ''}${act}`;@@@ -142,7 +155,7 @@tip.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');arrow.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');tip.innerHTML = content(el);-- tip.classList.toggle('is-interactive', el.hasAttribute('data-ovtip-interactive') || isTouch());++ tip.classList.toggle('is-interactive', el.hasAttribute('data-ovtip-interactive') || el.hasAttribute('data-ovtip-build') || isTouch());tip.classList.remove('is-on'); arrow.classList.remove('is-on');cancelAnimationFrame(raf);place();
@@ -1,5 +1,99 @@
@@ -1,5 +1,99 @@
# Changelog
# Changelog
+## 4.248.0 — 2026-11-14
+
+**Every time in the reader's own clock** (the owner, 11-14: "make it so areas like this show the time based on the
+users computer by default or configurable setting if logged in and changed").
+
+- theme-loader's `time`: `format(value, 'time'|'date'|'datetime'|'day'|'ago'|'full')`, `zone()`, `device()`,
+ `hour12()`, `zones()`, `upgrade(scope)`. Two new display settings, synced to the account like the rest: `clock`
+ (auto, 12, 24) and `tz` (auto, or any IANA zone the browser knows). Auto is this device's zone and clock.
+- `<time datetime="…" data-ov-time="…">` anywhere on a page is rewritten in the reader's time (its title says the full
+ moment with the zone), again when the setting changes and for any such element added later. A server keeps writing
+ UTC as the fallback for crawlers.
+- The Look panel has Clock (Auto, 12-hour, 24-hour) and Time zone.
+- Comments (thread.js): the time's title is the reader's moment, not "UTC".
+- The feed: a card from before today says its day and time ("Yesterday 9:14 PM", "Tue 3:05 PM"), not a bare time as if
+ it were today's; an event's start too. A report card (feed.item@1 1.2.0 `stat`) says when its number happened ("The
+ most people on OpenVibe at once today, at 3:47 AM") and today's side numbers as chips (people today, signed in, page
+ views, new accounts, live now).
+
+**Pictures that do not load leave no empty frame** (the owner, 11-14: "broken images"): a feed card drops its picture
+box, a group row (a run of deals) shows its placeholder.
+
+**The footer's online box opens into a column** (the owner, 11-14: "see the split of where the guests are … which sites
+they're on and what they're up to", "make them red" when live, "these cards also start stacking vertically"):
+someone live now wears a red ring and a LIVE badge with their viewers and links to their channel; the guests are one bar
+split by site (hover or focus a part for what people do there) with the parts in words; a Today line says the day's
+people and its peak, the peak's time in the reader's clock; open, the people are one per line and the tiles stack in
+two columns. An open box stays open across the minute's repaint. Reads Network's /api/frame/online `guest_sites`,
+`people[].live` and `today` (OpenVibe.Network #937).
+
+**`.ov-places`, a page's own places in one shape** (kit-layout.css; the owner, 11-14, on Download: "move our tab to the
+top … a fixed tab system … icons … like our main menu area … so the layout can be reused for pages like this"): icon
+rows in a side column on a computer, a row of icon tabs that stays under the bar on a phone, counts as badges, an
+extra part (a meter) on the computer only.
+
+**Signed in is the person the bar shows** (the owner, 11-14, on openvibe.download: the menu said "Sign in to make
+OpenVibe yours" under their own avatar): the guest card, the workspace and the menu's "me" follow the signed-in person,
+not a token; a site that signs in by its own session cookie has no token in the page.
+
+**Feature cards and questions, remade** (showcase; the owner, 11-14, on OpenRestream: "redesign all these icons … use the
+full space … modern and minimalist … stands out"; the questions have "so much padding for no reason" and no space
+before the next block): a feature's icon is a filled tile on its own hue (six in turn down a grid), its glyph large, a
+ring icon without its ring; on a phone the tile sits beside the title and the words take the card's whole width. A
+question is a compact row (48 px) with a glyph (its own third item, or one its words suggest), whatever a site's own
+details rule says, and the block after a showcase section is spaced like a section.
+
+**Notifications read like a social feed** (the owner, 11-14: "no spacing between them … confusing to have an X. Like,
+what does that do? … more like social media … rich content … the approval system can go through here"): every row the
+same card with room between, unread a tint and a dot at the right, a ⋯ menu (Mark as read, Remove this notice,
+Notification settings) instead of the bare X, and up to three buttons a notice brings in `rich_content.actions`
+([{ label, url, style: primary|danger }], https only: Approve and Deny on an approval, Watch on a live notice).
+
+**Vibe is a conversation** (ov-run.js and the menu; the owner, 11-14: "you see how it says now your agent's idle? You
+don't even need that … more like a conversation view … move this history … to a history button in the corner … the tabs
+… need to be above the actor. And the actor … a fixed thing that's on top, kind of like how Muse is"): All, Chats, Tasks
+and Code on top, your agent fixed under them (face, state, today's spend, History and Pause), and All opens today's
+conversation with it in place, with "Message Vibe" below. History lists every conversation, task and code run; pressed
+again, it is back to the conversation. The menu no longer lends the Dock's Now / Needs you / timeline above Vibe.
+The footer no longer names a live streamer twice (their row says LIVE; the channel tile goes).
+
+**One button system in every composer** (ov-composer.js; the owner, 11-14: "How is the centering and offset of icons
+still such a problem?", "give them that more 3D look … the post button … completely customized … an animation", "the
+attached file should be just in the plus button"): the tools a + already holds (photos, files, the camera) were still
+drawn in the row wherever a box sat outside a thread page, because `[hidden]` lost to the button's own display; hidden
+now means gone everywhere. Every icon button is a 40 px disk lit from above, its glyph centred on a grid and never
+nudged; Post, Reply and the round send are their own lit buttons with a shine that crosses on hover, a press you can
+feel and the arrow lifting; nothing to send is a dim disk (not an outline); a one-line box centres its buttons. Motion
+Off keeps them still.
+
+**Signed in on one site, signed in on the next** (navbar.js; the owner, 11-14: openvibe.tours showed Sign in though every
+other site had them signed in): a round trip that once found no session there (the 'guest' this script keeps) no longer
+stops the next one when the person arrives from another OpenVibe site; a sign-out (the site's own 'guest' cookie)
+still does. Once per tab per 10 minutes, as before.
+
+**The bar** (navbar.js; the owner, 11-14): the menu button keeps the little input (its dot on the left and a typed line;
+the spark beside the dot, which read as two dots, and its pink are gone: every colour is the theme's) over its three
+tabs; signing in, a comet of light runs around the Sign in button's rim instead of a ring inside its glyph; and on every
+page a row that scrolls sideways (a rail, chips, tabs, pills, a strip, or `data-ov-peek`) peeks a little way and back
+the first time it is on screen, so it is plain that it scrolls (once per row; never under a finger; Calm less, Off
+never; `data-ov-peek="off"` opts out).
+
+**The menu's field and close** (search.js; the owner, 11-14: "the close button's offset … more consistent with the
+search input", "the top thing … needs to be less round"): the field and its close share the kit's radius scale (a
+rounded rectangle, not a pill), the close is as tall as the field, centred on it, measured again once the sheet has
+slid in and when the window changes, and wears the field's surface and line. Vibe's strip above its list is quiet:
+only a timer, an agent at work or something that needs you shows (no idle line, no "Nothing needs you", no folds).
+
+**The menu's close** (the owner, 11-14): closing no longer moves the page (the browser restores no scroll for the
+menu's own Back, and focus returns without scrolling); the close button folds home into the menu button, and a tab
+flies back into the button that opened it only while the menu still shows that tab.
+
+**A pressed button keeps its place** (the owner, 11-14, Live's Chat button "shifts way down … makes you un-click it"):
+the forms press is `scale: .97`, not `transform: scale(.97)`, which replaced any transform a site positioned a button
+with (centered, slid in). Checkboxes and Motion off likewise.
+
## 4.247.0 — 2026-11-13
## 4.247.0 — 2026-11-13
**Any video in a comment** (the owner, 11-13: "full support for our composer to support all video file types"; plan
**Any video in a comment** (the owner, 11-13: "full support for our composer to support all video file types"; plan
@@ -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.247.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.248.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.247.0",
+ "version": "4.248.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)",
@@ -135,6 +135,11 @@
@@ -135,6 +135,11 @@
const chips = el(doc, 'div', 'ovdt-chips');
const chips = el(doc, 'div', 'ovdt-chips');
const list = el(doc, 'div', 'ovdt-list');
const list = el(doc, 'div', 'ovdt-list');
wrap.append(nowHead, nowBox, needsHead, needsBox, chips, list);
wrap.append(nowHead, nowBox, needsHead, needsBox, chips, list);
+ // 4.248.0, quiet (the menu's Vibe; the owner, 11-14: "Your agent's idle? You don't even need that"): only what is
+ // happening shows (a timer, an agent at work, something that needs you); no idle line, no "Nothing needs you",
+ // no timeline folds (those are History's). The Dock's own Agents panel keeps everything.
+ const quiet = !!opts.quiet;
+ if (quiet) { wrap.classList.add('is-quiet'); chips.hidden = true; list.hidden = true; }
container.appendChild(wrap);
container.appendChild(wrap);
async function api(path, options = {}) {
async function api(path, options = {}) {
@@ -166,7 +171,9 @@
@@ -166,7 +171,9 @@
function renderNow(now) {
function renderNow(now) {
nowBox.textContent = '';
nowBox.textContent = '';
+ nowHead.hidden = false;
if (!now) {
if (!now) {
+ if (quiet) { nowHead.hidden = true; return; }
nowBox.appendChild(el(doc, 'div', 'ovdt-card ovdt-empty', 'Your agent is idle. Ask it something in Talk.'));
nowBox.appendChild(el(doc, 'div', 'ovdt-card ovdt-empty', 'Your agent is idle. Ask it something in Talk.'));
return;
return;
}
}
@@ -216,7 +223,9 @@
@@ -216,7 +223,9 @@
function renderNeeds(needs) {
function renderNeeds(needs) {
needsBox.textContent = '';
needsBox.textContent = '';
+ needsHead.hidden = false;
if (!needs.length) {
if (!needs.length) {
+ if (quiet) { needsHead.hidden = true; return; }
needsBox.appendChild(el(doc, 'div', 'ovdt-empty', 'Nothing needs you.'));
needsBox.appendChild(el(doc, 'div', 'ovdt-empty', 'Nothing needs you.'));
return;
return;
}
}
@@ -114,7 +114,7 @@
@@ -114,7 +114,7 @@
// 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': '9b2a27f70f5f' };
+ const VIBE_REV = { 'ov-composer.js': 'c29a98f058e6' };
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', '');
@@ -84,7 +84,7 @@
@@ -84,7 +84,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': 'e1c7c07dca1f', 'dock-code.js': 'cb97274524d0', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '759f07eebc81', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '1b6262204e91', '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': '4071b3c54334', 'dock-code.js': 'cb97274524d0', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '759f07eebc81', 'dock-timeline.js': '893dbfa4c5e3', 'dock-actor.js': '1b6262204e91', '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);
@@ -310,7 +310,7 @@
@@ -310,7 +310,7 @@
* agent's live view (K-D3; it hides itself when there is none). Each loads on its own. */
* agent's live view (K-D3; it hides itself when there is none). Each loads on its own. */
function agentTop(id, panel, epoch) {
function agentTop(id, panel, epoch) {
load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {
load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {
- if (on && epoch === panelEpoch && !mounts[id + 'Timeline'] && panel.isConnected && panelActive(id)) mounts[id + 'Timeline'] = root.OpenVibeDockTimeline.mount(panel.querySelector('.ovdk-timeline'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), active: () => panelActive('agent'), focus: () => o.task || null });
+ if (on && epoch === panelEpoch && !mounts[id + 'Timeline'] && panel.isConnected && panelActive(id)) mounts[id + 'Timeline'] = root.OpenVibeDockTimeline.mount(panel.querySelector('.ovdk-timeline'), { quiet: part === 'live', token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), active: () => panelActive('agent'), focus: () => o.task || null });
});
});
if (panel.querySelector('.ovdk-actor')) load('dock-actor.js', () => !!(root.OpenVibeDockActor && root.OpenVibeDockActor.mount)).then((on) => {
if (panel.querySelector('.ovdk-actor')) load('dock-actor.js', () => !!(root.OpenVibeDockActor && root.OpenVibeDockActor.mount)).then((on) => {
if (on && epoch === panelEpoch && !mounts[id + 'Actor'] && panel.isConnected && panelActive(id)) mounts[id + 'Actor'] = root.OpenVibeDockActor.mount(panel.querySelector('.ovdk-actor'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), networkBase: o.apiBase || 'https://openvibe.network', active: () => panelActive('agent') });
if (on && epoch === panelEpoch && !mounts[id + 'Actor'] && panel.isConnected && panelActive(id)) mounts[id + 'Actor'] = root.OpenVibeDockActor.mount(panel.querySelector('.ovdk-actor'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), networkBase: o.apiBase || 'https://openvibe.network', active: () => panelActive('agent') });
@@ -500,6 +500,24 @@
@@ -500,6 +500,24 @@
.ovf-who-list img,.ovf-who-list i{flex:none;width:26px;height:26px;border-radius:50%;object-fit:cover}
.ovf-who-list img,.ovf-who-list i{flex:none;width:26px;height:26px;border-radius:50%;object-fit:cover}
.ovf-who-list small{color:var(--text-muted,#8b93ad);font-weight:500}
.ovf-who-list small{color:var(--text-muted,#8b93ad);font-weight:500}
.ovf-who-more{margin:8px 2px 0;color:var(--text-muted,#8b93ad)}
.ovf-who-more{margin:8px 2px 0;color:var(--text-muted,#8b93ad)}
+/* 4.248.0 (the owner, 11-14): who is live wears red; where the guests are; today; open, everything stacks. */
+.ovf-who img.is-live,.ovf-who i.is-live{box-shadow:0 0 0 2px var(--bg-secondary,#0f1420),0 0 0 4px var(--live-red-text,#ef4444)!important}
+.ovf-who-list li.is-live a{background:color-mix(in srgb,var(--live-red-text,#ef4444) 14%,transparent)}
+.ovf-who-live{font-style:normal;font-size:.72rem;font-weight:800;letter-spacing:.04em;text-transform:uppercase;padding:2px 7px;border-radius:999px;color:#fff;background:var(--live-red,#dc2626)}
+.ovf-who-today{margin:4px 2px 0;color:var(--text-secondary,#b6bdd2)}.ovf-who-today b{color:var(--text-primary,#f1f4fb);font-variant-numeric:tabular-nums}
+.ovf-split{display:flex;gap:2px;height:10px;margin:12px 0 0;border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--text-primary,#f1f4fb) 6%,transparent)}
+.ovf-split-s{flex:1 1 0;min-width:4px;transition:filter .15s,flex-grow .4s cubic-bezier(.2,.9,.3,1)}.ovf-split-s:hover{filter:brightness(1.25)}
+.ovf-split-l{list-style:none;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr));gap:4px 12px;margin:10px 0 0;padding:0}
+.ovf-split-l a,.ovf-split-l li>span{display:flex;align-items:center;gap:7px;min-width:0;min-height:30px;padding:3px 8px;border-radius:9px;color:var(--text-secondary,#b6bdd2);text-decoration:none}
+.ovf-split-l a:hover{background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent);color:var(--text-primary,#f1f4fb)}
+.ovf-who .ovf-split-l i{display:block;flex:none;width:10px;height:10px;border-radius:3px;align-self:center}
+.ovf-split-l b{color:var(--text-primary,#f1f4fb);font-variant-numeric:tabular-nums}.ovf-split-l small{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted,#8b93ad)}
+.ovf-who .ovf-c0{background:var(--accent,#60a5fa)}.ovf-who .ovf-c1{background:var(--success-text,#34d399)}.ovf-who .ovf-c2{background:var(--warning-text,#fbbf24)}.ovf-who .ovf-c3{background:#a78bfa}.ovf-who .ovf-c4{background:#f472b6}.ovf-who .ovf-c5{background:#22d3ee}.ovf-who .ovf-c6{background:#fb923c}.ovf-who .ovf-c7{background:#94a3b8}.ovf-who .ovf-c8{background:#64748b}
+/* Open, the box is a column: the people one per line, the tiles stacked (11-14: "start stacking vertically"). */
+.ovf-who[open] .ovf-who-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))}
+.ovf-who[open] .ovf-who-list a{display:flex;border-radius:12px}
+.ovf-now:has(>.ovf-who[open])>.ovf-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,160px),1fr));margin:6px 0 0;padding:0;overflow:visible}
+.ovf-now:has(>.ovf-who[open])>.ovf-tiles>.ovf-tile{border-radius:12px;white-space:normal}
.ovf-tiles{display:flex;gap:6px;margin:0 -14px;padding:0 14px 2px;overflow-x:auto;overscroll-behavior-x:contain;scrollbar-width:none}
.ovf-tiles{display:flex;gap:6px;margin:0 -14px;padding:0 14px 2px;overflow-x:auto;overscroll-behavior-x:contain;scrollbar-width:none}
.ovf-tiles::-webkit-scrollbar{display:none}
.ovf-tiles::-webkit-scrollbar{display:none}
.ovf-tile{flex:none;display:inline-flex;align-items:center;gap:6px;min-height:36px;box-sizing:border-box;padding:0 12px 0 10px;border-radius:999px;white-space:nowrap;font-weight:500!important;color:var(--text-secondary,#b6bdd2);background:color-mix(in srgb,var(--text-primary,#f1f4fb) 5%,transparent);transition:background-color .15s,transform .12s}
.ovf-tile{flex:none;display:inline-flex;align-items:center;gap:6px;min-height:36px;box-sizing:border-box;padding:0 12px 0 10px;border-radius:999px;white-space:nowrap;font-weight:500!important;color:var(--text-secondary,#b6bdd2);background:color-mix(in srgb,var(--text-primary,#f1f4fb) 5%,transparent);transition:background-color .15s,transform .12s}
@@ -662,7 +680,8 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
@@ -662,7 +680,8 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
const username = onlineText(p && p.username, 40);
const username = onlineText(p && p.username, 40);
const avatar = onlineUrl(p && p.avatar);
const avatar = onlineUrl(p && p.avatar);
return username && /^[A-Za-z0-9_.-]+$/.test(username)
return username && /^[A-Za-z0-9_.-]+$/.test(username)
- ? { name: onlineText(p.name, 60) || username, username, avatar, site: onlineText(p.site, 40) }
+ ? { name: onlineText(p.name, 60) || username, username, avatar, site: onlineText(p.site, 40),
+ ...(p.live === true ? { live: true, liveUrl: onlineUrl(p.live_url), viewers: onlineN(p.viewers) } : {}) }
: null;
: null;
}).filter(Boolean);
}).filter(Boolean);
const row = (r, withLine) => {
const row = (r, withLine) => {
@@ -683,6 +702,9 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
@@ -683,6 +702,9 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
counts: (Array.isArray(o.counts) ? o.counts : []).map((c) => row({ ...c, link: c && (c.link || c.url) }, false)).filter(Boolean),
counts: (Array.isArray(o.counts) ? o.counts : []).map((c) => row({ ...c, link: c && (c.link || c.url) }, false)).filter(Boolean),
items: (Array.isArray(o.items) ? o.items : []).map((i) => row(i, true)).filter(Boolean),
items: (Array.isArray(o.items) ? o.items : []).map((i) => row(i, true)).filter(Boolean),
sites: onlineN(o.sites), views_7d: onlineN(o.views_7d),
sites: onlineN(o.sites), views_7d: onlineN(o.views_7d),
+ // 4.248.0 (the owner, 11-14: "see the split of where the guests are"): a count per site with what people do there.
+ guestSites: (Array.isArray(o.guest_sites) ? o.guest_sites : []).slice(0, 9).map((g) => ({ name: onlineText(g && g.name, 30), n: onlineN(g && g.n), doing: onlineText(g && g.doing, 40), url: onlineUrl(g && g.url) })).filter((g) => g.name && g.n > 0),
+ today: o.today && typeof o.today === 'object' && onlineN(o.today.peak) > 0 ? { peak: onlineN(o.today.peak), peakAt: Number.isFinite(Date.parse(o.today.peak_at)) ? String(o.today.peak_at) : '', people: onlineN(o.today.people), signedIn: onlineN(o.today.signed_in) } : null,
};
};
}
}
/** The strip from the one read: the old facts it carries, who is online, the counts and this site's own items. */
/** The strip from the one read: the old facts it carries, who is online, the counts and this site's own items. */
@@ -691,15 +713,26 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
@@ -691,15 +713,26 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
// had under its pages: every member by name, then how many more and how many guests. A guest is a number only.
// had under its pages: every member by name, then how many more and how many guests. A guest is a number only.
let who = '';
let who = '';
if (o.total > 0) {
if (o.total > 0) {
- const face = (p, size) => (p.avatar ? `<img src="${esc(p.avatar)}" alt="" width="${size}" height="${size}" loading="lazy" decoding="async" data-ov-initial="${esc(p.name.charAt(0).toUpperCase())}">` : `<i aria-hidden="true">${esc(p.name.charAt(0).toUpperCase())}</i>`);
+ // Someone live now wears a red ring (11-14, the owner: "detect if … a user's live and make them red").
+ const face = (p, size) => (p.avatar ? `<img${p.live ? ' class="is-live"' : ''} src="${esc(p.avatar)}" alt="" width="${size}" height="${size}" loading="lazy" decoding="async" data-ov-initial="${esc(p.name.charAt(0).toUpperCase())}">` : `<i${p.live ? ' class="is-live"' : ''} aria-hidden="true">${esc(p.name.charAt(0).toUpperCase())}</i>`);
const parts = [o.members > 0 ? `${fmtN(o.members)} member${o.members === 1 ? '' : 's'}` : '', o.guests > 0 ? `${fmtN(o.guests)} guest${o.guests === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ');
const parts = [o.members > 0 ? `${fmtN(o.members)} member${o.members === 1 ? '' : 's'}` : '', o.guests > 0 ? `${fmtN(o.guests)} guest${o.guests === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ');
// Shorthand (the owner, 2026-11-10: "Make it like shorthand '3 online'. If you put your mouse over it, it says
// Shorthand (the owner, 2026-11-10: "Make it like shorthand '3 online'. If you put your mouse over it, it says
// 'last 30 minutes'"): the count and the faces. When, and how many are members and guests, are the tooltip's
// 'last 30 minutes'"): the count and the faces. When, and how many are members and guests, are the tooltip's
// and the first line of what a press opens (a finger never sees a tooltip).
// and the first line of what a press opens (a finger never sees a tooltip).
const head = `<span class="ovf-who-dot" aria-hidden="true"></span><span class="ovf-who-n" data-ov-tip="In the last 30 minutes"${parts ? ` data-ov-tip-sub="${esc(parts)}"` : ''}><b>${fmtN(o.total)}</b> online</span>${o.people.length ? `<span class="ovf-who-faces" aria-hidden="true">${o.people.slice(0, 5).map((p) => face(p, 24)).join('')}</span>` : ''}`;
const head = `<span class="ovf-who-dot" aria-hidden="true"></span><span class="ovf-who-n" data-ov-tip="In the last 30 minutes"${parts ? ` data-ov-tip-sub="${esc(parts)}"` : ''}><b>${fmtN(o.total)}</b> online</span>${o.people.length ? `<span class="ovf-who-faces" aria-hidden="true">${o.people.slice(0, 5).map((p) => face(p, 24)).join('')}</span>` : ''}`;
const when = `<p class="ovf-who-when">In the last 30 minutes${parts ? ` · ${parts}` : ''}</p>`;
const when = `<p class="ovf-who-when">In the last 30 minutes${parts ? ` · ${parts}` : ''}</p>`;
- const list = o.people.map((p) => `<li><a href="${NETWORK_URL}/@${encodeURIComponent(p.username)}">${face(p, 26)}<span${subjectAttr(p)}>${esc(p.name)}</span>${p.site ? `<small>${esc(p.site)}</small>` : ''}</a></li>`).join('');
- who = `<details class="ovf-who"><summary>${head}</summary>${when}${list ? `<ul class="ovf-who-list">${list}</ul>` : ''}${o.more > 0 ? `<p class="ovf-who-more">and ${fmtN(o.more)} more</p>` : ''}</details>`;
+ const list = o.people.map((p) => `<li${p.live ? ' class="is-live"' : ''}><a href="${p.live && p.liveUrl ? esc(p.liveUrl) : `${NETWORK_URL}/@${encodeURIComponent(p.username)}`}">${face(p, 26)}<span${subjectAttr(p)}>${esc(p.name)}</span>${p.live ? `<em class="ovf-who-live">Live${p.viewers > 0 ? ` · ${fmtN(p.viewers)}` : ''}</em>` : p.site ? `<small>${esc(p.site)}</small>` : ''}</a></li>`).join('');
+ // Today so far: the day's people and its peak, the peak's time in the reader's clock (theme-loader time).
+ const t = o.today;
+ const whole = (n) => (n < 100000 ? Number(n).toLocaleString('en-US') : fmtN(n)); // 1,043, not 1k
+ const today = t ? `<p class="ovf-who-today">Today${t.people > 0 ? ` <b>${whole(t.people)}</b> people${t.signedIn > 0 ? ` (${whole(t.signedIn)} signed in)` : ''} ·` : ''} peak <b>${whole(t.peak)}</b>${t.peakAt ? ` at <time datetime="${esc(t.peakAt)}" data-ov-time="time">${esc(t.peakAt.slice(11, 16))} UTC</time>` : ''}</p>` : '';
+ // Where the guests are: one bar split by site (hover or focus a part for what people do there), then the parts in
+ // words. Counts only, never anything about a guest.
+ const gs = o.guestSites || [];
+ const gsum = gs.reduce((a, g) => a + g.n, 0);
+ const split = gs.length && gsum > 0 ? `<div class="ovf-split" role="img" aria-label="Guests by site: ${esc(gs.map((g) => `${g.name} ${g.n}`).join(', '))}">${gs.map((g, i) => `<span class="ovf-split-s ovf-c${i}" data-grow="${g.n}" data-ov-tip="${esc(g.name)}: ${fmtN(g.n)} guest${g.n === 1 ? '' : 's'}" data-ov-tip-sub="${esc(g.doing)}"></span>`).join('')}</div>`
+ + `<ul class="ovf-split-l">${gs.map((g, i) => { const inner = `<i class="ovf-c${i}" aria-hidden="true"></i><b>${fmtN(g.n)}</b><span>${esc(g.name)}</span><small>${esc(g.doing)}</small>`; return `<li>${g.url ? `<a href="${esc(g.url)}">${inner}</a>` : `<span>${inner}</span>`}</li>`; }).join('')}</ul>` : '';
+ who = `<details class="ovf-who"><summary>${head}</summary>${when}${today}${split}${list ? `<ul class="ovf-who-list">${list}</ul>` : ''}${o.more > 0 ? `<p class="ovf-who-more">and ${fmtN(o.more)} more</p>` : ''}</details>`;
}
}
// Then a row of small tiles, each opening what it counts: live (only while someone is), the counts, this
// Then a row of small tiles, each opening what it counts: live (only while someone is), the counts, this
// site's own items, and the two facts the strip always had.
// site's own items, and the two facts the strip always had.
@@ -709,7 +742,10 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
@@ -709,7 +742,10 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
const live = o.live;
const live = o.live;
if (live && live.channels > 0) {
if (live && live.channels > 0) {
tiles.push(tile('https://openvibe.live/', ' is-live', `<span class="ovf-dot" aria-hidden="true"></span><b>${fmtN(live.channels)}</b><span>live now</span>${live.watching > 0 ? `<small>${fmtN(live.watching)} watching</small>` : ''}`));
tiles.push(tile('https://openvibe.live/', ' is-live', `<span class="ovf-dot" aria-hidden="true"></span><b>${fmtN(live.channels)}</b><span>live now</span>${live.watching > 0 ? `<small>${fmtN(live.watching)} watching</small>` : ''}`));
- live.first.forEach((c) => tiles.push(tile(c.url, ' is-channel', `<span>${esc(c.name)}</span>`))); // a channel is nobody: no effect to wear
+ // A channel whose streamer is in the list already says so on their own row (LIVE, red ring): not twice
+ // (11-14, the owner: "it shows Maticus twice").
+ const named = new Set(o.people.filter((p) => p.live && p.liveUrl).map((p) => p.liveUrl.toLowerCase()));
+ live.first.filter((c) => !named.has(String(c.url).toLowerCase())).forEach((c) => tiles.push(tile(c.url, ' is-channel', `<span>${esc(c.name)}</span>`))); // a channel is nobody: no effect to wear
}
}
o.counts.forEach((c) => tiles.push(row(c)));
o.counts.forEach((c) => tiles.push(row(c)));
o.items.forEach((i) => tiles.push(row(i)));
o.items.forEach((i) => tiles.push(row(i)));
@@ -721,7 +757,14 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
@@ -721,7 +757,14 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
function paintPulse() {
function paintPulse() {
if (!_mounted) return;
if (!_mounted) return;
const html = _online ? onlineHTML(_online) : (_pulse ? pulseHTML(_pulse) : '');
const html = _online ? onlineHTML(_online) : (_pulse ? pulseHTML(_pulse) : '');
- for (const el of _mounted.querySelectorAll('[data-ovf-pulse]')) { el.innerHTML = html; el.hidden = !html; }
+ for (const el of _mounted.querySelectorAll('[data-ovf-pulse]')) {
+ // Keep an open box open across the 60 s repaint (it used to snap shut under a reading finger).
+ const wasOpen = !!el.querySelector('details.ovf-who[open]');
+ el.innerHTML = html; el.hidden = !html;
+ if (wasOpen) { const d = el.querySelector('details.ovf-who'); if (d) d.open = true; }
+ el.querySelectorAll('[data-grow]').forEach((seg) => { seg.style.flexGrow = String(Number(seg.getAttribute('data-grow')) || 1); });
+ try { const L = typeof window !== 'undefined' && window.OpenVibeThemeLoader; if (L && L.time) L.time.upgrade(el); } catch { /* the UTC fallback stays */ }
+ }
}
}
function readPulse() {
function readPulse() {
if (typeof fetch === 'undefined' || (typeof document !== 'undefined' && document.hidden)) return;
if (typeof fetch === 'undefined' || (typeof document !== 'undefined' && document.hidden)) return;
@@ -17,6 +17,8 @@
@@ -17,6 +17,8 @@
* .ov-rail one row that scrolls sideways, items --item wide, snapping
* .ov-rail one row that scrolls sideways, items --item wide, snapping
* .ov-cover a box at least --min tall with one child (.is-centre) centred in it
* .ov-cover a box at least --min tall with one child (.is-centre) centred in it
* .ov-frame a box of --ratio that crops a picture or a video to fill it
* .ov-frame a box of --ratio that crops a picture or a video to fill it
+ * .ov-places a page's own places (My files, Shared links, Bin…): icon rows in a side column on a computer, a fixed
+ * row of icon tabs at the top on a phone (4.248.0). Put it first in the page so a phone shows it on top.
*
*
* The classless base (Pico's idea): put .ov-base on <body> or <main> and plain HTML (headings, text, links, lists,
* The classless base (Pico's idea): put .ov-base on <body> or <main> and plain HTML (headings, text, links, lists,
* quotes, code, tables, forms) looks finished on the site's theme, light or dark. Every selector is inside :where(),
* quotes, code, tables, forms) looks finished on the site's theme, light or dark. Every selector is inside :where(),
@@ -134,3 +136,49 @@
@@ -134,3 +136,49 @@
:where(.ov-base) :where(button:disabled, input:disabled, select:disabled, textarea:disabled) { opacity: .55; cursor: not-allowed; }
:where(.ov-base) :where(button:disabled, input:disabled, select:disabled, textarea:disabled) { opacity: .55; cursor: not-allowed; }
:where(.ov-base) :where(fieldset) { margin: 0 0 1em; padding: var(--space-xs) var(--space-s); border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius-sm); }
:where(.ov-base) :where(fieldset) { margin: 0 0 1em; padding: var(--space-xs) var(--space-s); border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius-sm); }
:where(.ov-base) :where(mark) { padding: 0 .2em; border-radius: 4px; background: color-mix(in srgb, var(--ovk-warn) 35%, transparent); color: inherit; }
:where(.ov-base) :where(mark) { padding: 0 .2em; border-radius: 4px; background: color-mix(in srgb, var(--ovk-warn) 35%, transparent); color: inherit; }
+
+/* ── .ov-places (4.248.0; the owner, 11-14, on Download: "move our tab to the top … a fixed tab system … add icons …
+ kind of like our main menu area … so the layout can be reused for pages like this"). One markup, two shapes:
+ <nav class="ov-places" aria-label="Places">
+ <a href="/files" aria-current="page"><svg …/><span>My files</span><em>4</em></a> …
+ <div class="ov-places-extra">…a meter, a button…</div>
+ </nav>
+ A computer: a column of icon rows (beside the content in an .ov-sidebar). A phone (< 861 px): a row of icon tabs
+ that stays under the bar while the page scrolls, the menu's tabs in small; the extra part is left for the page. */
+.ov-places { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+.ov-places > a, .ov-places > button {
+ position: relative; display: flex; align-items: center; gap: 10px; min-height: 42px; box-sizing: border-box;
+ padding: 8px 12px; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary, #b6bdd2);
+ font: inherit; font-weight: 600; text-decoration: none; cursor: pointer; text-align: left;
+ transition: background-color .15s ease, color .15s ease, scale .12s ease;
+}
+.ov-places > a:hover, .ov-places > button:hover { background: color-mix(in srgb, var(--text-primary, #f1f4fb) 6%, transparent); color: var(--text-primary, #f1f4fb); }
+.ov-places > a:active, .ov-places > button:active { scale: .97; }
+.ov-places > [aria-current="page"], .ov-places > [aria-selected="true"] {
+ background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); color: var(--text-primary, #f1f4fb);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent);
+}
+.ov-places > a > svg, .ov-places > button > svg, .ov-places > a > .ov-ic, .ov-places > button > .ov-ic { flex: none; width: 20px; height: 20px; color: var(--accent-light, var(--accent, #60a5fa)); }
+.ov-places > a > em, .ov-places > button > em {
+ margin-left: auto; padding: 1px 7px; border-radius: 999px; font-style: normal; font-size: .74rem; font-weight: 800;
+ font-variant-numeric: tabular-nums; color: var(--text-primary, #f1f4fb); background: color-mix(in srgb, var(--text-primary, #f1f4fb) 10%, transparent);
+}
+.ov-places-extra { margin-top: 12px; }
+@media (max-width: 860px) {
+ .ov-places {
+ position: sticky; top: var(--ovnav-real-h, 52px); z-index: 20; flex-direction: row; gap: 6px;
+ margin: 0 calc(-1 * var(--ov-gutter, 16px)); padding: 8px var(--ov-gutter, 16px);
+ overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none;
+ background: color-mix(in srgb, var(--bg-primary, #0b1220) 86%, transparent);
+ -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px);
+ }
+ .ov-places::-webkit-scrollbar { display: none; }
+ .ov-places > a, .ov-places > button {
+ flex: 1 0 auto; flex-direction: column; justify-content: center; gap: 4px; min-width: 76px; min-height: 58px;
+ padding: 8px 10px; font-size: .78rem; text-align: center; background: color-mix(in srgb, var(--text-primary, #f1f4fb) 5%, transparent);
+ }
+ .ov-places > a > em, .ov-places > button > em { position: absolute; top: 4px; right: 6px; margin: 0; font-size: .66rem; padding: 0 6px; }
+ .ov-places-extra { display: none; }
+}
+html[data-ov-motion=off] :is(.ov-places > a, .ov-places > button) { transition: none; }
+
@@ -321,8 +321,8 @@
@@ -321,8 +321,8 @@
.ovmb { display: block; overflow: visible; transition: filter .3s; }
.ovmb { display: block; overflow: visible; transition: filter .3s; }
.ovmb-bar { fill: transparent; stroke: currentColor; stroke-width: 1.7; }
.ovmb-bar { fill: transparent; stroke: currentColor; stroke-width: 1.7; }
.ovmb-dot { fill: currentColor; }
.ovmb-dot { fill: currentColor; }
- .ovmb-spark { fill: var(--ovo-b, #e879f9); }
- .ovmb-words { fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; opacity: .55; }
+ .ovmb-spark { fill: none; stroke: var(--ovo-a, var(--accent-light, #60a5fa)); stroke-width: 0; }
+ .ovmb-words { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; opacity: .6; }
.ovmb-app { fill: currentColor; opacity: .42; }
.ovmb-app { fill: currentColor; opacity: .42; }
.ovmb-lit { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }
.ovmb-lit { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }
.ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-words, .ovmb-spark, .ovmb-app, .ovmb-lit) { transform-box: fill-box; transform-origin: center; transition: transform .5s cubic-bezier(.2,1.3,.3,1), fill .3s, stroke-width .3s, opacity .25s; }
.ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-words, .ovmb-spark, .ovmb-app, .ovmb-lit) { transform-box: fill-box; transform-origin: center; transition: transform .5s cubic-bezier(.2,1.3,.3,1), fill .3s, stroke-width .3s, opacity .25s; }
@@ -406,8 +406,20 @@
@@ -406,8 +406,20 @@
:hover > .ovsi .ovsi-a { animation: none; opacity: 1; }
:hover > .ovsi .ovsi-a { animation: none; opacity: 1; }
html[data-ov-motion=off] .ovsi :is(.ovsi-p, .ovsi-a) { animation: none; }
html[data-ov-motion=off] .ovsi :is(.ovsi-p, .ovsi-a) { animation: none; }
.ovsi-o { opacity: 0; stroke-dasharray: 30 39; }
.ovsi-o { opacity: 0; stroke-dasharray: 30 39; }
- .is-going .ovsi-o { opacity: 1; animation: ovsi-go 1s linear infinite; }
- .is-going .ovsi-p { opacity: .55; }
+ /* 4.248.0 (the owner, 11-14: "the spinning circle should be on the outer rim … animated better"): signing in, a
+ comet of light runs around the button's rim (not inside its glyph), the glyph breathing within. */
+ .openvibe-navbar-login { position: relative; }
+ .openvibe-navbar-login.is-going::before { content: ''; position: absolute; inset: -4px; border-radius: 50%; pointer-events: none;
+ background: conic-gradient(from 0deg, transparent 0 52%, color-mix(in srgb, var(--accent-light, #93c5fd) 40%, transparent) 70%, var(--accent-light, #93c5fd) 92%, #fff 100%);
+ -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2.5px)); mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2.5px));
+ animation: ovsi-rim .85s linear infinite; filter: drop-shadow(0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent)); }
+ .is-going .ovsi-o { display: none; }
+ .is-going .ovsi-p { opacity: .85; animation: ovsi-breathe 1.1s ease-in-out infinite; transform-origin: 12px 12px; }
+ @keyframes ovsi-rim { to { transform: rotate(1turn); } }
+ @keyframes ovsi-breathe { 50% { opacity: .5; transform: scale(.9); } }
+ html[data-ov-motion=reduced] .openvibe-navbar-login.is-going::before { animation-duration: 1.6s; }
+ html[data-ov-motion=off] .openvibe-navbar-login.is-going::before { animation: none; background: color-mix(in srgb, var(--accent-light, #93c5fd) 70%, transparent); }
+ html[data-ov-motion=off] .is-going .ovsi-p { animation: none; }
@keyframes ovsi-p { 0%, 62%, 100% { opacity: 1; transform: none; } 70%, 92% { opacity: 0; transform: translateX(4px); } }
@keyframes ovsi-p { 0%, 62%, 100% { opacity: 1; transform: none; } 70%, 92% { opacity: 0; transform: translateX(4px); } }
@keyframes ovsi-a { 0%, 62%, 100% { opacity: 0; transform: translateX(-5px); } 70%, 92% { opacity: 1; transform: none; } }
@keyframes ovsi-a { 0%, 62%, 100% { opacity: 0; transform: translateX(-5px); } 70%, 92% { opacity: 1; transform: none; } }
@keyframes ovsi-go { to { stroke-dashoffset: -69; } }
@keyframes ovsi-go { to { stroke-dashoffset: -69; } }
@@ -624,7 +636,7 @@
@@ -624,7 +636,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': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': '0a397ea380cc', 'ov-share.css': 'd609a71f611a', 'search.js': 'dc2028f84893', 'dock.js': '0a657bac86b2', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '0f6074850291', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'f2e7c61e0934', 'soft.js': '98ab9b8080b1', 'ov-run.js': 'a9008daa5af5', 'ov-composer.js': '9b2a27f70f5f', 'items.js': 'a38f01f90c40', 'items.css': 'e917ec14cd4d' };
+ const SIBLING_REV = { 'ov-mark.js': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'd44fe4443e56', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '38d7cab3b420', 'ov-share.js': '0a397ea380cc', 'ov-share.css': 'd609a71f611a', 'search.js': '89f2b938b3ae', 'dock.js': '5f29e88f48dc', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'aebc7358ae27', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '7b0c89b39ac8', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'f2e7c61e0934', 'soft.js': '98ab9b8080b1', 'ov-run.js': '60b6e6de9f97', 'ov-composer.js': 'c29a98f058e6', 'items.js': 'a38f01f90c40', 'items.css': 'e917ec14cd4d' };
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.
@@ -1098,9 +1110,12 @@
@@ -1098,9 +1110,12 @@
// the same place, the one that closes it (rule opens-it-closes-it). One drawing for the bar, the menu's sheet and
// the same place, the one that closes it (rule opens-it-closes-it). One drawing for the bar, the menu's sheet and
// the Dock's (root.OpenVibeOrb); its styles are above (.ovmb). Class names kept from M57 (.ovmb-bar, .ovmb-dot for
// the Dock's (root.OpenVibeOrb); its styles are above (.ovmb). Class names kept from M57 (.ovmb-bar, .ovmb-dot for
// the face, .ovmb-app .ovmb-a0..2 for the tabs) so every caller and test still finds its parts.
// the face, .ovmb-app .ovmb-a0..2 for the tabs) so every caller and test still finds its parts.
+ // 4.248.0 (the owner, 11-14): the bar is the little input again, its dot on the left and a typed line in it (the
+ // spark that sat beside the dot read as two dots, and its pink is gone: every colour is the theme's); three tabs
+ // under it, the first lit.
const ORB_PARTS = '<rect class="ovmb-bar" x="3.2" y="4.3" width="17.6" height="7.2" rx="3.6"/>'
const ORB_PARTS = '<rect class="ovmb-bar" x="3.2" y="4.3" width="17.6" height="7.2" rx="3.6"/>'
- + '<circle class="ovmb-dot" cx="6.9" cy="7.9" r="1.5"/><path class="ovmb-words" d="M10 7.9h3.4"/>'
- + '<path class="ovmb-spark" d="M16.6 5.75l.62 1.53 1.53.62-1.53.62-.62 1.53-.62-1.53-1.53-.62 1.53-.62z"/>'
+ + '<circle class="ovmb-dot" cx="6.9" cy="7.9" r="1.5"/><path class="ovmb-words" d="M10 7.9h7.2"/>'
+ + '<path class="ovmb-spark" d="M17.2 7.9h0"/>'
+ '<rect class="ovmb-app ovmb-a0" x="3" y="14.7" width="5.2" height="5" rx="1.6"/><rect class="ovmb-app ovmb-a1" x="9.4" y="14.7" width="5.2" height="5" rx="1.6"/><rect class="ovmb-app ovmb-a2" x="15.8" y="14.7" width="5.2" height="5" rx="1.6"/>'
+ '<rect class="ovmb-app ovmb-a0" x="3" y="14.7" width="5.2" height="5" rx="1.6"/><rect class="ovmb-app ovmb-a1" x="9.4" y="14.7" width="5.2" height="5" rx="1.6"/><rect class="ovmb-app ovmb-a2" x="15.8" y="14.7" width="5.2" height="5" rx="1.6"/>'
+ '<rect class="ovmb-lit" x="3" y="14.7" width="5.2" height="5" rx="1.6"/>';
+ '<rect class="ovmb-lit" x="3" y="14.7" width="5.2" height="5" rx="1.6"/>';
function orbSVG(size = 22, open = false) {
function orbSVG(size = 22, open = false) {
@@ -1969,6 +1984,11 @@
@@ -1969,6 +1984,11 @@
let _authInFlight = null;
let _authInFlight = null;
let _hintPainted = false, _hintSettled = false;
let _hintPainted = false, _hintSettled = false;
+ /** The hint the site's own server set (a sign-out writes 'guest' here), as opposed to one this script kept. */
+ function cookieHint() {
+ const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_sso_hint=([^;]*)/);
+ return m ? m[1] : null;
+ }
function ssoHint() {
function ssoHint() {
const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_sso_hint=([^;]*)/);
const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_sso_hint=([^;]*)/);
if (m) return m[1];
if (m) return m[1];
@@ -2021,7 +2041,12 @@
@@ -2021,7 +2041,12 @@
*/
*/
function maybeSilentLogin() {
function maybeSilentLogin() {
if (!_config.silentLogin || typeof location === 'undefined') return false;
if (!_config.silentLogin || typeof location === 'undefined') return false;
- if (ssoHint() === 'guest') return false;
+ // A sign-out here (the 'guest' cookie) is final. A round trip that once found no session (the 'guest' kept in
+ // storage: a visit before signing in) is not: arriving from another OpenVibe site, where the person may well be
+ // signed in now, earns one quiet round trip (11-14, the owner: openvibe.tours showed Sign in though every other
+ // site had them signed in). The 10-minute limit below still holds.
+ if (cookieHint() === 'guest') return false;
+ if (ssoHint() === 'guest' && !fromOpenVibe()) return false;
if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false;
if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false;
if (/[?&]sso=none\b/.test(location.search)) return false;
if (/[?&]sso=none\b/.test(location.search)) return false;
try {
try {
@@ -3059,6 +3084,51 @@
@@ -3059,6 +3084,51 @@
_defaults: { linksFor },
_defaults: { linksFor },
};
};
+ // ── Rows that scroll sideways say so (4.248.0; the owner, 11-14: "it waits a sec and then … does a little animation
+ // so that it's apparent that it can scroll"). Every OpenVibe page: a row wider than its box (a rail, chips, tabs,
+ // pills, a strip, or anything marked data-ov-peek), the first time most of it is on screen, waits, then peeks a
+ // little way and back. Once per row per page; never while a finger or the pointer is on it; Calm peeks less, Off
+ // not at all; data-ov-peek="off" opts a row out. Design rule: a row that scrolls sideways shows it (a peek and a fade).
+ function startPeeks() {
+ if (typeof document === 'undefined' || typeof IntersectionObserver !== 'function' || root.__ovPeeks) return;
+ root.__ovPeeks = true;
+ const seen = new WeakSet();
+ const motion = () => document.documentElement.getAttribute('data-ov-motion') || '';
+ const wide = (el) => { try { if (el.scrollWidth - el.clientWidth < 24 || el.scrollLeft > 4) return false; const o = getComputedStyle(el).overflowX; return o === 'auto' || o === 'scroll'; } catch { return false; } };
+ const io = new IntersectionObserver((entries) => {
+ for (const e of entries) {
+ if (!e.isIntersecting || e.intersectionRatio < 0.6) continue;
+ const el = e.target; io.unobserve(el);
+ if (motion() === 'off' || !wide(el)) continue;
+ let touched = false; const stop = () => { touched = true; };
+ el.addEventListener('pointerdown', stop, { once: true, passive: true }); el.addEventListener('wheel', stop, { once: true, passive: true });
+ setTimeout(() => {
+ if (touched || !el.isConnected || el.scrollLeft > 4 || el.matches(':hover')) return;
+ const far = Math.min(motion() === 'reduced' ? 22 : 56, el.scrollWidth - el.clientWidth);
+ try { el.scrollTo({ left: far, behavior: 'smooth' }); } catch { el.scrollLeft = far; return; }
+ setTimeout(() => { if (!touched && Math.abs(el.scrollLeft - far) < 6) { try { el.scrollTo({ left: 0, behavior: 'smooth' }); } catch { el.scrollLeft = 0; } } }, 520);
+ }, 700);
+ }
+ }, { threshold: [0.6] });
+ const SEL = '[data-ov-peek]:not([data-ov-peek="off"]), [class*="rail"], [class*="chips"], [class*="tabs"], [class*="pills"], [class*="strip"], [class*="carousel"], [class*="scroller"]';
+ let queued = false;
+ const scan = () => {
+ queued = false;
+ let n = 0;
+ document.querySelectorAll(SEL).forEach((el) => {
+ if (n > 60 || seen.has(el) || el.getAttribute('data-ov-peek') === 'off' || el.closest('.openvibe-navbar, .ovs-panel, [role=dialog]')) return;
+ seen.add(el); n++;
+ if (wide(el)) io.observe(el);
+ });
+ };
+ const later = () => { if (queued) return; queued = true; (root.requestIdleCallback || ((f) => setTimeout(f, 300)))(scan, { timeout: 1500 }); };
+ later();
+ if (typeof MutationObserver === 'function' && document.body) new MutationObserver(later).observe(document.body, { childList: true, subtree: true });
+ }
+ if (typeof document !== 'undefined' && typeof window !== 'undefined' && !(typeof module !== 'undefined' && module.exports)) {
+ if (document.readyState === 'complete') setTimeout(startPeeks, 1200); else window.addEventListener('load', () => setTimeout(startPeeks, 1200), { once: true });
+ }
+
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
else {
else {
root.OpenVibeNavbar = OpenVibeNavbar;
root.OpenVibeNavbar = OpenVibeNavbar;
@@ -236,8 +236,16 @@
@@ -236,8 +236,16 @@
.openvibe-notif-dock .openvibe-nv-unread svg { width: 18px; height: 18px; }
.openvibe-notif-dock .openvibe-nv-unread svg { width: 18px; height: 18px; }
.openvibe-notif-dock .openvibe-nv-unread[aria-pressed="true"], .openvibe-notif-dock .openvibe-nv-unread:hover, .openvibe-notif-dock .openvibe-nv-search:hover { opacity: 1; }
.openvibe-notif-dock .openvibe-nv-unread[aria-pressed="true"], .openvibe-notif-dock .openvibe-nv-unread:hover, .openvibe-notif-dock .openvibe-nv-search:hover { opacity: 1; }
html[data-ov-motion=off] .openvibe-notif-dock .openvibe-nv-search.is-open { animation: none; }
html[data-ov-motion=off] .openvibe-notif-dock .openvibe-nv-search.is-open { animation: none; }
- .openvibe-notif-dock .openvibe-nv-find .openvibe-nv-search:not(.is-open), .openvibe-notif-dock .openvibe-nv-unread { width: 44px; height: 44px; }
- .openvibe-notif-dock .openvibe-nv-search:not(.is-open) input { width: 44px; }
+ /* 4.248.0 (the owner, 11-14: "these icons are offset … waste of space"): two 40 px disks, their glyphs centred on a
+ grid, on the tabs' own line; the row sits right under the switch above it. */
+ .openvibe-notif-dock .openvibe-nv-find .openvibe-nv-search:not(.is-open), .openvibe-notif-dock .openvibe-nv-unread { width: 40px; height: 40px; }
+ .openvibe-notif-dock .openvibe-nv-search:not(.is-open) input { width: 40px; }
+ .openvibe-notif-dock .openvibe-nv-search:not(.is-open) > svg { left: 50%; transform: translate(-50%, -50%); }
+ .openvibe-notif-dock .openvibe-nv-unread { display: grid; place-items: center; }
+ .openvibe-notif-dock .openvibe-nv-unread svg { display: block; margin: 0; }
+ .openvibe-notif-dock .openvibe-nv-find { padding-bottom: 0; align-self: center; gap: 4px; }
+ .openvibe-notif-dock .openvibe-nv-tools { padding: 0 8px 0 10px; gap: 4px; min-height: 44px; }
+ .openvibe-notif-dock .openvibe-nv-search, .openvibe-notif-dock .openvibe-nv-unread { box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 3px 8px -5px rgba(0,0,0,.6); }
.openvibe-notif-dock .openvibe-nv-search > svg { left: 11px; width: 22px; height: 22px; }
.openvibe-notif-dock .openvibe-nv-search > svg { left: 11px; width: 22px; height: 22px; }
.openvibe-notif-dock .openvibe-nv-unread svg { width: 22px; height: 22px; }
.openvibe-notif-dock .openvibe-nv-unread svg { width: 22px; height: 22px; }
.openvibe-notif-dock .openvibe-nv-unread { gap: 0; } /* its word is hidden (font-size 0): no gap may push the glyph off centre */
.openvibe-notif-dock .openvibe-nv-unread { gap: 0; } /* its word is hidden (font-size 0): no gap may push the glyph off centre */
@@ -295,7 +303,22 @@
@@ -295,7 +303,22 @@
.openvibe-nv-list { flex: 1; overflow-y: auto; padding: 2px 0 6px; overscroll-behavior: contain; }
.openvibe-nv-list { flex: 1; overflow-y: auto; padding: 2px 0 6px; overscroll-behavior: contain; }
.openvibe-nv-day { position: sticky; top: 0; z-index: 1; padding: 10px 16px 6px; font-size: 11.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted, #8b8b96); background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }
.openvibe-nv-day { position: sticky; top: 0; z-index: 1; padding: 10px 16px 6px; font-size: 11.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted, #8b8b96); background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }
/* A row: the opaque .ovn-row over .ovn-under, which a swipe uncovers (Read on the left, Clear on the right). */
/* A row: the opaque .ovn-row over .ovn-under, which a swipe uncovers (Read on the left, Clear on the right). */
- .openvibe-notif-item { position: relative; margin: 0 6px; border-radius: 14px; overflow: hidden; cursor: pointer; }
+ .openvibe-notif-item { position: relative; margin: 0 6px 6px; border-radius: 14px; overflow: hidden; cursor: pointer; }
+ /* 4.248.0 (the owner, 11-14: "no spacing between them … confusing to have an X … more like social media"): every row
+ the same card with room between, the face larger, unread a tint and a dot at the right, a ⋯ menu instead of a
+ bare X, buttons a notice brings (Approve, Deny, Watch). */
+ .openvibe-notif-item .ovn-row { background: color-mix(in srgb, var(--text-primary, #ececf1) 3%, var(--ovn-bg, #1d1f27)); }
+ .openvibe-notif-item.is-menu { overflow: visible; z-index: 3; }
+ .openvibe-notif-item .ovn-menu { position: absolute; right: 8px; top: 42px; z-index: 4; min-width: 220px; padding: 6px; border-radius: 14px; background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 14px 40px -12px rgba(0,0,0,.6); display: grid; gap: 2px; }
+ .openvibe-notif-item .ovn-menu[hidden] { display: none; }
+ .openvibe-notif-item .ovn-menu button { display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 8px 10px; border: 0; border-radius: 10px; background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 13.5px; font-weight: 600; text-align: left; cursor: pointer; }
+ .openvibe-notif-item .ovn-menu button:hover, .openvibe-notif-item .ovn-menu button:focus-visible { background: var(--bg-hover, rgba(255,255,255,.08)); outline: 0; }
+ .openvibe-notif-item .ovn-menu button[data-row="dismiss"] { color: var(--danger-text, #f87171); }
+ .openvibe-notif-item .ovn-menu svg { width: 17px; height: 17px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+ .openvibe-notif-item .ovn-acts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
+ .openvibe-notif-item .ovn-act { font: inherit; font-size: 12.5px; font-weight: 700; min-height: 32px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.14)); background: transparent; color: var(--text-primary, #ececf1); cursor: pointer; }
+ .openvibe-notif-item .ovn-act.is-primary { border-color: transparent; background: var(--accent-strong, var(--accent, #3b82f6)); color: var(--on-accent-strong, #fff); }
+ .openvibe-notif-item .ovn-act.is-danger { color: var(--danger-text, #f87171); border-color: color-mix(in srgb, var(--danger, #ef4444) 40%, transparent); }
.openvibe-notif-item .ovn-row { position: relative; z-index: 1; display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; padding: 10px 14px 10px 16px; border-radius: 14px;
.openvibe-notif-item .ovn-row { position: relative; z-index: 1; display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; padding: 10px 14px 10px 16px; border-radius: 14px;
background: var(--ovn-bg, var(--bg-elevated, #1d1f27)); transition: background .15s, transform .2s ease; touch-action: pan-y; outline: none; }
background: var(--ovn-bg, var(--bg-elevated, #1d1f27)); transition: background .15s, transform .2s ease; touch-action: pan-y; outline: none; }
.openvibe-notif-item .ovn-row.has-thumb { grid-template-columns: 38px minmax(0, 1fr) 56px; }
.openvibe-notif-item .ovn-row.has-thumb { grid-template-columns: 38px minmax(0, 1fr) 56px; }
@@ -312,7 +335,7 @@
@@ -312,7 +335,7 @@
.openvibe-notif-item[data-swipe="read"] .ovn-under .clear, .openvibe-notif-item[data-swipe="clear"] .ovn-under .read { display: none; }
.openvibe-notif-item[data-swipe="read"] .ovn-under .clear, .openvibe-notif-item[data-swipe="clear"] .ovn-under .read { display: none; }
.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; padding-right: 26px; }
/* 4.223.0: the title and its count are one run of text (the count overlapped the words when the title was a box). */
/* 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 { 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 .t { display: inline; }
@@ -322,8 +345,10 @@
@@ -322,8 +345,10 @@
.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; }
.openvibe-notif-item .meta { font-size: 11.5px; color: var(--text-muted, #8b8b96); margin-top: 5px; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.openvibe-notif-item .meta { font-size: 11.5px; color: var(--text-muted, #8b8b96); margin-top: 5px; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.openvibe-notif-item .meta .dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .6; }
.openvibe-notif-item .meta .dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .6; }
- .openvibe-notif-item .unread-dot { position: absolute; left: 5px; top: 26px; width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); }
- .openvibe-notif-item .row-actions { margin-left: auto; display: flex; gap: 2px; opacity: 0; transition: opacity .15s; }
+ .openvibe-notif-item .unread-dot { position: absolute; right: 14px; top: 50%; margin-top: -5px; width: 10px; height: 10px; border-radius: 50%; background: var(--accent, #3b82f6); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }
+ .openvibe-notif-item.unread .ovn-row { padding-right: 34px; }
+ .openvibe-notif-item .row-actions { position: absolute; right: 6px; top: 6px; z-index: 2; display: flex; gap: 2px; opacity: 0; transition: opacity .15s; }
+ .openvibe-notif-item.unread .row-actions { right: 30px; }
.openvibe-notif-item:hover .row-actions, .openvibe-notif-item:focus-within .row-actions { opacity: 1; }
.openvibe-notif-item:hover .row-actions, .openvibe-notif-item:focus-within .row-actions { opacity: 1; }
/* The row's buttons sit on its time line, never over its text; touch screens always show them. */
/* The row's buttons sit on its time line, never over its text; touch screens always show them. */
@media (hover: none), (pointer: coarse) { .openvibe-notif-item .row-actions { opacity: 1; } }
@media (hover: none), (pointer: coarse) { .openvibe-notif-item .row-actions { opacity: 1; } }
@@ -532,6 +557,14 @@
@@ -532,6 +557,14 @@
if (img) return `<span class="ovn-glyph ovn-avatar" style="--ovn-tone:${tone}"><img src="${esc(img)}" alt="" loading="lazy" onerror="this.remove()"><span class="ovn-badge">${svg(GLYPH[p.glyph] || GLYPH.bell)}</span></span>`;
if (img) return `<span class="ovn-glyph ovn-avatar" style="--ovn-tone:${tone}"><img src="${esc(img)}" alt="" loading="lazy" onerror="this.remove()"><span class="ovn-badge">${svg(GLYPH[p.glyph] || GLYPH.bell)}</span></span>`;
return `<span class="ovn-glyph" style="--ovn-tone:${tone}">${svg(GLYPH[p.glyph] || GLYPH.bell)}</span>`;
return `<span class="ovn-glyph" style="--ovn-tone:${tone}">${svg(GLYPH[p.glyph] || GLYPH.bell)}</span>`;
}
}
+ /** Buttons a notice carries (rich_content.actions: [{ label, url, style: 'primary'|'danger'|'' }], https only, at most
+ * three): Approve and Deny on an approval, Watch on a live notice (4.248.0; the owner, 11-14: "the approval system can
+ * go through here"). A press opens its address; the service on the other end does the action. */
+ function actsHtml(n) {
+ const acts = (Array.isArray(richOf(n).actions) ? richOf(n).actions : []).filter((a) => a && typeof a.label === 'string' && a.label.trim() && /^https:\/\//.test(String(a.url || ''))).slice(0, 3);
+ if (!acts.length) return '';
+ return `<div class="ovn-acts">${acts.map((a) => `<button type="button" class="ovn-act${a.style === 'primary' ? ' is-primary' : a.style === 'danger' ? ' is-danger' : ''}" data-row="go" data-url="${esc(a.url)}">${esc(String(a.label).slice(0, 28))}</button>`).join('')}</div>`;
+ }
function chipsHtml(p, extra = []) {
function chipsHtml(p, extra = []) {
const sev = p.severity ? [`<span class="ovn-chip sev" style="--ovn-tone:${TONE[p.tone] || TONE.accent}">${esc(p.severity)}</span>`] : [];
const sev = p.severity ? [`<span class="ovn-chip sev" style="--ovn-tone:${TONE[p.tone] || TONE.accent}">${esc(p.severity)}</span>`] : [];
const all = sev.concat((p.chips || []).concat(extra).map(c => `<span class="ovn-chip">${esc(c)}</span>`));
const all = sev.concat((p.chips || []).concat(extra).map(c => `<span class="ovn-chip">${esc(c)}</span>`));
@@ -930,6 +963,8 @@
@@ -930,6 +963,8 @@
gear: GLYPH.gear,
gear: GLYPH.gear,
open: '<path d="M14 4h6v6M20 4l-9 9M19 14v5a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 4 19V7a1.5 1.5 0 0 1 1.5-1.5H10"/>',
open: '<path d="M14 4h6v6M20 4l-9 9M19 14v5a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 4 19V7a1.5 1.5 0 0 1 1.5-1.5H10"/>',
read: '<path d="M5 12.6l4.6 4.6L19 7.6"/>',
read: '<path d="M5 12.6l4.6 4.6L19 7.6"/>',
+ more: '<circle cx="5.5" cy="12" r="1.2"/><circle cx="12" cy="12" r="1.2"/><circle cx="18.5" cy="12" r="1.2"/>',
+ trash: '<path d="M5 7h14M10 7V5h4v2M7 7l1 12h8l1-12"/>',
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
empty: '<path d="M12 3.5a1.22 1.22 0 0 1 1.22 1.22v0.47a5.44 5.44 0 0 1 4.22 5.25v3.38c0 0.84 0.28 1.59 0.84 2.25l0.75 0.84a0.75 0.75 0 0 1 -0.56 1.31H5.53a0.75 0.75 0 0 1 -0.56 -1.31l0.75 -0.84c0.56 -0.66 0.84 -1.41 0.84 -2.25v-3.38a5.44 5.44 0 0 1 4.22 -5.25v-0.47A1.22 1.22 0 0 1 12 3.5ZM9.94 18.97a2.16 2.16 0 0 0 4.13 0"/><path class="ovn-wave" d="M3.84 9.31a8.63 8.63 0 0 1 2.34 -4.6M20.16 9.31a8.63 8.63 0 0 0 -2.34 -4.6"/>',
empty: '<path d="M12 3.5a1.22 1.22 0 0 1 1.22 1.22v0.47a5.44 5.44 0 0 1 4.22 5.25v3.38c0 0.84 0.28 1.59 0.84 2.25l0.75 0.84a0.75 0.75 0 0 1 -0.56 1.31H5.53a0.75 0.75 0 0 1 -0.56 -1.31l0.75 -0.84c0.56 -0.66 0.84 -1.41 0.84 -2.25v-3.38a5.44 5.44 0 0 1 4.22 -5.25v-0.47A1.22 1.22 0 0 1 12 3.5ZM9.94 18.97a2.16 2.16 0 0 0 4.13 0"/><path class="ovn-wave" d="M3.84 9.31a8.63 8.63 0 0 1 2.34 -4.6M20.16 9.31a8.63 8.63 0 0 0 -2.34 -4.6"/>',
};
};
@@ -1026,6 +1061,16 @@
@@ -1026,6 +1061,16 @@
const act = e.target.closest('[data-row]');
const act = e.target.closest('[data-row]');
if (act) {
if (act) {
e.stopPropagation();
e.stopPropagation();
+ const menu = item.querySelector('.ovn-menu');
+ if (act.dataset.row === 'more') {
+ const open = menu && menu.hidden;
+ v.root.querySelectorAll('.openvibe-notif-item.is-menu').forEach((x) => { x.classList.remove('is-menu'); const m = x.querySelector('.ovn-menu'); if (m) m.hidden = true; });
+ if (menu && open) { menu.hidden = false; item.classList.add('is-menu'); const first = menu.querySelector('button'); if (first) first.focus({ preventScroll: true }); }
+ return;
+ }
+ if (menu) { menu.hidden = true; item.classList.remove('is-menu'); }
+ if (act.dataset.row === 'go') { if (ids.length) markRead(ids); navigateTo(act.dataset.url); return; }
+ if (act.dataset.row === 'settings') { navigateTo('https://openvibe.network/settings#notifications'); return; }
if (act.dataset.row === 'dismiss') ids.forEach(x => dismiss(v, x));
if (act.dataset.row === 'dismiss') ids.forEach(x => dismiss(v, x));
else if (act.dataset.row === 'read') markRead(ids);
else if (act.dataset.row === 'read') markRead(ids);
else if (act.dataset.row === 'copy') copyText(act.dataset.cmd || '', act);
else if (act.dataset.row === 'copy') copyText(act.dataset.cmd || '', act);
@@ -1137,7 +1182,10 @@
@@ -1137,7 +1182,10 @@
${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"><button type="button" data-row="dismiss" title="Clear this notice" aria-label="Clear this notice">${svg(ICON.close)}</button></div></div>
+ ${actsHtml(n)}
+ <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${when}</span>${label}</div>
+ <div class="row-actions"><button type="button" data-row="more" title="More" aria-label="More for this notice" aria-haspopup="menu">${svg(ICON.more)}</button></div>
+ <div class="ovn-menu" role="menu" hidden>${unread ? '<button type="button" role="menuitem" data-row="read">' + svg(ICON.read) + '<span>Mark as read</span></button>' : ''}<button type="button" role="menuitem" data-row="dismiss">${svg(ICON.trash)}<span>Remove this notice</span></button><button type="button" role="menuitem" data-row="settings">${svg(ICON.gear)}<span>Notification settings</span></button></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>
@@ -169,6 +169,37 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
@@ -169,6 +169,37 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
.ovc-v2 .ovt-send:disabled { opacity: .45; cursor: default; }
.ovc-v2 .ovt-send:disabled { opacity: .45; cursor: default; }
.ovc-v2 .ovt-send--round { display: grid; place-items: center; width: 40px; min-width: 40px; height: 40px; padding: 0; border-radius: 50%; }
.ovc-v2 .ovt-send--round { display: grid; place-items: center; width: 40px; min-width: 40px; height: 40px; padding: 0; border-radius: 50%; }
.ovc-v2 .ovt-send--round svg { width: 19px; height: 19px; }
.ovc-v2 .ovt-send--round svg { width: 19px; height: 19px; }
+/* ── 4.248.0, one button system for every box (the owner, 11-14: "How is the centering and offset of icons still such a
+ problem?", "give them that more 3D look … the post button … completely customized … an animation"). Design rule
+ (openvibe.design/rules): an icon button is a square box, display:grid and place-items:center, its glyph a block of
+ a fixed size with no margin, never nudged; a hidden tool is gone (display:none) wherever the box sits. ── */
+.ovc-v2 [hidden], .ovt-composer [hidden] { display: none !important; }
+.ovc-v2 .ovt-c-tools .ovt-btn { box-sizing: border-box; width: 40px; min-width: 40px; height: 40px; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center;
+ color: var(--accent-light, var(--ovt-accent, #60a5fa));
+ background: linear-gradient(180deg, color-mix(in srgb, var(--text-primary, #e6edf7) 12%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent));
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.16), inset 0 -1px 0 rgba(0,0,0,.3), 0 4px 10px -5px rgba(0,0,0,.6);
+ transition: translate .15s ease, box-shadow .15s ease, color .15s ease; }
+.ovc-v2 .ovt-c-tools .ovt-btn > svg, .ovc-v2 .ovt-c-tools .ovt-btn > .ovt-ic { display: block; width: 20px; height: 20px; margin: 0; flex: none; }
+.ovc-v2 .ovt-c-tools .ovt-btn:hover { translate: 0 -1px; color: var(--text-primary, #e6edf7); box-shadow: inset 0 1px 0 rgba(255,255,255,.22), inset 0 -1px 0 rgba(0,0,0,.3), 0 8px 16px -7px rgba(0,0,0,.65); }
+.ovc-v2 .ovt-c-tools .ovt-btn:active { translate: 0 1px; box-shadow: inset 0 2px 5px rgba(0,0,0,.4); }
+html[data-theme-mode=light] .ovc-v2 .ovt-c-tools .ovt-btn { background: linear-gradient(180deg, #fff, color-mix(in srgb, var(--text-primary, #0f172a) 6%, #fff)); box-shadow: inset 0 -1px 0 rgba(15,23,42,.08), 0 1px 2px rgba(15,23,42,.12), 0 4px 10px -6px rgba(15,23,42,.25); color: var(--accent, #2563eb); }
+/* Send: a button of its own, lit from above, a shine that crosses it on hover, a press you can feel, the arrow lifting. */
+.ovc-v2 .ovt-send { position: relative; isolation: isolate; overflow: hidden; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; padding: 0 20px;
+ color: #fff; letter-spacing: .01em;
+ background: linear-gradient(160deg, color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 72%, #fff), var(--ovt-accent, var(--accent, #3b82f6)) 48%, color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 78%, #000));
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.38), inset 0 -2px 0 rgba(0,0,0,.22), 0 10px 22px -10px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 85%, transparent);
+ transition: translate .15s ease, scale .12s ease, box-shadow .2s ease, filter .2s ease; }
+.ovc-v2 .ovt-send::after { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; background: linear-gradient(110deg, transparent 30%, rgba(255,255,255,.38) 50%, transparent 70%); translate: -130% 0; transition: translate .7s cubic-bezier(.2,.8,.2,1); }
+.ovc-v2 .ovt-send:not(:disabled):hover { translate: 0 -1px; box-shadow: inset 0 1px 0 rgba(255,255,255,.45), inset 0 -2px 0 rgba(0,0,0,.22), 0 14px 26px -10px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 95%, transparent); }
+.ovc-v2 .ovt-send:not(:disabled):hover::after { translate: 130% 0; }
+.ovc-v2 .ovt-send:not(:disabled):hover svg { translate: 0 -2px; }
+.ovc-v2 .ovt-send svg { transition: translate .2s cubic-bezier(.3,1.6,.4,1); }
+.ovc-v2 .ovt-send:not(:disabled):active { translate: 0 1px; scale: .97; transform: none; box-shadow: inset 0 2px 6px rgba(0,0,0,.3); }
+.ovc-v2 .ovt-send:disabled { opacity: 1; filter: saturate(.25) brightness(.72); box-shadow: inset 0 1px 0 rgba(255,255,255,.14); cursor: default; }
+.ovc-v2 .ovt-send--round { width: 40px; min-width: 40px; height: 40px; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center; }
+.ovc-v2 .ovt-send--round svg { display: block; width: 18px; height: 18px; margin: 0; }
+.ovc-v2[data-preset="chat"] .ovt-c-row { margin-top: 0; } /* one line: the buttons sit in its middle, not 2 px low */
+html[data-ov-motion=off] .ovc-v2 .ovt-send::after { display: none; } html[data-ov-motion=off] .ovc-v2 :is(.ovt-send, .ovt-btn, .ovt-send svg) { transition: none; translate: none; }
.ovc-cam { padding: 0; border: 0; border-radius: 22px; width: min(560px, 96vw); max-height: 94vh; overflow: hidden; background: #05070c; color: #fff; display: flex; flex-direction: column; }
.ovc-cam { padding: 0; border: 0; border-radius: 22px; width: min(560px, 96vw); max-height: 94vh; overflow: hidden; background: #05070c; color: #fff; display: flex; flex-direction: column; }
.ovc-cam::backdrop { background: rgba(2, 6, 14, .72); }
.ovc-cam::backdrop { background: rgba(2, 6, 14, .72); }
.ovc-cam-stage { position: relative; flex: 1; min-height: 0; background: #000; }
.ovc-cam-stage { position: relative; flex: 1; min-height: 0; background: #000; }
@@ -443,7 +474,7 @@ html[data-ov-motion=off] .ovc-tray { animation: none; transition: none; }
@@ -443,7 +474,7 @@ html[data-ov-motion=off] .ovc-tray { animation: none; transition: none; }
/* 4.236.0 (the owner, 11-13: "improve the send button … the icon's all screwed up"): with nothing to send it is a quiet
/* 4.236.0 (the owner, 11-13: "improve the send button … the icon's all screwed up"): with nothing to send it is a quiet
outline round its arrow, not a grey disc; the moment there is, it fills with the accent on a spring, and the arrow
outline round its arrow, not a grey disc; the moment there is, it fills with the accent on a spring, and the arrow
lifts under a pointer and on a press. */
lifts under a pointer and on a press. */
-.ovc-v2 .ovt-send--round:disabled { opacity: 1; background: transparent; color: var(--text-muted, #7386a3); box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--text-primary, #e6edf7) 16%, transparent); }
+.ovc-v2 .ovt-send--round:disabled { opacity: 1; filter: none; background: linear-gradient(180deg, color-mix(in srgb, var(--text-primary, #e6edf7) 12%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent)); color: var(--text-muted, #7386a3); box-shadow: inset 0 1px 0 rgba(255,255,255,.14), inset 0 -1px 0 rgba(0,0,0,.3), 0 4px 10px -5px rgba(0,0,0,.6); } /* 4.248.0: a dim disk like its neighbours, never an outline */
.ovc-v2 .ovt-send--round:not(:disabled) { animation: ovcSendReady .32s cubic-bezier(.34, 1.56, .64, 1); }
.ovc-v2 .ovt-send--round:not(:disabled) { animation: ovcSendReady .32s cubic-bezier(.34, 1.56, .64, 1); }
@keyframes ovcSendReady { from { transform: scale(.8); } }
@keyframes ovcSendReady { from { transform: scale(.8); } }
@media (hover: hover) and (pointer: fine) { .ovc-v2 .ovt-send--round:not(:disabled):hover svg { transform: translateY(-2px); } }
@media (hover: hover) and (pointer: fine) { .ovc-v2 .ovt-send--round:not(:disabled):hover svg { transform: translateY(-2px); } }
@@ -190,16 +190,16 @@ html.ov-sel-locked, html.ov-sel-locked body { overflow: hidden; overscroll-behav
@@ -190,16 +190,16 @@ html.ov-sel-locked, html.ov-sel-locked body { overflow: hidden; overscroll-behav
border-color: color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent);
border-color: color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent);
}
}
-:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"], input[type="radio"]):where(:not([data-ov-native], [data-ov-native] *, :disabled)):active { transform: scale(.88); }
+:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"], input[type="radio"]):where(:not([data-ov-native], [data-ov-native] *, :disabled)):active { scale: .88; }
:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"]:not([role="switch"]), input[type="radio"]):where(:not([data-ov-native], [data-ov-native] *)):focus-visible {
:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"]:not([role="switch"]), input[type="radio"]):where(:not([data-ov-native], [data-ov-native] *)):focus-visible {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 28%, transparent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 28%, transparent);
}
}
:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"]:not([role="switch"]), input[type="radio"]):where(:not([data-ov-native], [data-ov-native] *)):disabled { opacity: .5; cursor: not-allowed; }
:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"]:not([role="switch"]), input[type="radio"]):where(:not([data-ov-native], [data-ov-native] *)):disabled { opacity: .5; cursor: not-allowed; }
/* Buttons answer a press; text fields glow with focus (a site's own button or field rule wins over these). */
/* Buttons answer a press; text fields glow with focus (a site's own button or field rule wins over these). */
:where(html:not([data-ov-forms="off"])) :where(button, input[type="button"], input[type="submit"], input[type="reset"], a.btn, .btn, [role="button"]):where(:not([data-ov-native], [data-ov-native] *, :disabled)) {
:where(html:not([data-ov-forms="off"])) :where(button, input[type="button"], input[type="submit"], input[type="reset"], a.btn, .btn, [role="button"]):where(:not([data-ov-native], [data-ov-native] *, :disabled)) {
- transition: transform .12s ease, box-shadow .18s ease, background-color .16s ease, border-color .16s ease, color .16s ease;
+ transition: scale .12s ease, box-shadow .18s ease, background-color .16s ease, border-color .16s ease, color .16s ease;
}
}
-:where(html:not([data-ov-forms="off"])) :where(button, input[type="button"], input[type="submit"], input[type="reset"], a.btn, .btn, [role="button"]):where(:not([data-ov-native], [data-ov-native] *, :disabled)):active { transform: scale(.97); }
+:where(html:not([data-ov-forms="off"])) :where(button, input[type="button"], input[type="submit"], input[type="reset"], a.btn, .btn, [role="button"]):where(:not([data-ov-native], [data-ov-native] *, :disabled)):active { scale: .97; } /* `scale`, not `transform`: a button placed with a transform (centered, slid in) keeps its place while pressed (4.248.0; the owner, 11-14: Live's Chat button dropped 19 px and lost the click) */
:where(html:not([data-ov-forms="off"])) :where(input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"], [type="color"], [type="hidden"], [type="button"], [type="submit"], [type="reset"]), textarea, select):where(:not([data-ov-native], [data-ov-native] *)) {
:where(html:not([data-ov-forms="off"])) :where(input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"], [type="color"], [type="hidden"], [type="button"], [type="submit"], [type="reset"]), textarea, select):where(:not([data-ov-native], [data-ov-native] *)) {
transition: border-color .15s ease, box-shadow .2s ease, background-color .15s ease;
transition: border-color .15s ease, box-shadow .2s ease, background-color .15s ease;
}
}
@@ -239,7 +239,7 @@ html.ov-sel-locked, html.ov-sel-locked body { overflow: hidden; overscroll-behav
@@ -239,7 +239,7 @@ html.ov-sel-locked, html.ov-sel-locked body { overflow: hidden; overscroll-behav
html[data-ov-motion="calm"] :where(input[type="checkbox"], input[type="radio"], button, .btn)::before { transition-duration: .32s; }
html[data-ov-motion="calm"] :where(input[type="checkbox"], input[type="radio"], button, .btn)::before { transition-duration: .32s; }
html[data-ov-motion="off"] :where(input[type="checkbox"], input[type="radio"], button, input[type="button"], input[type="submit"], .btn, [role="button"], input, textarea, select),
html[data-ov-motion="off"] :where(input[type="checkbox"], input[type="radio"], button, input[type="button"], input[type="submit"], .btn, [role="button"], input, textarea, select),
html[data-ov-motion="off"] :where(input[type="checkbox"], input[type="radio"])::before { transition: none !important; animation: none !important; }
html[data-ov-motion="off"] :where(input[type="checkbox"], input[type="radio"])::before { transition: none !important; animation: none !important; }
-html[data-ov-motion="off"] :where(input[type="checkbox"], input[type="radio"], button, .btn, [role="button"]):active { transform: none !important; }
+html[data-ov-motion="off"] :where(input[type="checkbox"], input[type="radio"], button, .btn, [role="button"]):active { scale: none !important; }
:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"][role="switch"]:disabled) { opacity: .5; cursor: not-allowed; }
:where(html:not([data-ov-forms="off"])) :where(input[type="checkbox"][role="switch"]:disabled) { opacity: .5; cursor: not-allowed; }
:where(html:not([data-ov-forms="off"])) :where(input[type="file"]:not([data-ov-native]))::file-selector-button {
:where(html:not([data-ov-forms="off"])) :where(input[type="file"]:not([data-ov-native]))::file-selector-button {
font: inherit; font-weight: 600; margin-inline-end: .75em; padding: .38em .85em; cursor: pointer;
font: inherit; font-weight: 600; margin-inline-end: .75em; padding: .38em .85em; cursor: pointer;
@@ -1691,17 +1691,25 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
@@ -1691,17 +1691,25 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
player.observe(box);
player.observe(box);
}
}
const glyph = (name) => { const g = svgIcon(name); g.setAttribute('class', 'ovfd-gl'); return g; };
const glyph = (name) => { const g = svgIcon(name); g.setAttribute('class', 'ovfd-gl'); return g; };
+ // A moment in the reader's own zone and clock (theme-loader's time, 4.248.0; the owner, 11-14: "show the time based on the
+ // users computer by default or configurable setting"). Without the loader: this device's.
+ const tf = (v, kind) => {
+ const L = root.OpenVibeThemeLoader;
+ if (L && L.time && typeof L.time.format === 'function') return L.time.format(v, kind);
+ const d = new Date(typeof v === 'string' ? Date.parse(v) : v); if (!Number.isFinite(d.getTime())) return '';
+ return kind === 'time' ? d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : d.toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+ };
const when = (iso) => {
const when = (iso) => {
const t = Date.parse(iso || ''); if (!t) return '';
const t = Date.parse(iso || ''); if (!t) return '';
- const d = new Date(t);
- return d.toDateString() === new Date().toDateString() ? ago(iso) : d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
+ // Today: how long ago; before that, the day and the time (it used to print a bare time, as if it were today's).
+ return Date.now() - t < 12 * 3600000 ? ago(iso) : tf(t, 'day');
};
};
/** "Starts 2026-11-12 18:00 UTC" (Today's line when an event has no description) in the reader's own time. */
/** "Starts 2026-11-12 18:00 UTC" (Today's line when an event has no description) in the reader's own time. */
const startsLocal = (text) => {
const startsLocal = (text) => {
const m = /^Starts (\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}) UTC$/.exec(String(text || ''));
const m = /^Starts (\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}) UTC$/.exec(String(text || ''));
if (!m) return text;
if (!m) return text;
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]));
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]));
- return `Starts ${d.toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short' })}, ${d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}`;
+ return `Starts ${tf(d.getTime(), 'day')}`;
};
};
const tidy = (v) => String(v || '').replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim();
const tidy = (v) => String(v || '').replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim();
const cap = (v) => (v ? v.charAt(0).toUpperCase() + v.slice(1) : v);
const cap = (v) => (v ? v.charAt(0).toUpperCase() + v.slice(1) : v);
@@ -1886,6 +1894,8 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
@@ -1886,6 +1894,8 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
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);
+ // A picture that does not load leaves no empty frame (11-14, the owner: "broken images").
+ img.onerror = () => { box.classList.add(kind === 'clip' || kind === 'video' || kind === 'live' ? 'is-empty' : 'is-broken'); };
const len = media.length || c.duration_s;
const len = media.length || c.duration_s;
if (len) box.append(h('span', 'ovfd-len', lenOf(len)));
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 === 'rundown') { const rib = h('span', 'ovfd-ribbon'); rib.append(glyph('page'), document.createTextNode('Rundown')); box.append(rib); }
@@ -1922,10 +1932,13 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
@@ -1922,10 +1932,13 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
function statOf(c) {
function statOf(c) {
const n = c.counts && Number(c.counts.people);
const n = c.counts && Number(c.counts.people);
const t = c.trend && typeof c.trend === 'object' ? c.trend : null;
const t = c.trend && typeof c.trend === 'object' ? c.trend : null;
- if (!Number.isFinite(n) && !t) return null;
+ if (!Number.isFinite(n) && !t && !(c.stat && typeof c.stat === 'object')) return null;
const box = h('span', 'ovfd-stat');
const box = h('span', 'ovfd-stat');
if (Number.isFinite(n)) box.append(h('b', 'ovfd-stat-n', num(n)));
if (Number.isFinite(n)) box.append(h('b', 'ovfd-stat-n', num(n)));
- if (c.text) box.append(h('small', 'ovfd-stat-l', String(c.text).slice(0, 120)));
+ // feed.item@1 1.2.0 stat.at: when it happened, in the reader's time (the words no longer carry "01:10 UTC").
+ const st = c.stat && typeof c.stat === 'object' ? c.stat : null;
+ const at = st && Date.parse(String(st.at || ''));
+ if (c.text || at) box.append(h('small', 'ovfd-stat-l', [String(c.text || '').slice(0, 120), at ? `at ${tf(at, 'time')}` : ''].filter(Boolean).join(', ')));
if (t && ['up', 'down', 'flat'].includes(t.direction)) {
if (t && ['up', 'down', 'flat'].includes(t.direction)) {
const pct = Number.isFinite(Number(t.pct)) ? `${Math.round(Number(t.pct))}%` : '';
const pct = Number.isFinite(Number(t.pct)) ? `${Math.round(Number(t.pct))}%` : '';
const was = Number.isFinite(Number(t.previous)) ? `was ${num(Number(t.previous))}` : '';
const was = Number.isFinite(Number(t.previous)) ? `was ${num(Number(t.previous))}` : '';
@@ -1934,6 +1947,13 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
@@ -1934,6 +1947,13 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
box.append(pill);
box.append(pill);
if (was) box.append(h('small', 'ovfd-stat-was', was));
if (was) box.append(h('small', 'ovfd-stat-was', was));
}
}
+ // Today's side numbers (stat.facts; the owner, 11-14: "add total users per day or other cool metrics").
+ const facts = st && Array.isArray(st.facts) ? st.facts.filter((x) => x && Number.isFinite(Number(x.n)) && x.label).slice(0, 6) : [];
+ if (facts.length) {
+ const row = h('span', 'ovfd-facts');
+ facts.forEach((x) => { const f = h('span', 'ovfd-fact'); f.append(h('b', '', num(Number(x.n))), document.createTextNode(` ${String(x.label).slice(0, 40)}`)); row.append(f); });
+ box.append(row);
+ }
return box;
return box;
}
}
/** A folded run of the same kind (new jobs, new deals): its first three, each to its own page. */
/** A folded run of the same kind (new jobs, new deals): its first three, each to its own page. */
@@ -1950,7 +1970,10 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
@@ -1950,7 +1970,10 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
const row = h('span', 'ovfd-group-i');
const row = h('span', 'ovfd-group-i');
const th = pic(x);
const th = pic(x);
// Rows line up: where one has a picture, the others hold its place (a deal's: its store's first letter).
// Rows line up: where one has a picture, the others hold its place (a deal's: its store's first letter).
- if (th) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = th; row.append(img); }
+ if (th) {
+ const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = th; row.append(img);
+ img.onerror = () => { const ph = h('i', `ovfd-group-ph${isDeal ? ' is-deal' : ''}`); if (isDeal) ph.append(glyph('tag')); img.classList.add('is-broken'); img.before(ph); };
+ }
else if (isDeal) { const ph = h('i', 'ovfd-group-ph is-deal'); ph.append(glyph('tag')); row.append(ph); }
else if (isDeal) { const ph = h('i', 'ovfd-group-ph is-deal'); ph.append(glyph('tag')); row.append(ph); }
else if (anyPic) row.append(h('i', 'ovfd-group-ph'));
else if (anyPic) row.append(h('i', 'ovfd-group-ph'));
const t = h('span', 'ovfd-group-t'); t.append(h('span', 'ovfd-group-n', String(x.title || '').replace(/\s+[—-]\s+[^—-]{2,40}$/, '').slice(0, 120)));
const t = h('span', 'ovfd-group-t'); t.append(h('span', 'ovfd-group-n', String(x.title || '').replace(/\s+[—-]\s+[^—-]{2,40}$/, '').slice(0, 120)));
@@ -2156,6 +2179,10 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
@@ -2156,6 +2179,10 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
.ovfd-stat{display:grid;grid-template-columns:auto 1fr;align-items:baseline;column-gap:10px;row-gap:2px;padding:4px 2px}
.ovfd-stat{display:grid;grid-template-columns:auto 1fr;align-items:baseline;column-gap:10px;row-gap:2px;padding:4px 2px}
.ovfd-stat-n{font-family:'OV Brand',Nunito,Inter,sans-serif;font-size:34px;font-weight:800;line-height:1.05;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
.ovfd-stat-n{font-family:'OV Brand',Nunito,Inter,sans-serif;font-size:34px;font-weight:800;line-height:1.05;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
.ovfd-stat-l{font-size:13px;color:var(--text-secondary,#a8b3c4);align-self:center}
.ovfd-stat-l{font-size:13px;color:var(--text-secondary,#a8b3c4);align-self:center}
+.ovfd-facts{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
+.ovfd-fact{display:inline-flex;align-items:baseline;gap:4px;padding:4px 10px;border-radius:999px;font-size:12.5px;color:var(--text-secondary,#a8b3c4);background:color-mix(in srgb,var(--text-primary,#e8ecf3) 6%,transparent)}
+.ovfd-fact b{color:var(--text-primary,#e8ecf3);font-weight:750;font-variant-numeric:tabular-nums}
+.ovfd-media.is-empty{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 6%,transparent)}.ovfd-media.is-empty img{visibility:hidden}.ovfd-media.is-broken{display:none}.ovfd-group-i img.is-broken{display:none}
.ovfd-trend{grid-column:1;justify-self:start;font-size:12.5px;font-weight:750;padding:1px 8px;border-radius:999px;font-variant-numeric:tabular-nums}
.ovfd-trend{grid-column:1;justify-self:start;font-size:12.5px;font-weight:750;padding:1px 8px;border-radius:999px;font-variant-numeric:tabular-nums}
.ovfd-trend.is-up{color:#34d399;background:color-mix(in srgb,#10b981 16%,transparent)}.ovfd-trend.is-down{color:#f87171;background:color-mix(in srgb,#ef4444 14%,transparent)}.ovfd-trend.is-flat{color:var(--text-secondary,#a8b3c4);background:color-mix(in srgb,var(--text-primary,#e8ecf3) 8%,transparent)}
.ovfd-trend.is-up{color:#34d399;background:color-mix(in srgb,#10b981 16%,transparent)}.ovfd-trend.is-down{color:#f87171;background:color-mix(in srgb,#ef4444 14%,transparent)}.ovfd-trend.is-flat{color:var(--text-secondary,#a8b3c4);background:color-mix(in srgb,var(--text-primary,#e8ecf3) 8%,transparent)}
.ovfd-stat-was{font-size:12px;color:var(--text-muted,#93a0b4);align-self:center}
.ovfd-stat-was{font-size:12px;color:var(--text-muted,#93a0b4);align-self:center}
@@ -38,6 +38,7 @@
@@ -38,6 +38,7 @@
+ '.ovlk-icons{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 6px 6px 10px;border-radius:12px;background:var(--bg-tertiary,rgba(255,255,255,.05))}'
+ '.ovlk-icons{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 6px 6px 10px;border-radius:12px;background:var(--bg-tertiary,rgba(255,255,255,.05))}'
+ '.ovlk-icons>b{color:var(--text-primary,#e6edf7);font-weight:650}'
+ '.ovlk-icons>b{color:var(--text-primary,#e6edf7);font-weight:650}'
+ '.ovlk-seg{display:inline-flex;gap:2px;padding:2px;border-radius:999px;background:var(--bg-primary,rgba(0,0,0,.25))}'
+ '.ovlk-seg{display:inline-flex;gap:2px;padding:2px;border-radius:999px;background:var(--bg-primary,rgba(0,0,0,.25))}'
+ + '.ovlk-tz{min-width:0;max-width:62%;padding:6px 10px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.12));background:var(--bg-primary,rgba(0,0,0,.25));color:var(--text-primary,#e6edf7);font:inherit;font-size:.86rem}'
+ '.ovlk .ovlk-seg button{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;gap:5px;min-height:30px;padding:0 10px;border-radius:999px;cursor:pointer;font-size:12.5px;color:var(--text-secondary,#a8b3c4)}'
+ '.ovlk .ovlk-seg button{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;gap:5px;min-height:30px;padding:0 10px;border-radius:999px;cursor:pointer;font-size:12.5px;color:var(--text-secondary,#a8b3c4)}'
+ '.ovlk .ovlk-seg button[aria-checked=true]{background:var(--accent-strong,var(--accent,#3472d8));color:var(--on-accent-strong,#fff)}'
+ '.ovlk .ovlk-seg button[aria-checked=true]{background:var(--accent-strong,var(--accent,#3472d8));color:var(--on-accent-strong,#fff)}'
+ '.ovlk .ovlk-seg button:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:1px}'
+ '.ovlk .ovlk-seg button:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:1px}'
@@ -109,6 +110,18 @@
@@ -109,6 +110,18 @@
panel.append(row('motion', 'Motion', [['auto', 'Auto'], ['full', 'Full'], ['reduced', 'Calm'], ['off', 'Off']]));
panel.append(row('motion', 'Motion', [['auto', 'Auto'], ['full', 'Full'], ['reduced', 'Calm'], ['off', 'Off']]));
// Windows (4.202.0, asks 304 and 318): every window at once (ov-window.js), glass, solid or outlined.
// Windows (4.202.0, asks 304 and 318): every window at once (ov-window.js), glass, solid or outlined.
panel.append(row('window', 'Windows', [['glass', 'Glass'], ['solid', 'Solid'], ['outline', 'Outline']]));
panel.append(row('window', 'Windows', [['glass', 'Glass'], ['solid', 'Solid'], ['outline', 'Outline']]));
+ // Time (4.248.0; the owner, 11-14): every time on every site in this zone and clock. Auto is this device's.
+ if (L.time && L.display.options.clock) {
+ panel.append(row('clock', 'Clock', [['auto', 'Auto'], ['12', '12-hour'], ['24', '24-hour']]));
+ const dev = L.time.device();
+ const cur = d.tz && d.tz !== 'auto' ? d.tz : 'auto';
+ const sel = el('select', { class: 'ovlk-tz', 'data-look-tz': '', 'aria-label': 'Time zone' }, [el('option', { value: 'auto' }, [`Auto (${dev.replace(/_/g, ' ')})`])]);
+ const zones = L.time.zones();
+ if (cur !== 'auto' && zones.indexOf(cur) < 0) zones.unshift(cur);
+ zones.forEach((z) => sel.append(el('option', { value: z }, [z.replace(/_/g, ' ')])));
+ sel.value = cur;
+ panel.append(el('div', { class: 'ovlk-icons' }, [el('b', {}, ['Time zone']), sel]));
+ }
}
}
const signedIn = opts.signedIn != null ? !!opts.signedIn : /(?:^|; )ov_sso_hint=account/.test(document.cookie || '') || !!(root.localStorage && (() => { try { return root.localStorage.getItem('ov_token'); } catch (e) { return null; } })());
const signedIn = opts.signedIn != null ? !!opts.signedIn : /(?:^|; )ov_sso_hint=account/.test(document.cookie || '') || !!(root.localStorage && (() => { try { return root.localStorage.getItem('ov_token'); } catch (e) { return null; } })());
const named = showing ? showing.name : ((PICKS.find(([id]) => id === cur) || [0, cur])[1]);
const named = showing ? showing.name : ((PICKS.find(([id]) => id === cur) || [0, cur])[1]);
@@ -161,7 +174,11 @@
@@ -161,7 +174,11 @@
else L.save(id);
else L.save(id);
draw(box, opts);
draw(box, opts);
});
});
- ['ov:theme', 'ov:display'].forEach((ev) => root.addEventListener(ev, () => { if (box.isConnected) draw(box, opts); }));
+ box.addEventListener('change', (e) => {
+ const L = root.OpenVibeThemeLoader; const t = e.target;
+ if (L && t && t.matches && t.matches('[data-look-tz]')) { e.stopPropagation(); L.display.set({ tz: t.value || 'auto' }); }
+ });
+ ['ov:theme', 'ov:display'].forEach((ev) => root.addEventListener(ev, () => { if (box.isConnected && !(document.activeElement && document.activeElement.matches && document.activeElement.matches('[data-look-tz]'))) draw(box, opts); }));
}
}
root.OVLook = { mount, PICKS, shareLink };
root.OVLook = { mount, PICKS, shareLink };
@@ -318,8 +318,15 @@
@@ -318,8 +318,15 @@
const top = el('span', 'ovr-agent-top'); top.append(el('b', '', agentName()), el('i', 'ovr-agent-tag', 'your agent'));
const top = el('span', 'ovr-agent-top'); top.append(el('b', '', agentName()), el('i', 'ovr-agent-tag', 'your agent'));
t.append(top, el('span', 'ovr-agent-line', [AGENT_WORD[st], step, agent ? `${money(now.cost_today)}${cap} today` : ''].filter(Boolean).join(' · ')));
t.append(top, el('span', 'ovr-agent-line', [AGENT_WORD[st], step, agent ? `${money(now.cost_today)}${cap} today` : ''].filter(Boolean).join(' · ')));
who.append(agentFace('m', st), t);
who.append(agentFace('m', st), t);
- who.addEventListener('click', () => list('task'));
+ who.addEventListener('click', () => list('task', { history: true }));
card.append(who);
card.append(who);
+ // 4.248.0 (the owner, 11-14: "move this history … to a history button in the corner"): every conversation, task
+ // and code run behind one button; the view itself is the conversation.
+ const hist = el('button', `ovr-agent-go ovr-agent-hist${state.view === 'list' ? ' is-on' : ''}`); hist.type = 'button';
+ hist.setAttribute('aria-label', 'History: your conversations, tasks and code'); hist.title = 'History';
+ hist.append(svg('clock'));
+ hist.addEventListener('click', () => { if (state.view === 'list') list('all'); else list(state.filter, { history: true }); }); // pressed again: back to the conversation
+ card.append(hist);
if (agent && agent.id) {
if (agent && agent.id) {
const paused = st === 'paused';
const paused = st === 'paused';
const go = el('button', `ovr-agent-go${paused ? ' is-main' : ''}`); go.type = 'button';
const go = el('button', `ovr-agent-go${paused ? ' is-main' : ''}`); go.type = 'button';
@@ -411,8 +418,40 @@
@@ -411,8 +418,40 @@
function typing() { const t = el('div', 'ovr-typing'); t.setAttribute('aria-label', `${agentName()} is writing`); t.append(el('i'), el('i'), el('i')); return t; }
function typing() { const t = el('div', 'ovr-typing'); t.setAttribute('aria-label', `${agentName()} is writing`); t.append(el('i'), el('i'), el('i')); return t; }
+ // ── the home head (4.248.0; the owner, 11-14: the filters "need to be above the actor … the actor … a fixed thing
+ // that's on top, kind of like how Muse is") ──
+ function homeHead(where) {
+ const wrap = el('div', 'ovr-home');
+ const head = el('div', 'ovr-head');
+ const tabs = el('div', 'ovr-filters'); tabs.setAttribute('role', 'tablist'); tabs.setAttribute('aria-label', 'Show');
+ for (const [id, label, ic] of FILTERS) {
+ const b = el('button', 'ovr-filter'); b.type = 'button'; b.setAttribute('role', 'tab'); b.dataset.filter = id;
+ b.append(svg(ic), el('span', '', label));
+ const on = id === state.filter; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1;
+ b.addEventListener('click', () => list(id, { fromTab: true }));
+ tabs.append(b);
+ }
+ tabs.addEventListener('keydown', (e) => {
+ if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
+ const all = [...tabs.children]; 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();
+ });
+ head.append(tabs);
+ wrap.append(head);
+ const me = el('div', 'ovr-agent-slot'); me.hidden = true;
+ if (o.agent) { wrap.append(me); paintAgent(me); loadAgent().then(() => { if (me.isConnected) paintAgent(me); }); }
+ where.append(wrap);
+ return wrap;
+ }
+ /** All, for your agent: the conversation itself (your latest chat with it), not a list; the list is History. */
+ function latestChat(runs) {
+ const day = Date.now() - 36 * 3600e3;
+ return runs.find((r) => r && r.kind === 'chat' && Date.parse(r.updated_at || r.created_at || '') > day && !needsPerson(r)) || null;
+ }
+
// ── the list ──
// ── the list ──
- async function list(filter) {
+ async function list(filter, how = {}) {
if (filter && FILTER_IDS.includes(filter)) state.filter = filter;
if (filter && FILTER_IDS.includes(filter)) state.filter = filter;
clearStream();
clearStream();
const gen = ++state.gen;
const gen = ++state.gen;
@@ -420,28 +459,12 @@
@@ -420,28 +459,12 @@
if (state.view !== 'list' || !box.querySelector('.ovr-rows')) {
if (state.view !== 'list' || !box.querySelector('.ovr-rows')) {
state.view = 'list'; state.run = null;
state.view = 'list'; state.run = null;
box.textContent = ''; box.className = 'ovr is-list';
box.textContent = ''; box.className = 'ovr is-list';
- const head = el('div', 'ovr-head');
- const tabs = el('div', 'ovr-filters'); tabs.setAttribute('role', 'tablist'); tabs.setAttribute('aria-label', 'Show');
- for (const [id, label, ic] of FILTERS) {
- const b = el('button', 'ovr-filter'); b.type = 'button'; b.setAttribute('role', 'tab'); b.dataset.filter = id;
- b.append(svg(ic), el('span', '', label));
- b.addEventListener('click', () => { if (state.filter !== id || state.view !== 'list') list(id); });
- tabs.append(b);
- }
- tabs.addEventListener('keydown', (e) => {
- if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
- const all = [...tabs.children]; 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();
- });
- head.append(tabs);
- const me = el('div', 'ovr-agent-slot'); me.hidden = true;
- if (o.agent) { box.append(me); paintAgent(me); loadAgent().then(() => { if (me.isConnected) paintAgent(me); }); }
+ homeHead(box);
const rows = el('ul', 'ovr-rows'); rows.setAttribute('aria-label', 'Your runs');
const rows = el('ul', 'ovr-rows'); rows.setAttribute('aria-label', 'Your runs');
const foot = el('div', 'ovr-list-note');
const foot = el('div', 'ovr-list-note');
const top = el('div', 'ovr-top');
const top = el('div', 'ovr-top');
const pane = el('div', 'ovr-pane'); pane.hidden = true;
const pane = el('div', 'ovr-pane'); pane.hidden = true;
- box.append(head, top, rows, foot, pane);
+ box.append(top, rows, foot, pane);
dropTop(); dropPane();
dropTop(); dropPane();
if (Array.isArray(o.starters) && o.starters.length) {
if (Array.isArray(o.starters) && o.starters.length) {
const st = el('div', 'ovr-starters'); st.setAttribute('role', 'group'); st.setAttribute('aria-label', 'Things to ask');
const st = el('div', 'ovr-starters'); st.setAttribute('role', 'group'); st.setAttribute('aria-label', 'Things to ask');
@@ -457,6 +480,7 @@
@@ -457,6 +480,7 @@
}
}
place({ filter: state.filter });
place({ filter: state.filter });
box.querySelectorAll('.ovr-filter').forEach((b) => { const on = b.dataset.filter === state.filter; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
box.querySelectorAll('.ovr-filter').forEach((b) => { const on = b.dataset.filter === state.filter; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
+ { const h = box.querySelector('.ovr-agent-hist'); if (h) h.classList.add('is-on'); }
// A filter the host draws itself (a pane): it takes the place of the rows, the starters and the box. The host's
// A filter the host draws itself (a pane): it takes the place of the rows, the starters and the box. The host's
// pieces may share one owner (the Dock lends one panel at a time), so the old one goes before the new one comes.
// pieces may share one owner (the Dock lends one panel at a time), so the old one goes before the new one comes.
const pf = paneFor(state.filter);
const pf = paneFor(state.filter);
@@ -493,6 +517,12 @@
@@ -493,6 +517,12 @@
const needs = (r) => (needsPerson(r) ? 1 : 0);
const needs = (r) => (needsPerson(r) ? 1 : 0);
state.runs = state.runs.concat(extra).sort((x, y) => needs(y) - needs(x) || String(y.updated_at || '').localeCompare(String(x.updated_at || '')));
state.runs = state.runs.concat(extra).sort((x, y) => needs(y) - needs(x) || String(y.updated_at || '').localeCompare(String(x.updated_at || '')));
}
}
+ // All, for your agent, is the conversation (Muse, dots): today's chat opens in place; History shows the list.
+ // Anything waiting on you (an approval, a question) keeps the list first, where it can be answered in place.
+ if (o.agent && state.filter === 'all' && !how.history && !how.stay && !state.runs.some(needsPerson)) {
+ const last = latestChat(state.runs);
+ if (last) { open(last.ref || last.id, last, { home: true }); return; }
+ }
rows.textContent = ''; rows.removeAttribute('aria-busy');
rows.textContent = ''; rows.removeAttribute('aria-busy');
// 4.242.0: how many are working right now (a coding agent's "2 running tasks"), above the rows.
// 4.242.0: how many are working right now (a coding agent's "2 running tasks"), above the rows.
{ const busy = state.runs.filter((r) => r.status === 'running' || r.status === 'planning').length; let pill = box.querySelector('.ovr-busy');
{ const busy = state.runs.filter((r) => r.status === 'running' || r.status === 'planning').length; let pill = box.querySelector('.ovr-busy');
@@ -553,7 +583,7 @@
@@ -553,7 +583,7 @@
const gen = ++state.gen;
const gen = ++state.gen;
state.view = 'thread'; state.run = known || null; state.steps = new Map();
state.view = 'thread'; state.run = known || null; state.steps = new Map();
place({ ref });
place({ ref });
- box.textContent = ''; box.className = 'ovr is-thread';
+ box.textContent = ''; box.className = `ovr is-thread${how.home ? ' is-home' : ''}`;
const head = el('div', 'ovr-thead');
const head = el('div', 'ovr-thead');
const back = el('button', 'ovr-ib'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your runs'); back.title = 'Your runs'; back.append(svg('back'));
const back = el('button', 'ovr-ib'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your runs'); back.title = 'Your runs'; back.append(svg('back'));
back.addEventListener('click', () => list());
back.addEventListener('click', () => list());
@@ -563,7 +593,7 @@
@@ -563,7 +593,7 @@
const faceSlot = el('span', 'ovr-face-slot');
const faceSlot = el('span', 'ovr-face-slot');
head.append(back, faceSlot, who, away);
head.append(back, faceSlot, who, away);
const thread = el('div', 'ovr-thread'); thread.setAttribute('role', 'log'); thread.setAttribute('aria-live', 'polite');
const thread = el('div', 'ovr-thread'); thread.setAttribute('role', 'log'); thread.setAttribute('aria-live', 'polite');
- box.append(head, thread);
+ if (how.home && o.agent) { homeHead(box); box.append(thread); } else box.append(head, thread);
const paintHead = (r) => {
const paintHead = (r) => {
state.run = r;
state.run = r;
faceSlot.textContent = ''; faceSlot.append(face(r, 's'));
faceSlot.textContent = ''; faceSlot.append(face(r, 's'));
@@ -591,7 +621,7 @@
@@ -591,7 +621,7 @@
const next = queue.shift();
const next = queue.shift();
sendNow(next.text, next.node).catch((err) => { next.node.classList.add('is-failed'); next.node.querySelector('.ovr-queued').textContent = (err && err.message) || 'That did not send'; });
sendNow(next.text, next.node).catch((err) => { next.node.classList.add('is-failed'); next.node.querySelector('.ovr-queued').textContent = (err && err.message) || 'That did not send'; });
};
};
- composer(box, { placeholder: 'Say the next thing', onSend: async (v) => {
+ composer(box, { placeholder: how.home ? `Message ${agentName()}` : 'Say the next thing', onSend: async (v) => {
if (state.run && ['running', 'planning'].includes(state.run.status)) {
if (state.run && ['running', 'planning'].includes(state.run.status)) {
const q = el('div', 'ovr-step is-you is-queued'); const b = el('div', 'ovr-bubble'); b.append(document.createTextNode(v.text)); q.append(b, el('small', 'ovr-queued', 'Queued: it reads this after the step it is on'));
const q = el('div', 'ovr-step is-you is-queued'); const b = el('div', 'ovr-bubble'); b.append(document.createTextNode(v.text)); q.append(b, el('small', 'ovr-queued', 'Queued: it reads this after the step it is on'));
thread.append(q); queue.push({ text: v.text, node: q });
thread.append(q); queue.push({ text: v.text, node: q });
@@ -892,6 +922,15 @@ a.ovr-row { color: inherit; text-decoration: none; }
@@ -892,6 +922,15 @@ a.ovr-row { color: inherit; text-decoration: none; }
.ovr-filter[aria-selected="true"] { color: var(--accent-light, #93c5fd) !important; border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent) !important; }
.ovr-filter[aria-selected="true"] { color: var(--accent-light, #93c5fd) !important; border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent) !important; }
.ovr-filter:active { transform: scale(.94); }
.ovr-filter:active { transform: scale(.94); }
.ovr-agent-slot { flex: none; width: 100%; max-width: 780px; margin-inline: auto; box-sizing: border-box; }
.ovr-agent-slot { flex: none; width: 100%; max-width: 780px; margin-inline: auto; box-sizing: border-box; }
+/* 4.248.0: the filters, then your agent, fixed at the top of Vibe while the conversation scrolls under them. */
+.ovr-home { position: sticky; top: 0; z-index: 4; flex: none; display: grid; gap: 6px; width: 100%; max-width: 780px; margin-inline: auto; padding: 2px 0 8px; box-sizing: border-box;
+ background: linear-gradient(to bottom, var(--ovs-sheet-bg, var(--bg-primary, #0b1220)) 82%, transparent); }
+.ovr-home .ovr-head { padding: 0 2px; }
+.ovr-home .ovr-agent { padding: 6px 6px 6px 4px; border-radius: 18px; }
+.ovr-home .ovr-agent-who { min-height: 46px; gap: 10px; }
+.ovr-home .ovr-face.is-m { width: 40px; height: 40px; border-radius: 50%; }
+.ovr-agent-hist.is-on { background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); color: var(--text-primary, #e6edf7); }
+.ovr.is-home > .ovr-thread { padding-top: 4px; width: 100%; max-width: 780px; margin-inline: auto; box-sizing: border-box; }
.ovr-agent-slot[hidden] { display: none; }
.ovr-agent-slot[hidden] { display: none; }
.ovr-agent { display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 6px; border-radius: 20px; background: linear-gradient(135deg, color-mix(in srgb, hsl(var(--ovr-ah, 220) 70% 55%) 14%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 3%, transparent)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); animation: ovr-rise .32s cubic-bezier(.2,.9,.3,1.1) both; }
.ovr-agent { display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 6px; border-radius: 20px; background: linear-gradient(135deg, color-mix(in srgb, hsl(var(--ovr-ah, 220) 70% 55%) 14%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 3%, transparent)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); animation: ovr-rise .32s cubic-bezier(.2,.9,.3,1.1) both; }
.ovr-agent-who { all: unset; box-sizing: border-box; flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 2px 6px; border-radius: 14px; cursor: pointer; }
.ovr-agent-who { all: unset; box-sizing: border-box; flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 2px 6px; border-radius: 14px; cursor: pointer; }
@@ -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': '9b2a27f70f5f', 'ov-combo.js': '8001a3b19c2f', 'player.js': '8f4b6892e572', 'player.css': '699a7c4927d5' };
+ const THREAD_REV = { 'ov-composer.js': 'c29a98f058e6', 'ov-combo.js': '8001a3b19c2f', 'player.js': '8f4b6892e572', 'player.css': '699a7c4927d5' };
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,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.247.0",
+ "version": "4.248.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": {
@@ -226,7 +226,7 @@
@@ -226,7 +226,7 @@
.ovs-fly-part--count { width: auto !important; height: auto !important; }
.ovs-fly-part--count { width: auto !important; height: auto !important; }
.ovs-fly-count { display: inline-grid; place-items: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: #ef4444; color: #fff; font: 800 11px/1 system-ui, sans-serif; }
.ovs-fly-count { display: inline-grid; place-items: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: #ef4444; color: #fff; font: 800 11px/1 system-ui, sans-serif; }
.ovs-panel { position: absolute; left: 50%; top: 8vh; width: min(720px, calc(100vw - 32px)); max-height: 80vh; transform: translateX(-50%); display: flex; flex-direction: column; border-radius: 18px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-secondary, #121826); box-shadow: 0 30px 80px rgba(0,0,0,.55); overflow: hidden; }
.ovs-panel { position: absolute; left: 50%; top: 8vh; width: min(720px, calc(100vw - 32px)); max-height: 80vh; transform: translateX(-50%); display: flex; flex-direction: column; border-radius: 18px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-secondary, #121826); box-shadow: 0 30px 80px rgba(0,0,0,.55); overflow: hidden; }
-.ovs-bar { display: flex; align-items: center; gap: 10px; margin: 10px 10px 6px; padding: 0 6px 0 14px; border-radius: 999px; background: var(--bg-primary, rgba(0,0,0,.25)); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.12)); transition: box-shadow .2s, background .2s; }
+.ovs-bar { display: flex; align-items: center; gap: 10px; margin: 10px 10px 6px; padding: 0 6px 0 14px; border-radius: calc(var(--ov-radius-2, 14px) + 2px); background: var(--bg-primary, rgba(0,0,0,.25)); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.12)); transition: box-shadow .2s, background .2s; }
.ovs-bar:focus-within { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
.ovs-bar:focus-within { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
/* A site's own focus ring (the orange box on openvibe.download, 10-29) never draws on the field: the pill shows focus. */
/* A site's own focus ring (the orange box on openvibe.download, 10-29) never draws on the field: the pill shows focus. */
.ovs .ovs-input, .ovs .ovs-input:focus, .ovs .ovs-input:focus-visible { outline: none !important; box-shadow: none !important; border: 0 !important; background: transparent !important; }
.ovs .ovs-input, .ovs .ovs-input:focus, .ovs .ovs-input:focus-visible { outline: none !important; box-shadow: none !important; border: 0 !important; background: transparent !important; }
@@ -672,7 +672,14 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -672,7 +672,14 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@keyframes ovs-orb-in { from { opacity: 0; transform: scale(.45) rotate(-90deg); } }
@keyframes ovs-orb-in { from { opacity: 0; transform: scale(.45) rotate(-90deg); } }
@keyframes ovs-orb-out { to { opacity: 0; transform: scale(.5) rotate(90deg); } }
@keyframes ovs-orb-out { to { opacity: 0; transform: scale(.5) rotate(90deg); } }
.ovs.ovs-anchored .ovs-orb svg { display: block; overflow: visible; width: 24px; height: 24px; }
.ovs.ovs-anchored .ovs-orb svg { display: block; overflow: visible; width: 24px; height: 24px; }
- .ovs.ovs-anchored .ovs-orb.is-x { background: var(--bg-secondary, #121826); box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 14%, transparent), 0 8px 22px -8px rgba(0, 0, 0, .7); }
+ .ovs.ovs-anchored .ovs-orb.is-x { background: var(--bg-primary, rgba(0,0,0,.25)); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.12)), 0 8px 22px -10px rgba(0, 0, 0, .7); }
+ /* 4.248.0 (the owner, 11-14: "the top thing … needs to be less round … make everything more consistent"): the field and
+ its close share the kit's radius scale (--ov-radius-2 + 2, the tabs' own), so the close is a rounded square beside it. */
+ .ovs.ovs-anchored .ovs-orb.is-x { border-radius: calc(var(--ov-radius-2, 14px) + 2px); }
+ .ovs.ovs-anchored .ovs-orb.is-x::after { border-radius: inherit; }
+ /* 4.248.0: as tall as the search field beside it, centred on it (placeOrb measures both). */
+ .ovs.ovs-anchored .ovs-orb.is-x { width: var(--orb-size, 48px); height: var(--orb-size, 48px); transition: transform .42s cubic-bezier(.2, 1.1, .3, 1), background-color .3s, box-shadow .3s, top .25s ease, left .25s ease; }
+ .ovs.ovs-anchored .ovs-orb.is-x:hover { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 55%, var(--border, rgba(255,255,255,.12))), 0 8px 22px -10px rgba(0, 0, 0, .7); }
.ovs.ovs-anchored .ovs-orb.is-x::before { display: none; }
.ovs.ovs-anchored .ovs-orb.is-x::before { display: none; }
.ovs.ovs-anchored .ovs-orb { width: 48px; height: 48px; }
.ovs.ovs-anchored .ovs-orb { width: 48px; height: 48px; }
.ovs.ovs-anchored .ovs-orb:focus { outline: none; }
.ovs.ovs-anchored .ovs-orb:focus { outline: none; }
@@ -1040,7 +1047,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -1040,7 +1047,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
if (g.as === 'card') {
if (g.as === 'card') {
const body = document.createElement('div'); body.className = 'ovs-cardbody';
const body = document.createElement('div'); body.className = 'ovs-cardbody';
try {
try {
- g.card.render(body, { close, here: here(), user: ctx.user || null, signedIn: !!(ctx.token && ctx.user && !ctx.user.is_anon) });
+ g.card.render(body, { close, here: here(), user: ctx.user || null, signedIn: !!(ctx.user && !ctx.user.is_anon) });
} catch { return; }
} catch { return; }
if (!body.childNodes.length) return;
if (!body.childNodes.length) return;
if (g.title) { const h = document.createElement('li'); h.className = 'ovs-group'; h.setAttribute('role', 'presentation'); h.setAttribute('data-sec', g.section); h.textContent = g.title; listEl.appendChild(h); }
if (g.title) { const h = document.createElement('li'); h.className = 'ovs-group'; h.setAttribute('role', 'presentation'); h.setAttribute('data-sec', g.section); h.textContent = g.title; listEl.appendChild(h); }
@@ -1713,7 +1720,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -1713,7 +1720,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
try { return root.OpenVibeDock && root.OpenVibeDock.mountInto ? root.OpenVibeDock.mountInto(slot, how) : null; } catch { return null; }
try { return root.OpenVibeDock && root.OpenVibeDock.mountInto ? root.OpenVibeDock.mountInto(slot, how) : null; } catch { return null; }
}
}
function mountRuns() {
function mountRuns() {
- const me = ctx.token && ctx.user && !ctx.user.is_anon ? ctx.user : null;
+ const me = ctx.user && !ctx.user.is_anon ? ctx.user : null;
// Runs and the Action Center are the person's Network places, like the Dock's (navbar.js passes its base).
// Runs and the Action Center are the person's Network places, like the Dock's (navbar.js passes its base).
const net = ctx.runsBase || ctx.apiBase || 'https://openvibe.network';
const net = ctx.runsBase || ctx.apiBase || 'https://openvibe.network';
const D = () => root.OpenVibeDock;
const D = () => root.OpenVibeDock;
@@ -1724,6 +1731,8 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -1724,6 +1731,8 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
// Dock's Vibe, Agents and Code panels are no longer lent here.
// Dock's Vibe, Agents and Code panels are no longer lent here.
actions: { base: net }, starters: VIBE_STARTERS, agent: true,
actions: { base: net }, starters: VIBE_STARTERS, agent: true,
// Above the list: Now (timers, what is on) and the live view of an agent at work, from the Dock's agent top.
// Above the list: Now (timers, what is on) and the live view of an agent at work, from the Dock's agent top.
+ // Above the list: what is happening now (a timer, an agent at work, what needs you), quiet otherwise (4.248.0: no
+ // idle line, no "Nothing needs you", no timeline folds; the owner, 11-14).
top: (slot) => lend(slot, { tab: 'agent', part: 'live' }),
top: (slot) => lend(slot, { tab: 'agent', part: 'live' }),
onPlace: (p) => { if (p.filter) runFilter = p.filter; },
onPlace: (p) => { if (p.filter) runFilter = p.filter; },
};
};
@@ -2073,14 +2082,16 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -2073,14 +2082,16 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
// With the home screen, Notifications is one of You's tabs (4.223.0); without it, the section stays at the top.
// 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 (root.OpenVibeNotifications && ctx.token && !H) groups.push({ title: 'Notifications', as: 'notifications', items: [] });
// §Z.2 (4.247.0): a guest sees first what signing in adds, with the bar's own Sign in link.
// §Z.2 (4.247.0): a guest sees first what signing in adds, with the bar's own Sign in link.
- if (!(ctx.token && ctx.user && !ctx.user.is_anon)) groups.push({ as: 'signin', items: [], section: 'signin' });
+ // Signed in is the person the bar shows, not a token: a site that signs in by its own session cookie (Download) has
+ // no token in the page, and the menu said "Sign in to make OpenVibe yours" under their avatar (11-14).
+ if (!(ctx.user && !ctx.user.is_anon)) groups.push({ as: 'signin', items: [], section: 'signin' });
if (H) {
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.user && !ctx.user.is_anon) && typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });
return groups;
return groups;
}
}
- if (ctx.token && ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });
+ if (ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });
const yours = mine && mine.services.some((x) => x.count > 0);
const yours = mine && mine.services.some((x) => x.count > 0);
// Without their own history, developer services are not places to visit: they come up here once used.
// Without their own history, developer services are not places to visit: they come up here once used.
const dockPool = yours ? ranked : ranked.filter((x) => x.group !== 'build' || useOf(x.id));
const dockPool = yours ? ranked : ranked.filter((x) => x.group !== 'build' || useOf(x.id));
@@ -2124,7 +2135,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -2124,7 +2135,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
function homeOpts() {
function homeOpts() {
const { all, ranked, f } = basis();
const { all, ranked, f } = basis();
const apps = ranked.map((x) => ({ id: x.id, title: x.name, url: x.url, site: x.icon || x.id, group: x.group })).filter((x) => x.url);
const apps = ranked.map((x) => ({ id: x.id, title: x.name, url: x.url, site: x.icon || x.id, group: x.group })).filter((x) => x.url);
- const yours = ctx.token && ctx.user && !ctx.user.is_anon ? youItems().filter((y) => y.title !== 'Agents').map((y) => ({ id: `you:${slugOf(y.title)}`, title: y.title, url: y.url, site: y.site, group: 'you' })) : [];
+ const yours = ctx.user && !ctx.user.is_anon ? youItems().filter((y) => y.title !== 'Agents').map((y) => ({ id: `you:${slugOf(y.title)}`, title: y.title, url: y.url, site: y.site, group: 'you' })) : [];
const tools = catalog ? catalog.tools.map((t) => [toolUse(t), t]).sort((a, b) => b[0] - a[0]).map(([, t]) => ({ id: `tool:${slugOf(t.name)}`, title: t.name, url: t.go || t.url, site: t.icon || 'tools' })) : [];
const tools = catalog ? catalog.tools.map((t) => [toolUse(t), t]).sort((a, b) => b[0] - a[0]).map(([, t]) => ({ id: `tool:${slugOf(t.name)}`, title: t.name, url: t.go || t.url, site: t.icon || 'tools' })) : [];
const seen = new Set(), recentIds = [];
const seen = new Set(), recentIds = [];
for (const h of (mine ? mine.items : [])) {
for (const h of (mine ? mine.items : [])) {
@@ -2369,7 +2380,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -2369,7 +2380,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
// - the menu's own circle grows from the button while it flies (it was cut short before), and the close orb turns
// - the menu's own circle grows from the button while it flies (it was cut short before), and the close orb turns
// in once the button has left its place.
// in once the button has left its place.
// Motion Off skips it all; Calm plays the same path short, with no trail, tilt or glow (Calm reduces, only Off removes).
// Motion Off skips it all; Calm plays the same path short, with no trail, tilt or glow (Calm reduces, only Off removes).
- let flyFrom = null, flyIdle = null;
+ let flyFrom = null, flyIdle = null, fromTab = null;
const motionLevel = () => (typeof document !== 'undefined' && document.documentElement.getAttribute('data-ov-motion')) || '';
const motionLevel = () => (typeof document !== 'undefined' && document.documentElement.getAttribute('data-ov-motion')) || '';
function tabFace(id) {
function tabFace(id) {
const b = tabsEl && tabsEl.querySelector(`.ovs-tab[data-tab="${id}"], .ovs-tab[data-dock="${id}"]`);
const b = tabsEl && tabsEl.querySelector(`.ovs-tab[data-tab="${id}"], .ovs-tab[data-dock="${id}"]`);
@@ -2498,6 +2509,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -2498,6 +2509,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
const asked = { here: 'here', sites: 'here', feed: 'feed', you: 'you', tools: 'you' }[opts.tab];
const asked = { here: 'here', sites: 'here', feed: 'feed', you: 'you', tools: 'you' }[opts.tab];
// The menu opens on this site's tab unless a tab was asked (the picture asks You): no tab is remembered (4.189.0).
// The menu opens on this site's tab unless a tab was asked (the picture asks You): no tab is remembered (4.189.0).
tab = asked || 'here';
tab = asked || 'here';
+ fromTab = tab; // what the opener opened: only that tab flies back into it
allOpen = false; hereOpen = true; leadHere = false; discoverPick = null;
allOpen = false; hereOpen = true; leadHere = false; discoverPick = null;
drainCards();
drainCards();
syncFeeds();
syncFeeds();
@@ -2536,6 +2548,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -2536,6 +2548,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
const bar = el.querySelector('.ovs-bar'), br = bar ? bar.getBoundingClientRect() : null;
const bar = el.querySelector('.ovs-bar'), br = bar ? bar.getBoundingClientRect() : null;
const tx = br ? br.right + 6 + 24 : innerWidth - 24, ty = br ? br.top + br.height / 2 : 28;
const tx = br ? br.right + 6 + 24 : innerWidth - 24, ty = br ? br.top + br.height / 2 : 28;
orbEl.style.setProperty('--orb-x', `${Math.round(tx - 24)}px`); orbEl.style.setProperty('--orb-y', `${Math.round(ty - 24)}px`);
orbEl.style.setProperty('--orb-x', `${Math.round(tx - 24)}px`); orbEl.style.setProperty('--orb-y', `${Math.round(ty - 24)}px`);
+ settleOrb();
if (home) { orbEl.style.setProperty('--orb-dx', `${Math.round(home.cx - tx)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(home.cy - ty)}px`); }
if (home) { orbEl.style.setProperty('--orb-dx', `${Math.round(home.cx - tx)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(home.cy - ty)}px`); }
// The button has flown to its tab, or no menu button is on screen: the close turns in at the pill.
// The button has flown to its tab, or no menu button is on screen: the close turns in at the pill.
if (flyFrom || !home) orbEl.classList.add('is-x', 'is-pop');
if (flyFrom || !home) orbEl.classList.add('is-x', 'is-pop');
@@ -2589,7 +2602,9 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -2589,7 +2602,9 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
// on a phone, the orb on a computer). Measured each time, never remembered: the bar may have scrolled away or come
// on a phone, the orb on a computer). Measured each time, never remembered: the bar may have scrolled away or come
// back since, and on a phone the computer's orb is hidden (zero size). Null when none is on screen.
// back since, and on a phone the computer's orb is hidden (zero size). Null when none is on screen.
function barButtonCentre() {
function barButtonCentre() {
- const list = [ctx && ctx.from, document.getElementById('openvibe-burger'), document.getElementById('openvibe-search-btn')];
+ // The orb is the menu button turned into the close: it goes home into the menu button, never into the picture or
+ // another opener (11-14, the owner: still went into the avatar).
+ const list = [document.getElementById('openvibe-burger'), document.getElementById('openvibe-search-btn')];
for (const b of list) {
for (const b of list) {
if (!b || typeof b.getBoundingClientRect !== 'function' || typeof b.closest !== 'function' || !b.closest('.openvibe-navbar')) continue;
if (!b || typeof b.getBoundingClientRect !== 'function' || typeof b.closest !== 'function' || !b.closest('.openvibe-navbar')) continue;
const r = b.getBoundingClientRect();
const r = b.getBoundingClientRect();
@@ -2598,9 +2613,32 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -2598,9 +2613,32 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
}
}
return null;
return null;
}
}
+ /** The close sits beside the search field, centred on it and as tall as it (4.248.0; the owner, 11-14: "the close
+ * button's offset … more consistent with the search input"). Measured again once the sheet has slid in and when the
+ * window changes, never only at the first frame (the field was still moving then). */
+ let orbWatch = null;
+ function placeOrb() {
+ if (!orbEl || !el || !el.classList.contains('is-open') || !phoneSheet()) return;
+ const bar = el.querySelector('.ovs-bar'); if (!bar) return;
+ const r = bar.getBoundingClientRect(); if (!r.height) return;
+ const size = Math.max(44, Math.min(56, Math.round(r.height)));
+ orbEl.style.setProperty('--orb-size', `${size}px`);
+ orbEl.style.setProperty('--orb-x', `${Math.round(Math.min(innerWidth - size - 4, r.right + 8))}px`);
+ orbEl.style.setProperty('--orb-y', `${Math.round(r.top + (r.height - size) / 2)}px`);
+ }
+ function settleOrb() {
+ [140, 360, 700].forEach((t) => setTimeout(placeOrb, t));
+ if (!orbWatch && typeof window !== 'undefined') {
+ orbWatch = () => requestAnimationFrame(placeOrb);
+ window.addEventListener('resize', orbWatch, { passive: true });
+ if (window.visualViewport) window.visualViewport.addEventListener('resize', orbWatch, { passive: true });
+ }
+ }
function close({ navigating = false } = {}) {
function close({ navigating = false } = {}) {
// The tab flies back into the bar button that opened it (not when a link was followed: the page is going).
// The tab flies back into the bar button that opened it (not when a link was followed: the page is going).
- const backTo = flyFrom; flyFrom = null;
+ // Only while the menu still shows what the opener opened (11-14, the owner: "it goes to the user avatar instead of
+ // … going to the right icon"): opened from the picture and moved to Community, the close is the menu's own.
+ const backTo = flyFrom && !door && tab === fromTab ? flyFrom : null; flyFrom = null; fromTab = null;
if (backTo && !navigating && el && el.classList.contains('is-open') && !closing) fly(tabFace(door || tab), backTo, { back: true, toFace: flyIdle });
if (backTo && !navigating && el && el.classList.contains('is-open') && !closing) fly(tabFace(door || tab), backTo, { back: true, toFace: flyIdle });
flyIdle = null;
flyIdle = null;
if (view === 'results' && rtabsEl) leaveResults();
if (view === 'results' && rtabsEl) leaveResults();
@@ -2608,7 +2646,9 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -2608,7 +2646,9 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
if (!el || closing) return;
if (!el || closing) return;
if (navigating) { pushed = false; markBack(); }
if (navigating) { pushed = false; markBack(); }
// Closed by the person (Close, Escape, a tap outside): take back the entry Back would have used.
// Closed by the person (Close, Escape, a tap outside): take back the entry Back would have used.
- if (pushed && !popping) { pushed = false; try { if (history.state && history.state.ovsMenu) { ownBack = true; history.back(); } } catch { /* */ } }
+ // The page stays where it is (11-14, the owner: closing "scrolls the page down"): the browser restores no scroll
+ // for this Back, and focus returns without scrolling.
+ if (pushed && !popping) { pushed = false; try { if (history.state && history.state.ovsMenu) { ownBack = true; keepScroll(); history.back(); } } catch { /* */ } }
// A phone's menu folds back into the orb (its x turns back into dots) before it goes; elsewhere it closes at once.
// A phone's menu folds back into the orb (its x turns back into dots) before it goes; elsewhere it closes at once.
if (phoneSheet() && !calm() && el.classList.contains('is-open')) {
if (phoneSheet() && !calm() && el.classList.contains('is-open')) {
el.classList.add('is-closing');
el.classList.add('is-closing');
@@ -2641,7 +2681,20 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -2641,7 +2681,20 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
if (inflight) { inflight.abort(); inflight = null; }
if (inflight) { inflight.abort(); inflight = null; }
if (ctx && typeof ctx.onClose === 'function') { try { ctx.onClose(); } catch { /* */ } }
if (ctx && typeof ctx.onClose === 'function') { try { ctx.onClose(); } catch { /* */ } }
try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: false } })); } catch { /* */ }
try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: false } })); } catch { /* */ }
- if (lastFocus && lastFocus.focus) lastFocus.focus();
+ if (lastFocus && lastFocus.focus) { try { lastFocus.focus({ preventScroll: true }); } catch { /* */ } }
+ }
+ /** Back taken by the menu itself: no scroll restored by the browser, and the page put back where it was if anything
+ * moved it (the scroll lock's release, a late restore). */
+ function keepScroll() {
+ try {
+ const was = history.scrollRestoration;
+ history.scrollRestoration = 'manual';
+ const y = Number(document.body && document.body.style.position === 'fixed' ? -parseFloat(document.body.style.top || '0') : root.scrollY) || 0;
+ let over = false;
+ const done = () => { if (over) return; over = true; root.removeEventListener('popstate', done); setTimeout(() => { try { history.scrollRestoration = was; } catch { /* */ } if (Math.abs((root.scrollY || 0) - y) > 2) root.scrollTo(0, y); }, 0); };
+ root.addEventListener('popstate', done);
+ setTimeout(done, 600);
+ } catch { /* */ }
}
}
// On a phone the empty menu keeps focus on its orb to avoid raising the keyboard. Escape must still close it.
// On a phone the empty menu keeps focus on its orb to avoid raising the keyboard. Escape must still close it.
@@ -119,8 +119,19 @@ a.sc-orb-node:hover .sc-orb-face>*:first-child,a.sc-orb-node:focus-visible .sc-o
@@ -119,8 +119,19 @@ a.sc-orb-node:hover .sc-orb-face>*:first-child,a.sc-orb-node:focus-visible .sc-o
@media (min-width:900px){.sc-grid:has(>:nth-child(5):last-child),.sc-grid:has(>:nth-child(6):last-child),.sc-grid:has(>:nth-child(9):last-child){grid-template-columns:repeat(3,minmax(0,1fr))}}
@media (min-width:900px){.sc-grid:has(>:nth-child(5):last-child),.sc-grid:has(>:nth-child(6):last-child),.sc-grid:has(>:nth-child(9):last-child){grid-template-columns:repeat(3,minmax(0,1fr))}}
.sc-card{display:flex;gap:14px;align-items:flex-start;padding:16px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
.sc-card{display:flex;gap:14px;align-items:flex-start;padding:16px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
-.sc-ic{width:40px;height:40px;flex:none;display:grid;place-items:center;border-radius:12px;font-size:17px;color:var(--accent-light,#60a5fa);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent)}
-.sc-ic.sc-ic-ov{background:none;border-radius:50%}
+/* 4.248.0 (the owner, 11-14: "redesign all these icons … use the full space … modern and minimalist … stands out
+ unique"): a filled tile on its own hue (six, in turn down a grid), the glyph large in it; a ring icon loses its ring. */
+.sc-card,.sc-grid>*{--sc-h:var(--accent,#3b82f6)}
+.sc-grid>:nth-child(6n+2){--sc-h:#10b981}.sc-grid>:nth-child(6n+3){--sc-h:#f59e0b}.sc-grid>:nth-child(6n+4){--sc-h:#8b5cf6}.sc-grid>:nth-child(6n+5){--sc-h:#ec4899}.sc-grid>:nth-child(6n){--sc-h:#06b6d4}
+.sc-ic{width:46px;height:46px;flex:none;display:grid;place-items:center;border-radius:14px;font-size:20px;overflow:hidden;color:color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 28%,#fff);background:linear-gradient(150deg,color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 42%,transparent),color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 16%,transparent));box-shadow:inset 0 1px 0 rgba(255,255,255,.14),0 8px 20px -10px color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 70%,transparent)}
+html[data-theme-mode=light] .sc-ic{color:color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 78%,#0b1220);background:linear-gradient(150deg,color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 24%,#fff),color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 10%,#fff))}
+.sc-ic.sc-ic-ov{border-radius:14px}
+.sc-ic .ovi-bg,.sc-ic .ovi-ring,.sc-ic .ovi-comet,.sc-ic .ovi-prog{display:none}
+.sc-ic .sc-ovi{--ovi-size:46px!important;display:grid;place-items:center;width:46px;height:46px;color:inherit}.sc-ic .sc-ovi svg{width:46px;height:46px;transform:scale(1.32)}.sc-ic .ovi-glyph{stroke:currentColor;stroke-width:2.3}
+.sc-ic .sc-ovi--glyph{--ovi-size:26px!important;width:26px;height:26px}.sc-ic>i{font-size:21px}
+a.sc-card:hover .sc-ic,a.sc-card:focus-visible .sc-ic{scale:1.06}.sc-ic{transition:scale .2s cubic-bezier(.2,.9,.3,1.2)}
+/* A phone: the tile beside the title, the words under both, the card's whole width (no empty column under the icon). */
+@media (max-width:600px){.sc-grid .sc-card{display:grid;grid-template-columns:auto minmax(0,1fr);column-gap:12px;align-items:center}.sc-grid .sc-card>span:last-child{display:contents}.sc-grid .sc-card b{grid-column:2}.sc-grid .sc-card small{grid-column:1/-1;margin-top:8px}}
.sc-ovi--glyph{border-radius:50%;color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 13%,transparent);box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--accent,#3b82f6) 42%,transparent)}.sc-ovi--glyph svg{width:54%!important;height:54%!important}
.sc-ovi--glyph{border-radius:50%;color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 13%,transparent);box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--accent,#3b82f6) 42%,transparent)}.sc-ovi--glyph svg{width:54%!important;height:54%!important}
.sc-card b{display:block;font-size:15.5px}.sc-card small{display:block;margin-top:3px;color:var(--text-secondary,#96a7c2);font-size:13.5px;line-height:1.45}
.sc-card b{display:block;font-size:15.5px}.sc-card small{display:block;margin-top:3px;color:var(--text-secondary,#96a7c2);font-size:13.5px;line-height:1.45}
.sc-steps{list-style:none;margin:0;padding:0;counter-reset:sc;display:grid;gap:14px 24px;grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
.sc-steps{list-style:none;margin:0;padding:0;counter-reset:sc;display:grid;gap:14px 24px;grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
@@ -189,14 +200,24 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
@@ -189,14 +200,24 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
.sc-spot-media img,.sc-spot-media video{display:block;width:100%;height:auto}
.sc-spot-media img,.sc-spot-media video{display:block;width:100%;height:auto}
.sc-spot h3{margin:0 0 8px;font-size:clamp(20px,2.2vw,26px);letter-spacing:-.01em}.sc-spot h3 a{color:inherit;text-decoration:none}.sc-spot h3 a:hover{color:var(--accent-light,#60a5fa)}
.sc-spot h3{margin:0 0 8px;font-size:clamp(20px,2.2vw,26px);letter-spacing:-.01em}.sc-spot h3 a{color:inherit;text-decoration:none}.sc-spot h3 a:hover{color:var(--accent-light,#60a5fa)}
.sc-spot p{margin:0;color:var(--text-secondary,#96a7c2);line-height:1.6;font-size:15.5px}.sc-spot-act{margin-top:16px}
.sc-spot p{margin:0;color:var(--text-secondary,#96a7c2);line-height:1.6;font-size:15.5px}.sc-spot-act{margin-top:16px}
-.sc-faq{display:grid;gap:8px;max-width:820px}
-.sc-faq details,.sc-sec>details:not([class]){border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
-.sc-faq summary,.sc-sec>details:not([class])>summary{cursor:pointer;list-style:none;padding:14px 44px 14px 16px;font-weight:700;position:relative}
+/* 4.248.0 (the owner, 11-14: "so much padding for no reason … more compact … no space between your streams and the
+ question section"): a compact row whatever a site's own details rule says, its glyph, the next block spaced. */
+.sc-faq{display:grid;gap:6px;max-width:820px}
+.sc-faq details,.sc-sec>details:not([class]){margin:0;padding:0!important;border-radius:12px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);transition:border-color .15s,background-color .15s}
+.sc-faq details[open]{border-color:color-mix(in srgb,var(--accent,#3b82f6) 34%,var(--border,rgba(255,255,255,.08)))}
+.sc-faq summary,.sc-sec>details:not([class])>summary{cursor:pointer;list-style:none;padding:12px 44px 12px 16px;font-weight:700;position:relative}
+.sc-faq summary{display:flex;align-items:center;gap:10px;min-height:48px;box-sizing:border-box;padding:8px 40px 8px 10px;font-weight:650;line-height:1.35}
+.sc-faq summary:hover{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 4%,transparent);border-radius:12px}
+.sc-q-ic{flex:none;display:grid;place-items:center;width:30px;height:30px;border-radius:9px;color:var(--accent-light,#60a5fa);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent)}
+.sc-q-ic svg{width:16px;height:16px}
+.sc-sec:has(>.sc-faq)+:not(.sc-sec),.sc-sec+:not(.sc-sec):not(script):not(style):not(link){margin-top:clamp(28px,5vw,44px)}
.sc-faq summary::-webkit-details-marker,.sc-sec>details:not([class])>summary::-webkit-details-marker{display:none}
.sc-faq summary::-webkit-details-marker,.sc-sec>details:not([class])>summary::-webkit-details-marker{display:none}
.sc-faq summary::after,.sc-sec>details:not([class])>summary::after{content:'';position:absolute;right:18px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}
.sc-faq summary::after,.sc-sec>details:not([class])>summary::after{content:'';position:absolute;right:18px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}
.sc-faq details[open] summary::after,.sc-sec>details[open]:not([class])>summary::after{transform:rotate(-135deg);margin-top:-2px}
.sc-faq details[open] summary::after,.sc-sec>details[open]:not([class])>summary::after{transform:rotate(-135deg);margin-top:-2px}
.sc-sec>details:not([class])+details:not([class]){margin-top:8px}
.sc-sec>details:not([class])+details:not([class]){margin-top:8px}
.sc-faq p,.sc-sec>details:not([class])>p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}
.sc-faq p,.sc-sec>details:not([class])>p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}
+.sc-faq p{padding:0 14px 12px 50px;font-size:14.5px}
+@media (max-width:600px){.sc-faq p{padding-left:14px}}
.sc-fold{border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-fold{border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-fold>summary{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:56px;padding:0 48px 0 18px;cursor:pointer;list-style:none;position:relative}
.sc-fold>summary{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:56px;padding:0 48px 0 18px;cursor:pointer;list-style:none;position:relative}
.sc-fold>summary::-webkit-details-marker{display:none}
.sc-fold>summary::-webkit-details-marker{display:none}
@@ -374,7 +374,20 @@ function faq({ id: sid, title = 'Questions', lede, items, icon: secIcon } = {})
@@ -374,7 +374,20 @@ function faq({ id: sid, title = 'Questions', lede, items, icon: secIcon } = {})
const usable = (items || []).filter((q) => Array.isArray(q) && q[0] && q[1]);
const usable = (items || []).filter((q) => Array.isArray(q) && q[0] && q[1]);
if (!usable.length) return '';
if (!usable.length) return '';
const h = `sc-${slug(title)}`;
const h = `sc-${slug(title)}`;
- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'help')}<div class="sc-faq">${usable.map(([q, a], i) => `<details${i === 0 ? ' open' : ''}><summary>${esc(q)}</summary><p>${esc(a)}</p></details>`).join('')}</div></section>`;
+ // 4.248.0 (the owner, 11-14: "so much padding for no reason … more compact … add icons"): each question wears a glyph,
+ // its own third item or one its words suggest; the row is compact whatever the site's own details rule says.
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'help')}<div class="sc-faq">${usable.map(([q, a, ic], i) => `<details${i === 0 ? ' open' : ''}><summary><span class="sc-q-ic" aria-hidden="true">${qGlyph(ic, q)}</span><span>${esc(q)}</span></summary><p>${esc(a)}</p></details>`).join('')}</div></section>`;
+}
+/** The glyph a question wears: the one it names, else the first its words suggest, else a question mark. */
+const Q_WORDS = [[/\b(free|cost|price|pay|paid|money|charge)/i, 'coin'], [/\b(key|safe|secure|privacy|private|password)/i, 'lock'],
+ [/\b(platform|where|which site|countr|languag)/i, 'globe'], [/\b(encode|quality|bitrate|resolution|setting)/i, 'sliders'],
+ [/\b(obs|camera|webcam|record|video|vod|clip)/i, 'video'], [/\b(appear|show|see|visible|public)/i, 'eye'], [/\b(delete|remove|cancel)/i, 'trash'],
+ [/\b(account|sign|login|profile)/i, 'user'], [/\b(api|sdk|code|developer|bot)/i, 'code'], [/\b(live|stream|broadcast)/i, 'live'],
+ [/\b(how long|when|time|schedule)/i, 'clock'], [/\b(download|export|backup)/i, 'download'], [/\b(ai|agent|model)/i, 'spark']];
+function qGlyph(named, q) {
+ headGlyphs = headGlyphs || require('./glyphs');
+ const want = named && headGlyphs.NAMES.includes(String(named)) ? String(named) : ((Q_WORDS.find(([re]) => re.test(String(q || ''))) || [])[1] || 'help');
+ return headGlyphs.svg(headGlyphs.NAMES.includes(want) ? want : 'help', { size: 16 });
}
}
/** A JSON-LD block that cannot close its script early. */
/** A JSON-LD block that cannot close its script early. */
const jsonLd = (obj) => `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
const jsonLd = (obj) => `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
@@ -654,8 +667,19 @@ a.sc-orb-node:hover .sc-orb-face>*:first-child,a.sc-orb-node:focus-visible .sc-o
@@ -654,8 +667,19 @@ a.sc-orb-node:hover .sc-orb-face>*:first-child,a.sc-orb-node:focus-visible .sc-o
@media (min-width:900px){.sc-grid:has(>:nth-child(5):last-child),.sc-grid:has(>:nth-child(6):last-child),.sc-grid:has(>:nth-child(9):last-child){grid-template-columns:repeat(3,minmax(0,1fr))}}
@media (min-width:900px){.sc-grid:has(>:nth-child(5):last-child),.sc-grid:has(>:nth-child(6):last-child),.sc-grid:has(>:nth-child(9):last-child){grid-template-columns:repeat(3,minmax(0,1fr))}}
.sc-card{display:flex;gap:14px;align-items:flex-start;padding:16px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
.sc-card{display:flex;gap:14px;align-items:flex-start;padding:16px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
-.sc-ic{width:40px;height:40px;flex:none;display:grid;place-items:center;border-radius:12px;font-size:17px;color:var(--accent-light,#60a5fa);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent)}
-.sc-ic.sc-ic-ov{background:none;border-radius:50%}
+/* 4.248.0 (the owner, 11-14: "redesign all these icons … use the full space … modern and minimalist … stands out
+ unique"): a filled tile on its own hue (six, in turn down a grid), the glyph large in it; a ring icon loses its ring. */
+.sc-card,.sc-grid>*{--sc-h:var(--accent,#3b82f6)}
+.sc-grid>:nth-child(6n+2){--sc-h:#10b981}.sc-grid>:nth-child(6n+3){--sc-h:#f59e0b}.sc-grid>:nth-child(6n+4){--sc-h:#8b5cf6}.sc-grid>:nth-child(6n+5){--sc-h:#ec4899}.sc-grid>:nth-child(6n){--sc-h:#06b6d4}
+.sc-ic{width:46px;height:46px;flex:none;display:grid;place-items:center;border-radius:14px;font-size:20px;overflow:hidden;color:color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 28%,#fff);background:linear-gradient(150deg,color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 42%,transparent),color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 16%,transparent));box-shadow:inset 0 1px 0 rgba(255,255,255,.14),0 8px 20px -10px color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 70%,transparent)}
+html[data-theme-mode=light] .sc-ic{color:color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 78%,#0b1220);background:linear-gradient(150deg,color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 24%,#fff),color-mix(in srgb,var(--sc-h,var(--accent,#3b82f6)) 10%,#fff))}
+.sc-ic.sc-ic-ov{border-radius:14px}
+.sc-ic .ovi-bg,.sc-ic .ovi-ring,.sc-ic .ovi-comet,.sc-ic .ovi-prog{display:none}
+.sc-ic .sc-ovi{--ovi-size:46px!important;display:grid;place-items:center;width:46px;height:46px;color:inherit}.sc-ic .sc-ovi svg{width:46px;height:46px;transform:scale(1.32)}.sc-ic .ovi-glyph{stroke:currentColor;stroke-width:2.3}
+.sc-ic .sc-ovi--glyph{--ovi-size:26px!important;width:26px;height:26px}.sc-ic>i{font-size:21px}
+a.sc-card:hover .sc-ic,a.sc-card:focus-visible .sc-ic{scale:1.06}.sc-ic{transition:scale .2s cubic-bezier(.2,.9,.3,1.2)}
+/* A phone: the tile beside the title, the words under both, the card's whole width (no empty column under the icon). */
+@media (max-width:600px){.sc-grid .sc-card{display:grid;grid-template-columns:auto minmax(0,1fr);column-gap:12px;align-items:center}.sc-grid .sc-card>span:last-child{display:contents}.sc-grid .sc-card b{grid-column:2}.sc-grid .sc-card small{grid-column:1/-1;margin-top:8px}}
.sc-ovi--glyph{border-radius:50%;color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 13%,transparent);box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--accent,#3b82f6) 42%,transparent)}.sc-ovi--glyph svg{width:54%!important;height:54%!important}
.sc-ovi--glyph{border-radius:50%;color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 13%,transparent);box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--accent,#3b82f6) 42%,transparent)}.sc-ovi--glyph svg{width:54%!important;height:54%!important}
.sc-card b{display:block;font-size:15.5px}.sc-card small{display:block;margin-top:3px;color:var(--text-secondary,#96a7c2);font-size:13.5px;line-height:1.45}
.sc-card b{display:block;font-size:15.5px}.sc-card small{display:block;margin-top:3px;color:var(--text-secondary,#96a7c2);font-size:13.5px;line-height:1.45}
.sc-steps{list-style:none;margin:0;padding:0;counter-reset:sc;display:grid;gap:14px 24px;grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
.sc-steps{list-style:none;margin:0;padding:0;counter-reset:sc;display:grid;gap:14px 24px;grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
@@ -724,14 +748,24 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
@@ -724,14 +748,24 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
.sc-spot-media img,.sc-spot-media video{display:block;width:100%;height:auto}
.sc-spot-media img,.sc-spot-media video{display:block;width:100%;height:auto}
.sc-spot h3{margin:0 0 8px;font-size:clamp(20px,2.2vw,26px);letter-spacing:-.01em}.sc-spot h3 a{color:inherit;text-decoration:none}.sc-spot h3 a:hover{color:var(--accent-light,#60a5fa)}
.sc-spot h3{margin:0 0 8px;font-size:clamp(20px,2.2vw,26px);letter-spacing:-.01em}.sc-spot h3 a{color:inherit;text-decoration:none}.sc-spot h3 a:hover{color:var(--accent-light,#60a5fa)}
.sc-spot p{margin:0;color:var(--text-secondary,#96a7c2);line-height:1.6;font-size:15.5px}.sc-spot-act{margin-top:16px}
.sc-spot p{margin:0;color:var(--text-secondary,#96a7c2);line-height:1.6;font-size:15.5px}.sc-spot-act{margin-top:16px}
-.sc-faq{display:grid;gap:8px;max-width:820px}
-.sc-faq details,.sc-sec>details:not([class]){border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
-.sc-faq summary,.sc-sec>details:not([class])>summary{cursor:pointer;list-style:none;padding:14px 44px 14px 16px;font-weight:700;position:relative}
+/* 4.248.0 (the owner, 11-14: "so much padding for no reason … more compact … no space between your streams and the
+ question section"): a compact row whatever a site's own details rule says, its glyph, the next block spaced. */
+.sc-faq{display:grid;gap:6px;max-width:820px}
+.sc-faq details,.sc-sec>details:not([class]){margin:0;padding:0!important;border-radius:12px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);transition:border-color .15s,background-color .15s}
+.sc-faq details[open]{border-color:color-mix(in srgb,var(--accent,#3b82f6) 34%,var(--border,rgba(255,255,255,.08)))}
+.sc-faq summary,.sc-sec>details:not([class])>summary{cursor:pointer;list-style:none;padding:12px 44px 12px 16px;font-weight:700;position:relative}
+.sc-faq summary{display:flex;align-items:center;gap:10px;min-height:48px;box-sizing:border-box;padding:8px 40px 8px 10px;font-weight:650;line-height:1.35}
+.sc-faq summary:hover{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 4%,transparent);border-radius:12px}
+.sc-q-ic{flex:none;display:grid;place-items:center;width:30px;height:30px;border-radius:9px;color:var(--accent-light,#60a5fa);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent)}
+.sc-q-ic svg{width:16px;height:16px}
+.sc-sec:has(>.sc-faq)+:not(.sc-sec),.sc-sec+:not(.sc-sec):not(script):not(style):not(link){margin-top:clamp(28px,5vw,44px)}
.sc-faq summary::-webkit-details-marker,.sc-sec>details:not([class])>summary::-webkit-details-marker{display:none}
.sc-faq summary::-webkit-details-marker,.sc-sec>details:not([class])>summary::-webkit-details-marker{display:none}
.sc-faq summary::after,.sc-sec>details:not([class])>summary::after{content:'';position:absolute;right:18px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}
.sc-faq summary::after,.sc-sec>details:not([class])>summary::after{content:'';position:absolute;right:18px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}
.sc-faq details[open] summary::after,.sc-sec>details[open]:not([class])>summary::after{transform:rotate(-135deg);margin-top:-2px}
.sc-faq details[open] summary::after,.sc-sec>details[open]:not([class])>summary::after{transform:rotate(-135deg);margin-top:-2px}
.sc-sec>details:not([class])+details:not([class]){margin-top:8px}
.sc-sec>details:not([class])+details:not([class]){margin-top:8px}
.sc-faq p,.sc-sec>details:not([class])>p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}
.sc-faq p,.sc-sec>details:not([class])>p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}
+.sc-faq p{padding:0 14px 12px 50px;font-size:14.5px}
+@media (max-width:600px){.sc-faq p{padding-left:14px}}
.sc-fold{border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-fold{border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-fold>summary{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:56px;padding:0 48px 0 18px;cursor:pointer;list-style:none;position:relative}
.sc-fold>summary{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:56px;padding:0 48px 0 18px;cursor:pointer;list-style:none;position:relative}
.sc-fold>summary::-webkit-details-marker{display:none}
.sc-fold>summary::-webkit-details-marker{display:none}
@@ -15,7 +15,9 @@ assert.ok(/const contextBar = \(spec = \{\}\) => \{[\s\S]{0,400}showBar\(PATH_OK
@@ -15,7 +15,9 @@ assert.ok(/const contextBar = \(spec = \{\}\) => \{[\s\S]{0,400}showBar\(PATH_OK
assert.ok(/\}, 'edit'\);/.test(src) && /was\.onClear\(\); \}, 'reply'\);/.test(src), 'edit and reply say their kind');
assert.ok(/\}, 'edit'\);/.test(src) && /was\.onClear\(\); \}, 'reply'\);/.test(src), 'edit and reply say their kind');
assert.ok(/\.ovc-v2 \.ovc-bar::before \{ content: ""; position: absolute; left: 6px;[^}]*background: var\(--ovt-accent/.test(src), 'a rail of the accent at its start');
assert.ok(/\.ovc-v2 \.ovc-bar::before \{ content: ""; position: absolute; left: 6px;[^}]*background: var\(--ovt-accent/.test(src), 'a rail of the accent at its start');
assert.ok(/\.ovc-v2 \.ovc-bar\[data-kind=edit\]::before \{ background: var\(--warning/.test(src), 'editing wears its own colour');
assert.ok(/\.ovc-v2 \.ovc-bar\[data-kind=edit\]::before \{ background: var\(--warning/.test(src), 'editing wears its own colour');
-assert.ok(/\.ovc-v2 \.ovt-send--round:disabled \{ opacity: 1; background: transparent;[^}]*box-shadow: inset 0 0 0 1\.5px/.test(src), 'nothing to send: a quiet outline, not a grey disc');
+// 4.248.0 (the owner, 11-14, circling the outlined send: "How is the centering and offset of icons still such a problem?";
+// "give them that more 3D look"): nothing to send is a dim disk like the buttons beside it, never an outline.
+assert.ok(/\.ovc-v2 \.ovt-send--round:disabled \{ opacity: 1; filter: none; background: linear-gradient/.test(src) && !/\.ovc-v2 \.ovt-send--round:disabled \{[^}]*inset 0 0 0 1\.5px/.test(src), 'nothing to send: a dim disk, not an outline');
assert.ok(/\.ovc-v2 \.ovt-send--round:not\(:disabled\) \{ animation: ovcSendReady/.test(src), 'ready: it fills on a spring');
assert.ok(/\.ovc-v2 \.ovt-send--round:not\(:disabled\) \{ animation: ovcSendReady/.test(src), 'ready: it fills on a spring');
assert.ok(/html\[data-ov-motion=off\] \.ovc-v2 \.ovt-send--round/.test(src) && /html\[data-ov-motion=off\] \.ovc-v2 \.ovc-bar \{ animation: none; \}/.test(src), 'and nothing moves with motion off');
assert.ok(/html\[data-ov-motion=off\] \.ovc-v2 \.ovt-send--round/.test(src) && /html\[data-ov-motion=off\] \.ovc-v2 \.ovc-bar \{ animation: none; \}/.test(src), 'and nothing moves with motion off');
console.log('composer-bar-send: a context bar of any kind inside the box, a quiet Send until there is something to send');
console.log('composer-bar-send: a context bar of any kind inside the box, a quiet Send until there is something to send');
@@ -13,7 +13,9 @@ const { brotli } = require('../scripts/size-report');
@@ -13,7 +13,9 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
const ICONS = require('../nav-icons');
-const NAVBAR_BROTLI_BUDGET = 54.6 * 1024; // 54.4 KB with the content hash of search.js's close that goes home (4.246.0, 2026-11-13: CI's brotli measured 54.4 against 54.4; budgets grow for a feature); before: 54.2 KB with the names wearing what their people wear (4.244.0, 2026-11-13, plan §Z.3: OV.names(scope) and the items loader); before: 53.3 KB with the viewer model (4.243.0, 2026-11-13, plan §Z.2: OV.viewer(), data-ov-viewer and the openvibe-viewer event); before, 52.3 KB with the menu button drawn from the menu (4.240.0, 2026-11-13: the owner's "regenerate this icon … based on what the menu actually looks like … glow"); before: // 51.6 KB with menu addresses (data-ov-menu, #ov-menu=, menuAddress; 4.229.0, 2026-11-12: the owner's "other tools and buttons open specific areas of the menu"; budgets grow for a feature); before that: 50.9 KB with Back to the menu, the links' warm-up and token() (4.229.0, 2026-11-12: the owner's back button and fast links; budgets grow for a feature); before that: 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
+// 56.4 KB since 4.248.0 (2026-11-14): rows that scroll sideways peek, the Sign in wait on its rim, a stored guest answer that
+// no longer keeps a person signed out (the owner's asks of 11-14; budgets grow for a feature).
+const NAVBAR_BROTLI_BUDGET = 56.4 * 1024; // 54.4 KB with the content hash of search.js's close that goes home (4.246.0, 2026-11-13: CI's brotli measured 54.4 against 54.4; budgets grow for a feature); before: 54.2 KB with the names wearing what their people wear (4.244.0, 2026-11-13, plan §Z.3: OV.names(scope) and the items loader); before: 53.3 KB with the viewer model (4.243.0, 2026-11-13, plan §Z.2: OV.viewer(), data-ov-viewer and the openvibe-viewer event); before, 52.3 KB with the menu button drawn from the menu (4.240.0, 2026-11-13: the owner's "regenerate this icon … based on what the menu actually looks like … glow"); before: // 51.6 KB with menu addresses (data-ov-menu, #ov-menu=, menuAddress; 4.229.0, 2026-11-12: the owner's "other tools and buttons open specific areas of the menu"; budgets grow for a feature); before that: 50.9 KB with Back to the menu, the links' warm-up and token() (4.229.0, 2026-11-12: the owner's back button and fast links; budgets grow for a feature); before that: 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
// ── size budget and generated block ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{
@@ -472,6 +472,17 @@ const USER = { id: 7, username: 'vibe', display_name: 'Vibe', avatar_url: null,
@@ -472,6 +472,17 @@ const USER = { id: 7, username: 'vibe', display_name: 'Vibe', avatar_url: null,
assert.strictEqual(calls.filter(c => c.navigation).length, 0, 'guest hint: no silent navigation');
assert.strictEqual(calls.filter(c => c.navigation).length, 0, 'guest hint: no silent navigation');
}
}
+ {
+ // 11-14: a round trip that once found no session (the 'guest' kept in storage) does not stop the next one when the
+ // person arrives from another OpenVibe site (openvibe.tours stayed signed out).
+ const { navbar, calls } = loadNavbar({
+ hostname: 'openvibe.tours', storage: { ov_sso_hint: 'guest' }, referrer: 'https://openvibe.network/',
+ fetch: async () => jsonResponse(401, {}),
+ });
+ navbar.init({ service: 'tours', sessionUrl: '/auth/me', fedcm: false, silentLogin: '/auth/login?silent=1&next={url}' });
+ await navbar.refreshAuth();
+ assert.strictEqual(calls.filter(c => c.navigation).length, 1, 'stored guest + an OpenVibe referrer: one silent round trip');
+ }
console.log('navbar-auth: all tests passed');
console.log('navbar-auth: all tests passed');
})().catch((err) => {
})().catch((err) => {
console.error(err);
console.error(err);
@@ -501,7 +501,9 @@ const created = (subject, over = {}) => ({
@@ -501,7 +501,9 @@ const created = (subject, over = {}) => ({
// 26.5 → 26.8 KB the same day (4.238.0): the menu's foot as two OV buttons and a compact empty state (26.52 KB measured).
// 26.5 → 26.8 KB the same day (4.238.0): the menu's foot as two OV buttons and a compact empty state (26.52 KB measured).
// 27.1 KB (4.238.0, 2026-11-13): the ringing bell (its drawing, the swing and the waves), the drawn arrow on Full inbox,
// 27.1 KB (4.238.0, 2026-11-13): the ringing bell (its drawing, the swing and the waves), the drawn arrow on Full inbox,
// 44 px round buttons with centred glyphs.
// 44 px round buttons with centred glyphs.
- assert.ok(size('notification-ui.js') <= 27.1 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 27.1 KB`);
+ // 28.2 KB (4.248.0, 2026-11-14): the ⋯ menu instead of the X, the buttons a notice brings (rich_content.actions), the
+ // social-feed rows (the owner: "no spacing … confusing to have an X … more like social media").
+ assert.ok(size('notification-ui.js') <= 28.2 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 28.2 KB`);
console.log('notification live: all checks passed');
console.log('notification live: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
@@ -44,7 +44,8 @@ const server = http.createServer((req, res) => {
@@ -44,7 +44,8 @@ const server = http.createServer((req, res) => {
assert.equal(await cs('sw', 'transform', '::before'), 'none', 'a switch has no check mark');
assert.equal(await cs('sw', 'transform', '::before'), 'none', 'a switch has no check mark');
assert.equal(await cs('mine', 'width'), '30px', 'a site\'s own rule wins');
assert.equal(await cs('mine', 'width'), '30px', 'a site\'s own rule wins');
assert.notEqual(await cs('native', 'appearance'), 'none', 'data-ov-native keeps the browser\'s box');
assert.notEqual(await cs('native', 'appearance'), 'none', 'data-ov-native keeps the browser\'s box');
- assert.ok(/transform/.test(await cs('b', 'transitionProperty')), 'a button answers a press');
+ // 4.248.0: the press is `scale` (never `transform`, which a site may position a button with).
+ assert.ok(/scale/.test(await cs('b', 'transitionProperty')), 'a button answers a press');
await p.goto(`${base}/?motion=off`, { maxMs: 5000 });
await p.goto(`${base}/?motion=off`, { maxMs: 5000 });
assert.equal(await cs('off', 'transitionDuration', '::before'), '0s', 'motion Off: the check does not move');
assert.equal(await cs('off', 'transitionDuration', '::before'), '0s', 'motion Off: the check does not move');
assert.equal(await cs('b', 'transitionDuration'), '0s', 'motion Off: buttons do not move');
assert.equal(await cs('b', 'transitionDuration'), '0s', 'motion Off: buttons do not move');
@@ -75,7 +75,7 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
@@ -75,7 +75,7 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
assert.ok(real.includes('data-icon="calendar"') && !real.includes('sc-ovi--glyph'), 'a name the ring set has is its own mark');
assert.ok(real.includes('data-icon="calendar"') && !real.includes('sc-ovi--glyph'), 'a name the ring set has is its own mark');
assert.ok(!/data-icon="calendar"[^>]*>[^<]*<svg viewBox="0 0 48 48"/.test(cal), 'not the ring set\'s fallback');
assert.ok(!/data-icon="calendar"[^>]*>[^<]*<svg viewBox="0 0 48 48"/.test(cal), 'not the ring set\'s fallback');
assert.ok(ring.includes('<span class="sc-ic"><i class="fa-solid fa-code"'), 'a Font Awesome icon keeps the plain slot');
assert.ok(ring.includes('<span class="sc-ic"><i class="fa-solid fa-code"'), 'a Font Awesome icon keeps the plain slot');
- assert.ok(sc.CSS.includes('.sc-ic.sc-ic-ov{background:none'), 'the slot drops its tile for a ring icon');
+ assert.ok(sc.CSS.includes('.sc-ic .ovi-ring') && sc.CSS.includes('.sc-ic.sc-ic-ov{border-radius:14px}'), 'a ring icon sits in the tile without its ring (4.248.0)');
}
}
// Next steps (Shared ADR 0002): safe links only, the referral tag, the target's ring icon and name, escaping, at most four.
// Next steps (Shared ADR 0002): safe links only, the referral tag, the target's ring icon and name, escaping, at most four.
@@ -121,7 +121,7 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
@@ -121,7 +121,7 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
assert.ok(sp.includes('<video src="/v.mp4" muted loop playsinline autoplay preload="none" poster="/p.jpg"'), 'a short muted loop, with its poster');
assert.ok(sp.includes('<video src="/v.mp4" muted loop playsinline autoplay preload="none" poster="/p.jpg"'), 'a short muted loop, with its poster');
assert.strictEqual((sp.match(/class="sc-spot"/g) || []).length, 2);
assert.strictEqual((sp.match(/class="sc-spot"/g) || []).length, 2);
const fq = sc.faq({ items: [['Do I need OBS?', 'No.'], ['Broken'], ['Free?', 'Yes.']] });
const fq = sc.faq({ items: [['Do I need OBS?', 'No.'], ['Broken'], ['Free?', 'Yes.']] });
- assert.ok(fq.includes('<details open><summary>Do I need OBS?</summary>') && (fq.match(/<details/g) || []).length === 2, 'an accordion, the first open');
+ assert.ok(fq.includes('<details open><summary><span class="sc-q-ic" aria-hidden="true">') && fq.includes('<span>Do I need OBS?</span></summary>') && (fq.match(/<details/g) || []).length === 2, 'an accordion, the first open');
const ld = sc.faqJsonLd([['Q </script>', 'A']]);
const ld = sc.faqJsonLd([['Q </script>', 'A']]);
assert.ok(ld.startsWith('<script type="application/ld+json">') && !ld.slice(35, -9).includes('<'), 'JSON-LD cannot close its script early');
assert.ok(ld.startsWith('<script type="application/ld+json">') && !ld.slice(35, -9).includes('<'), 'JSON-LD cannot close its script early');
assert.ok(JSON.parse(ld.slice(35, -9).replace(/\\u003c/g, '<')).mainEntity[0].name === 'Q </script>');
assert.ok(JSON.parse(ld.slice(35, -9).replace(/\\u003c/g, '<')).mainEntity[0].name === 'Q </script>');
@@ -52,7 +52,9 @@ assert.ok(LT.arctic['--success'], 'loader entries carry the full token set');
@@ -52,7 +52,9 @@ assert.ok(LT.arctic['--success'], 'loader entries carry the full token set');
// 63,500 since 4.111.1 (2026-11-05): the FAVICON block draws the Pair (about 1.2 KB raw) instead of the retired ring.
// 63,500 since 4.111.1 (2026-11-05): the FAVICON block draws the Pair (about 1.2 KB raw) instead of the retired ring.
// 66,500 since 4.183.0 (2026-11-10): motion has four choices, --ov-move, and Calm paces endless loops (about 2.9 KB raw).
// 66,500 since 4.183.0 (2026-11-10): motion has four choices, --ov-move, and Calm paces endless loops (about 2.9 KB raw).
// 67,600 since 4.186.0 (2026-11-10): a control nobody styled wears the theme (three rules, about 0.9 KB raw with their note).
// 67,600 since 4.186.0 (2026-11-10): a control nobody styled wears the theme (three rules, about 0.9 KB raw with their note).
-assert.ok(loader.length < 67600, `theme-loader.js stays small for first paint (${loader.length} characters)`);
+// 75,000 since 4.248.0 (2026-11-14): every time in the reader's own zone and clock (time.format, <time data-ov-time>,
+// the clock and tz settings; about 7 KB raw with its notes; the owner: "show the time based on the users computer").
+assert.ok(loader.length < 75000, `theme-loader.js stays small for first paint (${loader.length} characters)`);
assert.ok(loader.includes('KNOWN_PROPS'), 'loader clears tokens the new theme does not set');
assert.ok(loader.includes('KNOWN_PROPS'), 'loader clears tokens the new theme does not set');
// 5. Derived tokens: text on the accent always reads, on every theme.
// 5. Derived tokens: text on the accent always reads, on every theme.
@@ -353,7 +353,11 @@
@@ -353,7 +353,11 @@
// ── Display settings, one record for every OpenVibe site: display.get() → DISPLAY_OPTS' values; display.set(patch)
// ── Display settings, one record for every OpenVibe site: display.get() → DISPLAY_OPTS' values; display.set(patch)
// applies here and in other tabs and saves to the account. <html data-ov-*> and --ov-text-scale before first paint.
// applies here and in other tabs and saves to the account. <html data-ov-*> and --ov-text-scale before first paint.
- const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'full', 'reduced', 'off'], text: ['100', '112', '125'], seasonal: ['on', 'off'], scheme: ['manual', 'auto'], contrast: ['normal', 'more'], font: ['default', 'readable'], colors: ['default', 'cb-safe'], oled: ['off', 'on'], icons: ['auto', 'line', 'solid'], window: ['glass', 'solid', 'outline'] };
+ const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'full', 'reduced', 'off'], text: ['100', '112', '125'], seasonal: ['on', 'off'], scheme: ['manual', 'auto'], contrast: ['normal', 'more'], font: ['default', 'readable'], colors: ['default', 'cb-safe'], oled: ['off', 'on'], icons: ['auto', 'line', 'solid'], window: ['glass', 'solid', 'outline'], clock: ['auto', '12', '24'], tz: ['auto'] };
+ // Time (4.248.0; the owner, 11-14: "show the time based on the user's computer by default or configurable setting if
+ // logged in and changed"): clock auto|12|24 and tz auto or an IANA zone (any zone this browser knows, not only 'auto').
+ function zoneOk(z) { if (!/^[A-Za-z][A-Za-z0-9_+\-]*(\/[A-Za-z0-9_+\-]+){0,2}$/.test(String(z || '')) || String(z).length > 40) return false; try { new Intl.DateTimeFormat('en-US', { timeZone: String(z) }); return true; } catch (e) { return false; } }
+ function okDisplay(k, v) { return !!DISPLAY_OPTS[k] && (DISPLAY_OPTS[k].indexOf(String(v)) >= 0 || (k === 'tz' && zoneOk(v))); }
// Status names some sites use (--error, --error-text, --ok, --warn, --accent-text) that no theme sets: they resolve to the theme's
// Status names some sites use (--error, --error-text, --ok, --warn, --accent-text) that no theme sets: they resolve to the theme's
// readable status colours instead of each site's fallback literal (pale on light themes). :where() keeps them
// readable status colours instead of each site's fallback literal (pale on light themes). :where() keeps them
// below any site's own value (4.86.0).
// below any site's own value (4.86.0).
@@ -404,8 +408,8 @@
@@ -404,8 +408,8 @@
document.addEventListener('animationstart', function (e) { if (document.documentElement.getAttribute('data-ov-motion') === 'reduced') paceLoops(e.target); }, true);
document.addEventListener('animationstart', function (e) { if (document.documentElement.getAttribute('data-ov-motion') === 'reduced') paceLoops(e.target); }, true);
}
}
function readDisplay() {
function readDisplay() {
- const out = { motion: 'auto', text: '100', seasonal: 'on', scheme: 'manual', contrast: 'normal', font: 'default', colors: 'default', oled: 'off', icons: 'auto', window: 'glass' };
- try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null') || {}; for (const k in DISPLAY_OPTS) if (DISPLAY_OPTS[k].indexOf(String(d[k])) >= 0) out[k] = String(d[k]); } catch (e) { /* */ }
+ const out = { motion: 'auto', text: '100', seasonal: 'on', scheme: 'manual', contrast: 'normal', font: 'default', colors: 'default', oled: 'off', icons: 'auto', window: 'glass', clock: 'auto', tz: 'auto' };
+ try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null') || {}; for (const k in DISPLAY_OPTS) if (okDisplay(k, d[k])) out[k] = String(d[k]); } catch (e) { /* */ }
return out;
return out;
}
}
function applyDisplay(d) {
function applyDisplay(d) {
@@ -430,7 +434,7 @@
@@ -430,7 +434,7 @@
function setDisplay(patch, opts) {
function setDisplay(patch, opts) {
const d = readDisplay();
const d = readDisplay();
const was = d.seasonal + '|' + d.scheme;
const was = d.seasonal + '|' + d.scheme;
- for (const k in DISPLAY_OPTS) if (patch && DISPLAY_OPTS[k].indexOf(String(patch[k])) >= 0) d[k] = String(patch[k]);
+ for (const k in DISPLAY_OPTS) if (patch && okDisplay(k, patch[k])) d[k] = String(patch[k]);
// A change made here is dated now; one adopted from the account or another site keeps its own date.
// A change made here is dated now; one adopted from the account or another site keeps its own date.
const at = opts && Number(opts.at) > 0 ? Number(opts.at) : (opts && opts.local ? displayAt() : Date.now());
const at = opts && Number(opts.at) > 0 ? Number(opts.at) : (opts && opts.local ? displayAt() : Date.now());
try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(Object.assign({}, d, { at: at }))); } catch (e) { /* */ }
try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(Object.assign({}, d, { at: at }))); } catch (e) { /* */ }
@@ -587,7 +591,7 @@
@@ -587,7 +591,7 @@
const da = clamp(L.da), ta = clamp(L.ta);
const da = clamp(L.da), ta = clamp(L.ta);
if (L.d && typeof L.d === 'object' && da > displayAt()) {
if (L.d && typeof L.d === 'object' && da > displayAt()) {
const d = readDisplay();
const d = readDisplay();
- for (const k in DISPLAY_OPTS) if (DISPLAY_OPTS[k].indexOf(String(L.d[k])) >= 0) d[k] = String(L.d[k]);
+ for (const k in DISPLAY_OPTS) if (okDisplay(k, L.d[k])) d[k] = String(L.d[k]);
try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(Object.assign({}, d, { at: da }))); } catch (e) { /* */ }
try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(Object.assign({}, d, { at: da }))); } catch (e) { /* */ }
}
}
const id = String(L.t || '');
const id = String(L.t || '');
@@ -704,10 +708,87 @@
@@ -704,10 +708,87 @@
if (from && typeof location !== 'undefined' && from !== location.hostname) setTimeout(syncFromServer, 2500);
if (from && typeof location !== 'undefined' && from !== location.hostname) setTimeout(syncFromServer, 2500);
}
}
+ // ── Time, in the reader's own clock (4.248.0) ────────────
+ // Every OpenVibe page says a moment in the reader's time zone and clock: the device's by default, the account's choice
+ // when they made one (display tz and clock). A server writes <time datetime="…Z" data-ov-time="datetime|date|time|day">
+ // with a UTC fallback for crawlers and no-script; this rewrites it in place, again when the choice changes and for any
+ // such element added later. Script that draws a time calls time.format(value, kind).
+ function deviceZone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch (e) { return 'UTC'; } }
+ function timeZone() { const z = readDisplay().tz; return z && z !== 'auto' ? z : deviceZone(); }
+ function hour12() { const c = readDisplay().clock; return c === '12' ? true : c === '24' ? false : undefined; }
+ const KINDS = {
+ time: { hour: 'numeric', minute: '2-digit' },
+ date: { year: 'numeric', month: 'short', day: 'numeric' },
+ datetime: { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' },
+ full: { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' },
+ };
+ function dayKey(ms, zone) { try { return new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(ms); } catch (e) { return ''; } }
+ /** One moment as words in the reader's time: kind time | date | datetime | day (Today 3:05 PM, Yesterday …) | full. */
+ function formatTime(value, kind, opts) {
+ const ms = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(String(value || ''));
+ if (!Number.isFinite(ms)) return '';
+ const zone = timeZone(), h12 = hour12(), lang = (opts && opts.locale) || undefined;
+ const other = zone !== deviceZone(); // a zone the reader picked that is not this device's: say which
+ const fmt = (o) => { try { return new Intl.DateTimeFormat(lang, Object.assign({ timeZone: zone }, o, h12 === undefined ? {} : { hour12: h12 })).format(ms); } catch (e) { return new Date(ms).toISOString().slice(0, 16).replace('T', ' ') + ' UTC'; } };
+ const tzName = () => { try { const p = new Intl.DateTimeFormat(lang, { timeZone: zone, timeZoneName: 'short' }).formatToParts(ms).find((x) => x.type === 'timeZoneName'); return p ? ' ' + p.value : ''; } catch (e) { return ''; } };
+ const k = String(kind || 'datetime');
+ if (k === 'ago') {
+ // How long ago, the same words in every zone; the title (upgradeTimes) says the moment in the reader's time.
+ const sec = Math.max(0, Math.floor((Date.now() - ms) / 1000));
+ if (sec < 60) return 'just now';
+ const u = sec < 3600 ? ['minute', Math.floor(sec / 60)] : sec < 86400 ? ['hour', Math.floor(sec / 3600)] : sec < 2592000 ? ['day', Math.floor(sec / 86400)] : sec < 31536000 ? ['month', Math.floor(sec / 2592000)] : ['year', Math.floor(sec / 31536000)];
+ return u[1] + ' ' + u[0] + (u[1] === 1 ? '' : 's') + ' ago';
+ }
+ if (k === 'day') {
+ const now = Date.now(), today = dayKey(now, zone), that = dayKey(ms, zone), yesterday = dayKey(now - 86400000, zone);
+ const t = fmt(KINDS.time) + (other ? tzName() : '');
+ if (that === today) return 'Today ' + t;
+ if (that === yesterday) return 'Yesterday ' + t;
+ if (Math.abs(now - ms) < 6 * 86400000) return fmt({ weekday: 'short' }) + ' ' + t;
+ return fmt(new Date(ms).getUTCFullYear() === new Date(now).getUTCFullYear() ? { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' } : KINDS.datetime) + (other ? tzName() : '');
+ }
+ if (k === 'full') return fmt(KINDS.full);
+ return fmt(KINDS[k] || KINDS.datetime) + (other && k !== 'date' ? tzName() : '');
+ }
+ function upgradeTimes(scope) {
+ if (typeof document === 'undefined') return;
+ const list = (scope && scope.querySelectorAll ? scope : document).querySelectorAll('time[data-ov-time][datetime]');
+ for (let i = 0; i < list.length; i++) {
+ const el = list[i], words = formatTime(el.getAttribute('datetime'), el.getAttribute('data-ov-time'));
+ if (!words) continue;
+ if (el.textContent !== words) el.textContent = words;
+ const full = formatTime(el.getAttribute('datetime'), 'full');
+ if (full && el.getAttribute('title') !== full) el.setAttribute('title', full);
+ }
+ }
+ if (typeof document !== 'undefined' && document.addEventListener && typeof window !== 'undefined') {
+ let queued = null;
+ const later = (node) => {
+ if (!queued) { queued = []; (window.requestAnimationFrame || setTimeout)(function () { const q = queued; queued = null; for (let i = 0; i < q.length; i++) upgradeTimes(q[i]); }); }
+ queued.push(node);
+ };
+ const watch = function () {
+ upgradeTimes(document);
+ if (typeof MutationObserver === 'undefined' || !document.body) return;
+ new MutationObserver(function (records) {
+ for (let i = 0; i < records.length; i++) {
+ const added = records[i].addedNodes;
+ for (let j = 0; j < added.length; j++) { const n = added[j]; if (n.nodeType === 1 && (n.matches('time[data-ov-time]') ? true : n.querySelector && n.querySelector('time[data-ov-time]'))) later(n.matches('time[data-ov-time]') ? n.parentNode || n : n); }
+ }
+ }).observe(document.body, { childList: true, subtree: true });
+ };
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', watch); else setTimeout(watch, 0);
+ window.addEventListener('ov:display', function () { upgradeTimes(document); });
+ }
+
// ── Public API ───────────────────────────────────────────
// ── Public API ───────────────────────────────────────────
const OpenVibeThemeLoader = {
const OpenVibeThemeLoader = {
THEMES: THEMES,
THEMES: THEMES,
display: { get: readDisplay, set: setDisplay, options: DISPLAY_OPTS, motion: function () { return motionOf(); }, move: moveOf },
display: { get: readDisplay, set: setDisplay, options: DISPLAY_OPTS, motion: function () { return motionOf(); }, move: moveOf },
+ // The reader's time (4.248.0): format(value, 'time'|'date'|'datetime'|'day'|'full'), zone(), device(), hour12(),
+ // upgrade(scope) for <time data-ov-time> drawn by script, zones() → every IANA zone this browser knows.
+ time: { format: formatTime, zone: timeZone, device: deviceZone, hour12: hour12, upgrade: upgradeTimes, ok: zoneOk,
+ zones: function () { try { return Intl.supportedValuesOf('timeZone'); } catch (e) { return ['UTC']; } } },
apply: applyById,
apply: applyById,
applyVars: applyVars,
applyVars: applyVars,
// Seasons: SEASONS, now() → the season today (or null), showing() → the season whose theme is on screen (or null)
// Seasons: SEASONS, now() → the season today (or null), showing() → the season whose theme is on screen (or null)
@@ -356,6 +356,7 @@ const ovcNameMarkup = (who, words) => {
@@ -356,6 +356,7 @@ const ovcNameMarkup = (who, words) => {
const subject = ovcSubjectOf(who);
const subject = ovcSubjectOf(who);
return `<span${subject ? ` data-ov-subject="${subject}"` : ''}>${esc(words)}</span>`;
return `<span${subject ? ` data-ov-subject="${subject}"` : ''}>${esc(words)}</span>`;
};
};
+// UTC is what the server knows; the reader's own zone and clock replace it in the page (theme-loader's time, 4.248.0).
const date = (iso) => {
const date = (iso) => {
const ms = Date.parse(String(iso || ''));
const ms = Date.parse(String(iso || ''));
if (!Number.isFinite(ms)) return { short: '', full: '' };
if (!Number.isFinite(ms)) return { short: '', full: '' };
@@ -428,7 +429,7 @@ function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowed
@@ -428,7 +429,7 @@ function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowed
const more = Math.max(0, Number(c.reply_count || 0) - rows.length);
const more = Math.max(0, Number(c.reply_count || 0) - rows.length);
replies = `<div class="ovc-replies" data-ovc-replies="${id}">${rows.map((r) => comment(r, id)).join('')}${more ? `<a class="ovc-more ovk-btn ovk-btn--quiet" data-ovc-more="${id}" href="${query(base, sort, { replies: id })}">${more} more ${more === 1 ? 'reply' : 'replies'}</a>` : ''}</div>`;
replies = `<div class="ovc-replies" data-ovc-replies="${id}">${rows.map((r) => comment(r, id)).join('')}${more ? `<a class="ovc-more ovk-btn ovk-btn--quiet" data-ovc-more="${id}" href="${query(base, sort, { replies: id })}">${more} more ${more === 1 ? 'reply' : 'replies'}</a>` : ''}</div>`;
}
}
- return `<article class="ovc-comment" id="comment-${id}"><header>${avatar ? `<img src="${esc(avatar)}" alt="" width="32" height="32" loading="lazy">` : ''}${profile ? `<a href="${profile}">${ovcNameMarkup(c, name(c))}</a>` : ovcNameMarkup(c, name(c))}${when.short ? `<time datetime="${esc(c.created_at)}" title="${esc(when.full)}">${esc(when.short)}</time>` : ''}</header>${target}${gone ? '<p>Comment removed</p>' : `<p>${esc(c.message || '')}</p>${attachments(c)}`}${controls}${validReply === id ? `<div data-ovc-composer-slot>${composer_html(id)}</div>` : ''}${replies}</article>`;
+ return `<article class="ovc-comment" id="comment-${id}"><header>${avatar ? `<img src="${esc(avatar)}" alt="" width="32" height="32" loading="lazy">` : ''}${profile ? `<a href="${profile}">${ovcNameMarkup(c, name(c))}</a>` : ovcNameMarkup(c, name(c))}${when.short ? `<time datetime="${esc(c.created_at)}" data-ov-time="ago" title="${esc(when.full)}">${esc(when.short)}</time>` : ''}</header>${target}${gone ? '<p>Comment removed</p>' : `<p>${esc(c.message || '')}</p>${attachments(c)}`}${controls}${validReply === id ? `<div data-ovc-composer-slot>${composer_html(id)}</div>` : ''}${replies}</article>`;
};
};
const hasReplySlot = validReply && comments.some((c) => String(c.id) === validReply || (validExpanded === String(c.id) && Array.isArray(expandedReplies) ? expandedReplies : c.replies || []).some((r) => String(r.id) === validReply));
const hasReplySlot = validReply && comments.some((c) => String(c.id) === validReply || (validExpanded === String(c.id) && Array.isArray(expandedReplies) ? expandedReplies : c.replies || []).some((r) => String(r.id) === validReply));
// Top and Newest only when there is something to put in order.
// Top and Newest only when there is something to put in order.
@@ -64,6 +64,10 @@
@@ -64,6 +64,10 @@
.ovtip-dismiss{flex:0 0 auto;color:var(--text-muted,#8b93ad);background:none;border:1px solid var(--border,rgba(255,255,255,.12))}
.ovtip-dismiss{flex:0 0 auto;color:var(--text-muted,#8b93ad);background:none;border:1px solid var(--border,rgba(255,255,255,.12))}
.ovtip-go:active,.ovtip-dismiss:active{transform:scale(.975)}
.ovtip-go:active,.ovtip-dismiss:active{transform:scale(.975)}
.ovtip-go i{font-size:.72rem}
.ovtip-go i{font-size:.72rem}
+.ovtip-actions.is-build{flex-direction:column;align-items:stretch;gap:8px}
+.ovtip-build{flex:none;min-height:44px;text-decoration:none;font-weight:750;gap:9px;background:linear-gradient(160deg,color-mix(in srgb,var(--ovtip-accent,var(--accent,#60a5fa)) 70%,#fff),var(--ovtip-accent,var(--accent,#3b82f6)) 50%,color-mix(in srgb,var(--ovtip-accent,var(--accent,#3b82f6)) 75%,#000));box-shadow:inset 0 1px 0 rgba(255,255,255,.35),0 10px 22px -10px var(--ovtip-accent,var(--accent,#3b82f6))}
+.ovtip-build i{font-size:.9rem}
+.ovtip-second{color:var(--text-primary,#e6edf7)}
/* On a phone a 330px tip pinned beside a button is unreadable and often half off-screen. Dock it
/* On a phone a 330px tip pinned beside a button is unreadable and often half off-screen. Dock it
to the bottom of the viewport as a full-width card instead. */
to the bottom of the viewport as a full-width card instead. */
@media (hover:none){
@media (hover:none){
@@ -97,10 +101,19 @@
@@ -97,10 +101,19 @@
// On touch the first tap opens the tip and swallows the click, so without an explicit
// On touch the first tap opens the tip and swallows the click, so without an explicit
// action the visitor is left tapping a button that appears to do nothing. Give them the
// action the visitor is left tapping a button that appears to do nothing. Give them the
// destination as a real button inside the tip — and a way out that isn't "tap elsewhere".
// destination as a real button inside the tip — and a way out that isn't "tap elsewhere".
- const act = isTouch() ? `<div class="ovtip-actions">
+ // 4.248.0 (the owner, 11-14, on Live's Build tip: "bring users to openvibe.codes and … use their agent … right in the
+ // UI … start working on OpenVibe yourself … make that more prevalent"): data-ovtip-build (its words) and
+ // data-ovtip-build-href (https) put the main action first, wide, on every device; the trigger's own place second.
+ const buildHref = /^https:\/\//.test(el.getAttribute('data-ovtip-build-href') || '') ? el.getAttribute('data-ovtip-build-href') : '';
+ const build = el.getAttribute('data-ovtip-build') && buildHref ? `<div class="ovtip-actions is-build">
+ <a class="ovtip-go ovtip-build" href="${esc(buildHref)}"><i class="fa-solid fa-wand-magic-sparkles"></i>${esc(el.getAttribute('data-ovtip-build'))}</a>
+ <div class="ovtip-actions"><button type="button" class="ovtip-dismiss" data-ovtip-dismiss>Got it</button>
+ <button type="button" class="ovtip-dismiss ovtip-second" data-ovtip-go>${esc(el.getAttribute('data-ovtip-go') || 'Open')}</button></div>
+ </div>` : '';
+ const act = build || (isTouch() ? `<div class="ovtip-actions">
<button type="button" class="ovtip-dismiss" data-ovtip-dismiss>Got it</button>
<button type="button" class="ovtip-dismiss" data-ovtip-dismiss>Got it</button>
<button type="button" class="ovtip-go" data-ovtip-go>${esc(el.getAttribute('data-ovtip-go') || 'Open')}<i class="fa-solid fa-arrow-right"></i></button>
<button type="button" class="ovtip-go" data-ovtip-go>${esc(el.getAttribute('data-ovtip-go') || 'Open')}<i class="fa-solid fa-arrow-right"></i></button>
- </div>` : '';
+ </div>` : '');
return `${title ? `<div class="ovtip-head">${icon ? `<span class="ovtip-ico"><i class="fa-solid ${icon}"></i></span>` : ''}<span class="ovtip-title">${title}</span></div>` : ''}
return `${title ? `<div class="ovtip-head">${icon ? `<span class="ovtip-ico"><i class="fa-solid ${icon}"></i></span>` : ''}<span class="ovtip-title">${title}</span></div>` : ''}
<div class="ovtip-body">${body}</div>
<div class="ovtip-body">${body}</div>
${foot ? `<div class="ovtip-foot">${foot}</div>` : ''}${act}`;
${foot ? `<div class="ovtip-foot">${foot}</div>` : ''}${act}`;
@@ -142,7 +155,7 @@
@@ -142,7 +155,7 @@
tip.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
tip.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
arrow.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
arrow.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
tip.innerHTML = content(el);
tip.innerHTML = content(el);
- tip.classList.toggle('is-interactive', el.hasAttribute('data-ovtip-interactive') || isTouch());
+ tip.classList.toggle('is-interactive', el.hasAttribute('data-ovtip-interactive') || el.hasAttribute('data-ovtip-build') || isTouch());
tip.classList.remove('is-on'); arrow.classList.remove('is-on');
tip.classList.remove('is-on'); arrow.classList.remove('is-on');
cancelAnimationFrame(raf);
cancelAnimationFrame(raf);
place();
place();