openvibers/openvibe.shared Commits

Shared 4.248.0: every time in the reader's own clock (#536)

OP OpenVibers committed 1 h ago · c6a74d7497 · 30 files, +752 −103

* Shared 4.248.0: every time in the reader's own clock; no empty picture frames; a pressed button keeps its place

* Footer online box opens into a column (guests by site, live in red, today); the menu's close keeps the page still and goes home

* kit-layout: .ov-places, a page's places as a side column on a computer and fixed icon tabs on a phone

* Menu: signed in is the person the bar shows, not a token (Download's guest card under the owner's avatar)

* Showcase: feature icons as filled tiles on their own hue; compact questions with a glyph each; the next block spaced

* Notifications: one card per row with room between, a ⋯ menu instead of the X, buttons a notice brings

* Vibe is a conversation: filters on top, the agent fixed under them, History in the corner; the footer names a live streamer once

* Composer: hidden tools are gone everywhere, one lit 3D button system, centred glyphs, a custom animated send

* Navbar: a stored 'no session' answer no longer keeps a person signed out when they arrive from another OpenVibe site

* Bar: the menu button as a launcher, the Sign in wait on its rim, rows that scroll sideways peek once

* Tooltip: a main action that starts the work (data-ovtip-build)

* Menu: the input mark back without pink, the field and close on the radius scale and aligned, Vibe's strip quiet; places' motion rule; notification budget

* Alerts in the menu: the search and filter disks centred and on the tabs' line, the row right under the switch

* 4.248.0 checks: a failed feed picture is hidden (not removed), the bar's budget for its new parts, sibling revisions

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 5455279..65e88e3 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 0aa16d1..f19a148 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex fe565b0..5812e01 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/dock-timeline.js b/dock-timeline.js
iindex 8a7e231..4f8f898 100644
---- a/dock-timeline.js
++++ b/dock-timeline.js
@@@ -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;
}
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex a82959b..b76584c 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -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 box
form.setAttribute('data-ovc-no-files', '');
ddiff --git a/dock.js b/dock.js
iindex 6885db2..b337825 100644
---- a/dock.js
++++ b/dock.js
@@@ -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') });
ddiff --git a/footer.js b/footer.js
iindex 828214a..bb47dec 100644
---- a/footer.js
++++ b/footer.js
@@@ -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;opacity
const 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;opacity
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),
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;opacity
const 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;opacity
function 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;
ddiff --git a/kit-layout.css b/kit-layout.css
iindex c0f2a8d..9154c89 100644
---- a/kit-layout.css
++++ b/kit-layout.css
@@@ -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; }
++
ddiff --git a/navbar.js b/navbar.js
iindex fa0e1b1..5bd3f38 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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;
ddiff --git a/notification-ui.js b/notification-ui.js
iindex ca142ce..ab09a11 100644
---- a/notification-ui.js
++++ b/notification-ui.js
@@@ -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>
ddiff --git a/ov-composer.js b/ov-composer.js
iindex 510071e..49aed1a 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -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 quiet
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. */
--.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); } }
ddiff --git a/ov-forms.css b/ov-forms.css
iindex 9433996..8adc58c 100644
---- a/ov-forms.css
++++ b/ov-forms.css
@@@ -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);
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-behav
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"])::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;
ddiff --git a/ov-home.js b/ov-home.js
iindex da5bdd6..c064f3f 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -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}
ddiff --git a/ov-look.js b/ov-look.js
iindex 823201e..a802a95 100644
---- a/ov-look.js
++++ b/ov-look.js
@@@ -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 };
ddiff --git a/ov-run.js b/ov-run.js
iindex cbe4562..3f7ae45 100644
---- a/ov-run.js
++++ b/ov-run.js
@@@ -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; }
ddiff --git a/ov-thread.js b/ov-thread.js
iindex 1b14d9e..6c68073 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -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() {
ddiff --git a/package.json b/package.json
iindex 8d9f4c1..8398e34 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/search.js b/search.js
iindex fead88e..47aad1a 100644
---- a/search.js
++++ b/search.js
@@@ -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 it
allOpen = 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.
ddiff --git a/showcase.css b/showcase.css
iindex 7b783f8..64327a2 100644
---- a/showcase.css
++++ b/showcase.css
@@@ -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}
ddiff --git a/showcase.js b/showcase.js
iindex 22cb047..daeb1eb 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -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}
ddiff --git a/test/composer-bar-send.test.js b/test/composer-bar-send.test.js
iindex 55fc455..113e52a 100644
---- a/test/composer-bar-send.test.js
++++ b/test/composer-bar-send.test.js
@@@ -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(/\.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');
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex 52fb7b5..701fb23 100644
---- a/test/nav-icons.test.js
++++ b/test/nav-icons.test.js
@@@ -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 ──────────────────────────
{
ddiff --git a/test/navbar-auth.test.js b/test/navbar-auth.test.js
iindex 0a58cdb..ed2c067 100644
---- a/test/navbar-auth.test.js
++++ b/test/navbar-auth.test.js
@@@ -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);
ddiff --git a/test/notification-live.test.js b/test/notification-live.test.js
iindex d1cbcf9..0c759e5 100644
---- a/test/notification-live.test.js
++++ b/test/notification-live.test.js
@@@ -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); });
ddiff --git a/test/ov-forms-controls-chrome.test.js b/test/ov-forms-controls-chrome.test.js
iindex 48ba6c0..f72b769 100644
---- a/test/ov-forms-controls-chrome.test.js
++++ b/test/ov-forms-controls-chrome.test.js
@@@ -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');
ddiff --git a/test/showcase.test.js b/test/showcase.test.js
iindex a7a166b..b1cb91a 100644
---- a/test/showcase.test.js
++++ b/test/showcase.test.js
@@@ -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(!/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: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.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>');
ddiff --git a/test/theme-parity.test.js b/test/theme-parity.test.js
iindex cbe3f29..c436c24 100644
---- a/test/theme-parity.test.js
++++ b/test/theme-parity.test.js
@@@ -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.
ddiff --git a/theme-loader.js b/theme-loader.js
iindex 51696ab..146ba96 100644
---- a/theme-loader.js
++++ b/theme-loader.js
@@@ -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)
ddiff --git a/thread.js b/thread.js
iindex 4b6194f..3a13a14 100644
---- a/thread.js
++++ b/thread.js
@@@ -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, attachmentsAllowed
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>`;
}
-- 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.
ddiff --git a/tooltip.js b/tooltip.js
iindex a8f0278..03fcb10 100644
---- a/tooltip.js
++++ b/tooltip.js
@@@ -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 it
to 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();