openvibers/openvibe.extensions Commits

Companion 0.9.2: bundle OpenVibe.Shared 4.212.0 (#23)

OP OpenVibers committed 1 days ago · 963b8a0e71 · 32 files, +6662 −234

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 1da62c3..b8e1ca9 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,18 @@ The OpenVibe Companion's releases. Each one is a `browser/manifest.json` version
release](https://github.com/OpenVibers/OpenVibe.Extensions/releases); older entries are in the git
history.
++## 0.9.2 — 2026-11-11
++
++- **The side panel's Dock is OpenVibe.Shared 4.212.0** (was 4.124.0), copied from the release with the
++ Companion's own two patches kept: the read-only Dock token renders the four server controls as links
++ to openvibe.codes, and a Live frame that refuses embedding falls back to the still and a **Watch on
++ openvibe.live** link (Firefox and an unlisted Chrome id never try the frame).
++- The newer Dock brings its **Code** and **Actor** tabs, the one window (`ov-window.js`), the composer,
++ the glyph set, the theme loader and the agent sheet/session, all bundled under `browser/shared/`
++ because the extension's CSP allows no remote script.
++- `AGENT_EMBED_EXTENSION_ORIGINS` on OpenVibe.Live is set to the extension's fixed Chrome id
++ (`chrome-extension://bjcjcfcecpkkmfndegkjekoipdmbgakd`), so the panel can frame the agent stream.
++
## 0.9.1 — 2026-11-07
- **The side panel's agent view can read.** OpenVibe.Codes' two agent-view read routes now accept
ddiff --git a/README.md b/README.md
iindex 5e4d7a6..6e6e1e5 100644
---- a/README.md
++++ b/README.md
@@@ -6,7 +6,7 @@
**Build and test:** `npm install && npm run pack` (Node 22 or newer) · **Tests:** `npm test`
**Status:** alpha (roadmap Wave 18, client part). The extension builds, its tests pass, and headless
--Chrome loads the unpacked 0.9.0 build with no console or CSP errors (2026-11-07, Chrome 156 via
++Chrome loads the unpacked 0.9.2 build with no console or CSP errors (2026-11-07, Chrome 156 via
CDP `Extensions.loadUnpacked`; the side panel's Agents tab draws the Dock's agent view against stubbed first parties, see the credential note below). A development build of
the coupon part looked up codes and sent a report against a local OpenVibe.Coupons. Mozilla's
`web-ext lint` (what addons.mozilla.org runs on upload) passes with no errors, warnings or notices
@@@ -111,7 +111,7 @@ covered in Coupons' own tests: lookups are identical for every caller, and the A
The **OpenVibe Dock** — the pill, the card with its Vibe, Agents, Chats and Activity tabs — sits in
the browser's side panel (Chrome's `side_panel`, Firefox's `sidebar_action`), on every site. It is the
--released OpenVibe.Shared Dock 4.124.0, bundled under `browser/shared/` because the extension's CSP allows no
++released OpenVibe.Shared Dock 4.212.0, bundled under `browser/shared/` because the extension's CSP allows no
remote script, and it reads and writes the same state on the Network as the Dock on a site, so it
opens where the person left off. It hands that state to the Dock as it opens (the page's tab, chat, agent and
unread counts) and re-reads it every 30 s, so a change on the page reaches the open side panel.
@@@ -119,14 +119,15 @@ unread counts) and re-reads it every 30 s, so a change on the page reaches the o
The Agents tab draws the person's own desk stream the same way a site's Dock does: the Live embed, the
task line, and the owner's **Pause/Resume**, **Curtain now**, **Mute/Unmute** and **End** controls. The
view is read with the person's token from OpenVibe.Codes (`GET /api/v1/me/agent-view`); the extension
--allows Codes and Live's embed for exactly that. Codes' normal session check does not yet accept the
--Dock's read-only OAuth token (issuer `<network>/dock`), so a production side panel keeps the view
--hidden until Codes or the Network accepts that credential; `scripts/check-panel.js` proves the bundled
--Dock itself draws it. Live's embed also allows first-party site origins, not an extension origin yet.
++allows Codes and Live's embed for exactly that. Codes accepts the Dock's read-only OAuth token (issuer
++`<network>/dock`), so the panel shows the stream; its four server controls are links to openvibe.codes,
++where the person's own session does the same thing. The fixed Chrome id is in Live's
++`AGENT_EMBED_EXTENSION_ORIGINS`, and a browser that cannot frame the embed gets the still and a
++**Watch on openvibe.live** link instead. `scripts/check-panel.js` proves both in headless Chrome.
It needs the person's own OpenVibe sign-in. Press **Sign in** in the panel or the settings page: the
Network's account chooser opens, the extension is a public OAuth client (`companion`, PKCE S256, no
--secret), and the Dock gets a read-only token (`dock`, `activity`, `chat.read`) that refreshes itself.
++secret), and the Dock gets a read-only token (`dock`, `activity`, `chat.read`, `agent.view`) that refreshes itself.
The access token is kept in `storage.session`; the rotating refresh token in `storage.local`, on this
device. Sign out revokes it at the Network, and the account page (`/my`) lists it under **Signed-in
apps**. The 0.7.0 pasted-token path is still in the settings page as a fallback until a later release
@@@ -139,9 +140,10 @@ browser/
manifest.json MV3; Chromium (minimum_chrome_version 114) and Firefox (gecko id, strict_min_version 128)
panel.html / panel.js the Dock in the side panel: the Network's state, the same tabs as the Dock on a site
panel.css the Dock fills the panel (no floating card, no pill)
-- shared/ the released OpenVibe.Shared Dock 4.124.0, bundled: the CSP allows no remote script
-- (dock.js, dock-vibe.js, dock-agent.js, dock-timer.js, action-center.js,
-- notification-ui.js, notification-live.js, ov-mark.js, kit.css)
++ shared/ the released OpenVibe.Shared Dock 4.212.0, bundled: the CSP allows no remote script
++ (dock.js, dock-*.js, action-center.js, notification-*.js, agent-sheet.js,
++ agent-session.js, ov-window.js, ov-float.js, ov-composer.js, ov-glyphs.js,
++ brands.js, theme-loader.js, ov-mark.js, kit.css)
popup.html / popup.js DOM wiring only
options.html / .js connect (save a checked token), disconnect, grant the API host permission
lib/config.js API_ORIGIN (Coupons), NETWORK_ORIGIN and CHAT_ORIGIN (the Dock), CODES_ORIGIN (its agent view); HANDOFF_ORIGINS (Rent, Watch, Food, Reviews)
ddiff --git a/STATUS.json b/STATUS.json
iindex ba71fcf..2afbb34 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,11 +5,11 @@
"wave": "W18",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"adr": "OpenVibe.Contracts ADR-023 (first-party client surfaces)",
-- "version": "0.9.0",
++ "version": "0.9.2",
"surfaces": {
"browser": {
"name": "OpenVibe Companion",
-- "version": "0.9.0",
++ "version": "0.9.2",
"manifest": 3,
"targets": [
"chromium >= 114",
@@@ -31,7 +31,7 @@
],
"credential": "optional cpx_ install token (coupons.lookup, coupons.report); the Dock one-click OAuth public client (scopes dock, activity, chat.read; access in storage.session, rotating refresh in storage.local); the 0.7.0 pasted OpenVibe token is still accepted as a fallback until a later release",
"publishedToStores": false,
-- "loadedInChrome": "yes: headless Chrome 156 loads the 0.9.0 build via CDP Extensions.loadUnpacked; the side panel's Agents tab draws the Dock's agent view against a stubbed Network/Chat/Codes (the Live stream frame, the task line and meta, and Pause/Resume, Curtain now, Mute/Unmute and End) with its styles applied and no CSP refusal (scripts/check-panel.js); lib/autoapply.js passes three fixture checkout pages in headless Chrome; the one-click Dock sign-in is covered by test/auth.test.js",
++ "loadedInChrome": "yes: headless Chrome 156 loads the 0.9.2 build via CDP Extensions.loadUnpacked; the side panel's Agents tab draws the Dock's agent view against a stubbed Network/Chat/Codes (the Live stream frame, the task line and meta, and Pause/Resume, Curtain now, Mute/Unmute and End) with its styles applied and no CSP refusal (scripts/check-panel.js); lib/autoapply.js passes three fixture checkout pages in headless Chrome; the one-click Dock sign-in is covered by test/auth.test.js",
"loadedInFirefox": "not yet loaded in a browser; Mozilla's web-ext lint (8.10.0, what AMO runs on upload) passes with 0 errors, 0 warnings and 0 notices, and runs in CI with warnings as errors",
"handoffs": [
"https://openvibe.rent/board (keep a listing)",
@@@ -44,7 +44,7 @@
"storeListing": "store/ (listing, privacy and permission answers, screenshot plan); support URL https://openvibe.coupons/companion",
"features": [
"OpenVibe Dock one-click sign-in: public OAuth client companion (PKCE S256, no secret), read-only scopes dock/activity/chat.read, access token in storage.session, rotating refresh token in storage.local, Sign out revokes the family, the account page lists it under Signed-in apps",
-- "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock 4.124.0 bundled under browser/shared/ (dock.js, dock-vibe.js, dock-agent.js, dock-timer.js, action-center.js, notification-ui.js, notification-live.js, ov-mark.js, kit.css), reading and writing the same Network state as the Dock on a site, with the same tabs (Vibe, Agents, Chats, Activity), and re-reading it every 30 s so the page, the pop-out and the panel agree; the Agents tab draws the person's agent view with Pause/Resume, Curtain now, Mute/Unmute and End, and approvals/sends are hidden with the read-only scopes"
++ "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock 4.212.0 bundled under browser/shared/ (dock.js, dock-*.js, action-center.js, notification-*.js, agent-sheet.js, agent-session.js, ov-window.js, ov-float.js, ov-composer.js, ov-glyphs.js, brands.js, theme-loader.js, ov-mark.js, kit.css), reading and writing the same Network state as the Dock on a site, with the same tabs (Vibe, Agents, Chats, Activity), and re-reading it every 30 s so the page, the pop-out and the panel agree; the Agents tab draws the person's agent view with Pause/Resume, Curtain now, Mute/Unmute and End, and approvals/sends are hidden with the read-only scopes"
],
"pending": [
"owner: create the Chrome Web Store and AMO developer listings and upload the store zips with store/'s answers and screenshots; the Chrome id is fixed by browser/manifest.json key",
@@@ -67,7 +67,7 @@
"watches a page on OpenVibe.Watch, and compares delivery apps on OpenVibe.Food",
"saves a review site's stated rating to OpenVibe.Reviews (read only when pressed, only on those sites)",
"an optional cpx_ install token, never an OpenVibe session; and the Dock one-click OAuth sign-in (scopes dock, activity, chat.read; access in storage.session, rotating refresh in storage.local; the 0.7.0 pasted token is still a fallback until a later release)",
-- "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock 4.124.0 bundled under browser/shared/ (dock.js, dock-vibe.js, dock-agent.js, dock-timer.js, action-center.js, notification-ui.js, notification-live.js, ov-mark.js, kit.css), reading and writing the same Network state as the Dock on a site, with the same tabs (Vibe, Agents, Chats, Activity), and re-reading it every 30 s so the page, the pop-out and the panel agree; the Agents tab draws the person's agent view with Pause/Resume, Curtain now, Mute/Unmute and End, and approvals/sends are hidden with the read-only scopes",
++ "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock 4.212.0 bundled under browser/shared/ (dock.js, dock-*.js, action-center.js, notification-*.js, agent-sheet.js, agent-session.js, ov-window.js, ov-float.js, ov-composer.js, ov-glyphs.js, brands.js, theme-loader.js, ov-mark.js, kit.css), reading and writing the same Network state as the Dock on a site, with the same tabs (Vibe, Agents, Chats, Activity), and re-reading it every 30 s so the page, the pop-out and the panel agree; the Agents tab draws the person's agent view with Pause/Resume, Curtain now, Mute/Unmute and End, and approvals/sends are hidden with the read-only scopes",
"store-ready: reproducible Chrome and Firefox store zips (npm run pack:stores), listing and privacy answers in store/, Mozilla web-ext lint in CI",
"kiosk companion (Firefox new-tab page and history bridge for Live's /kiosk), moved from OpenVibe.Live",
"hardware/: the Raspberry Pi streamer and controller scripts, moved from OpenVibe.Live"
ddiff --git a/browser/manifest.json b/browser/manifest.json
iindex 3319ecb..00eded1 100644
---- a/browser/manifest.json
++++ b/browser/manifest.json
@@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "OpenVibe Companion",
"short_name": "OpenVibe",
-- "version": "0.9.1",
++ "version": "0.9.2",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqVJohdlaysL2HgSsj2Fsd8PxPvXJkZHjEvNyGSYNSEpZBoIeZd7Cm7xznGVikDBbe7WseaNLt+HheX4WhN94jU/FrS2016AqQG9NTHoymJR8XSrPPDyhc85EUpICOFwqfFsSOykzaRyHZVcrvoepYwntwFjWdt1ul4VozwkqKovZoKUgCn398aNvZ6AH/J0xb0xWMiOiL9CvUWN/jK4wv0ShnJevpmyBBHL273CkQvfMS3VUBvBjq3seMU8BAHkE5RI3c2y/MRdjwYGsCoMU6MPYwt46PQZ0S8SPnFFozJndkpoqc/ue9oXhd77b12GKlJ1BPhHgn8BSx4IAvjeL1wIDAQAB",
"description": "Coupon codes for the shop you are on, tried in its coupon box when you ask; the same restaurant on every delivery app; a review site's rating saved to OpenVibe.Reviews; a mask in the email box of a sign-up form; and one click to keep a rental listing on your OpenVibe.Rent board or watch a page. The OpenVibe Dock sits in the side panel, on every site. Reads the tab only when you open it.",
"homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions",
ddiff --git a/browser/shared/action-center.js b/browser/shared/action-center.js
iindex 3736b80..dfa5603 100644
---- a/browser/shared/action-center.js
++++ b/browser/shared/action-center.js
@@@ -158,7 +158,6 @@
.ovac-group { display: flex; align-items: center; gap: 8px; margin: 10px 10px 4px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #8b8b96); }
.ovac-group span { font-variant-numeric: tabular-nums; letter-spacing: 0; color: var(--text-secondary, #b6b6c0); }
.ovac-group[data-bucket="waiting"] { color: color-mix(in srgb, var(--warning, #f5a524) 70%, var(--text-primary, #ececf1)); }
-- @media (prefers-reduced-motion: reduce) { .ovac { animation: none; } .ovac-item[data-bucket="running"] .ovac-state::before { animation: none; } }
`;
document.head.appendChild(st);
}
ddiff --git a/browser/shared/agent-session.css b/browser/shared/agent-session.css
nnew file mode 100644
iindex 0000000..5b48497
---- /dev/null
++++ b/browser/shared/agent-session.css
@@@ -0,0 +1,283 @@
++/* openvibe-shared/agent-session.css: the look of agent-session.js (4.32.0; it was a string inside the script before).
++ Theme tokens only, light or dark; OpenVibe.Design can restyle it. agent-session.js links it from beside itself when a
++ page has not; a page may link it in its <head> to paint styled from the first line. */
++.ovas { --ovas-b: var(--border, rgba(255,255,255,.1)); --ovas-s: var(--bg-secondary, #121826); --ovas-t: var(--bg-tertiary, rgba(255,255,255,.05)); --ovas-mut: var(--text-muted, #8b98ad); --ovas-sec: var(--text-secondary, #a8b3c4); --ovas-ok: color-mix(in srgb, #22c55e 70%, var(--text-primary, #e6edf7)); --ovas-bad: var(--live-red, #f87171); --ovas-add: color-mix(in srgb, #22c55e 18%, transparent); --ovas-del: color-mix(in srgb, #ef4444 18%, transparent); --ovas-code: color-mix(in srgb, var(--bg-primary, #0b1020) 72%, var(--ovas-s));
++ display: grid; gap: 12px; color: var(--text-primary, #e6edf7); font-family: inherit; min-width: 0; }
++.ovas-ic { width: 16px; height: 16px; flex: none; }
++.ovas-head { position: sticky; top: var(--ovnav-real-h, 0px); z-index: 2; display: grid; gap: 8px; padding: 12px 14px; border-radius: 16px; border: 1px solid var(--ovas-b); background: color-mix(in srgb, var(--ovas-s) 92%, transparent); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); }
++.ovas-top { display: flex; align-items: center; gap: 10px; min-width: 0; }
++.ovas-pill { display: inline-flex; align-items: center; gap: 6px; flex: none; height: 26px; padding: 0 10px; border-radius: 999px; font-size: 12.5px; font-weight: 700; background: var(--ovas-t); color: var(--ovas-sec); }
++.ovas-pill[data-s="running"], .ovas-pill[data-s="placed"], .ovas-pill[data-s="verifying"] { color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
++.ovas-pill[data-s="succeeded"] { color: var(--ovas-ok); background: color-mix(in srgb, #22c55e 15%, transparent); }
++.ovas-pill[data-s="failed"], .ovas-pill[data-s="expired"] { color: var(--ovas-bad); background: color-mix(in srgb, #ef4444 15%, transparent); }
++.ovas-title { flex: 1; min-width: 0; font-weight: 700; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovas-meta { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12.5px; color: var(--ovas-mut); }
++.ovas-meta b { color: var(--ovas-sec); font-weight: 650; font-variant-numeric: tabular-nums; }
++.ovas-agents { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
++.ovas-agent { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; font-size: 12px; font-weight: 650; border: 1px solid var(--ovas-b); color: var(--ovas-sec); }
++.ovas-agent.is-now { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); color: var(--text-primary, #e6edf7); }
++.ovas-plan { display: grid; gap: 6px; padding-top: 2px; }
++.ovas-plan > summary { display: flex; align-items: center; gap: 8px; cursor: pointer; list-style: none; font-size: 13px; font-weight: 600; }
++.ovas-plan > summary::-webkit-details-marker { display: none; }
++.ovas-plan-now { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovas-plan-now::before { content: ''; display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: var(--accent, #3b82f6); vertical-align: 1px; }
++.ovas-plan-n { flex: none; font-size: 12px; color: var(--ovas-mut); font-variant-numeric: tabular-nums; }
++.ovas-plan:not([open]) > ol { display: none; }
++.ovas-plan-bar { height: 4px; border-radius: 4px; background: var(--ovas-t); overflow: hidden; }
++.ovas-plan-bar i { display: block; height: 100%; background: var(--accent, #3b82f6); border-radius: inherit; transition: width .3s; }
++.ovas-plan ol { margin: 0; padding: 0; list-style: none; display: grid; gap: 3px; font-size: 13px; }
++.ovas-plan li { display: flex; gap: 8px; align-items: baseline; color: var(--ovas-sec); }
++.ovas-plan li::before { content: ''; flex: none; width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--ovas-mut); transform: translateY(1px); }
++.ovas-plan li[data-s="in_progress"] { color: var(--text-primary, #e6edf7); font-weight: 600; }
++.ovas-plan li[data-s="in_progress"]::before { border-color: var(--accent, #3b82f6); background: var(--accent, #3b82f6); animation: ovas-beat 1.2s ease-in-out infinite; }
++.ovas-plan li[data-s="completed"] { color: var(--ovas-mut); text-decoration: line-through; }
++.ovas-plan li[data-s="completed"]::before { background: var(--ovas-ok); border-color: var(--ovas-ok); }
++.ovas-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; min-width: 0; }
++.ovas-chip { height: 30px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--ovas-b); background: transparent; color: var(--ovas-sec); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; }
++.ovas-chip[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, #fff); }
++.ovas-chip.ovas-follow { margin-left: auto; }
++.ovas-feed { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; min-width: 0; }
++.ovas-feed > li { min-width: 0; }
++.ovas-msg { padding: 2px 4px; font-size: 15px; line-height: 1.55; overflow-wrap: anywhere; }
++.ovas-msg p { margin: 0 0 8px; } .ovas-msg p:last-child { margin-bottom: 0; }
++.ovas-msg ul, .ovas-msg ol { margin: 4px 0 8px; padding-left: 22px; } .ovas-msg li { margin: 2px 0; }
++.ovas-msg code, .ovas-detail code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .88em; padding: 1px 5px; border-radius: 6px; background: var(--ovas-t); }
++.ovas-msg a { color: var(--accent-light, var(--accent, #60a5fa)); }
++.ovas-code, .ovas-out, .ovas-diff { margin: 0; padding: 10px 12px; border-radius: 12px; background: var(--ovas-code); border: 1px solid var(--ovas-b); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; line-height: 1.5; overflow: auto; max-height: 50vh; white-space: pre; }
++.ovas-code code { padding: 0; background: none; }
++.ovas-group, .ovas-step { border: 1px solid var(--ovas-b); border-radius: 14px; background: var(--ovas-s); overflow: hidden; }
++.ovas-group > summary, .ovas-step > summary { display: flex; align-items: center; gap: 10px; min-height: 46px; padding: 6px 12px; cursor: pointer; list-style: none; }
++.ovas-group > summary::-webkit-details-marker, .ovas-step > summary::-webkit-details-marker { display: none; }
++.ovas-chev { width: 14px; height: 14px; flex: none; color: var(--ovas-mut); transition: transform .15s; }
++details[open] > summary .ovas-chev { transform: rotate(90deg); }
++.ovas-icons { display: inline-flex; flex: none; }
++.ovas-kind { display: grid; place-items: center; width: 28px; height: 28px; flex: none; border-radius: 9px; background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
++.ovas-icons .ovas-kind { width: 24px; height: 24px; border: 2px solid var(--ovas-s); border-radius: 8px; }
++.ovas-icons .ovas-kind + .ovas-kind { margin-left: -7px; }
++.ovas-kind .ovas-ic { width: 14px; height: 14px; }
++.ovas-kind[data-k="edit"], .ovas-kind[data-k="write"] { color: #4ade80; background: color-mix(in srgb, #22c55e 14%, transparent); }
++.ovas-kind[data-k="web"] { color: #38bdf8; background: color-mix(in srgb, #0ea5e9 14%, transparent); }
++.ovas-kind[data-k="agent"] { color: #c084fc; background: color-mix(in srgb, #a855f7 16%, transparent); }
++.ovas-sum { flex: 1; min-width: 0; display: grid; gap: 1px; }
++.ovas-sum b { font-size: 14px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovas-sum small { font-size: 12px; color: var(--ovas-mut); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
++.ovas-delta { flex: none; font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums; }
++.ovas-delta .a { color: var(--ovas-ok); } .ovas-delta .r { color: var(--ovas-bad); margin-left: 4px; }
++.ovas-state { flex: none; display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--ovas-mut); font-variant-numeric: tabular-nums; }
++.ovas-state .ovas-ic { width: 15px; height: 15px; }
++.ovas-state[data-s="ok"] .ovas-ic { color: var(--ovas-ok); } .ovas-state[data-s="fail"] { color: var(--ovas-bad); }
++.ovas-spin { position: relative; display: inline-block; width: 4px; height: 4px; margin: 0 6px; border: 0; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovas-hop 1s ease-in-out .15s infinite; }
++.ovas-spin::before, .ovas-spin::after { content: ''; position: absolute; top: 0; width: 4px; height: 4px; border-radius: 50%; background: inherit; animation: ovas-hop 1s ease-in-out infinite; }
++.ovas-spin::before { left: -6px; } .ovas-spin::after { left: 6px; animation-delay: .3s; }
++@keyframes ovas-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-3px); opacity: 1; } }
++.ovas-group > ol { list-style: none; margin: 0; padding: 0 8px 8px; display: grid; gap: 6px; }
++.ovas-group.is-one { border: 0; background: none; }
++.ovas-group.is-one > summary { display: none; }
++.ovas-group.is-one > ol { padding: 0; }
++.ovas-group.is-one .ovas-step { background: var(--ovas-s); border-color: var(--ovas-b); }
++.ovas-group .ovas-step { background: var(--ovas-t); border-color: transparent; }
++.ovas-detail { display: grid; gap: 10px; padding: 0 12px 12px; }
++.ovas-label { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 11px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--ovas-mut); margin-bottom: -4px; }
++.ovas-copy { display: inline-flex; align-items: center; gap: 4px; border: 0; background: none; color: var(--ovas-mut); font: inherit; font-size: 11.5px; font-weight: 650; letter-spacing: 0; text-transform: none; cursor: pointer; padding: 2px 4px; border-radius: 6px; }
++.ovas-copy:hover { color: var(--text-primary, #e6edf7); background: var(--ovas-t); }
++.ovas-copy .ovas-ic { width: 13px; height: 13px; }
++.ovas-out.is-fail { box-shadow: inset 3px 0 0 var(--ovas-bad); }
++.ovas-wait { font-size: 13px; color: var(--ovas-mut); display: flex; align-items: center; gap: 8px; }
++.ovas-diff span { display: block; padding: 0 6px; margin: 0 -6px; }
++.ovas-diff .a { background: var(--ovas-add); } .ovas-diff .r { background: var(--ovas-del); } .ovas-diff .h { color: var(--ovas-mut); }
++.ovas-sub { margin: 0 8px 8px; padding: 8px 0 0 12px; border-left: 2px solid color-mix(in srgb, #a855f7 45%, transparent); display: grid; gap: 8px; }
++.ovas-handoff { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--ovas-sec); }
++.ovas-handoff::before, .ovas-handoff::after { content: ''; flex: 1; height: 1px; background: var(--ovas-b); }
++.ovas-hand { display: grid; justify-items: center; gap: 4px; max-width: 82%; text-align: center; }
++.ovas-hand-row { display: inline-flex; align-items: center; gap: 6px; }
++.ovas-hand-row .ovas-ic { color: var(--ovas-mut); }
++.ovas-hand small { font-size: 12.5px; color: var(--ovas-mut); line-height: 1.4; }
++.ovas-note { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; padding: 9px 12px; border-radius: 12px; font-size: 13.5px; background: color-mix(in srgb, #f59e0b 12%, transparent); color: var(--text-primary, #e6edf7); }
++.ovas-note[data-level="error"] { background: color-mix(in srgb, #ef4444 13%, transparent); }
++.ovas-note[data-level="info"] { background: color-mix(in srgb, #22c55e 10%, transparent); }
++.ovas-note[data-level="info"] .ovas-ic { color: var(--ovas-ok); }
++.ovas-note .ovas-ic { color: #f59e0b; margin-top: 2px; } .ovas-note[data-level="error"] .ovas-ic { color: var(--ovas-bad); }
++.ovas-done { display: grid; gap: 6px; padding: 12px 14px; border-radius: 14px; border: 1px solid color-mix(in srgb, #22c55e 40%, transparent); background: color-mix(in srgb, #22c55e 8%, transparent); }
++.ovas-done[data-ok="0"] { border-color: color-mix(in srgb, #ef4444 40%, transparent); background: color-mix(in srgb, #ef4444 8%, transparent); }
++.ovas-done > b { display: flex; align-items: center; gap: 6px; font-size: 13px; }
++.ovas-line { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 4px; font-size: 12.5px; color: var(--ovas-mut); }
++.ovas-line::before { content: ''; flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--ovas-mut); opacity: .7; }
++.ovas-extra { display: grid; gap: 8px; padding: 8px 12px; border-radius: 12px; border: 1px solid var(--ovas-b); background: var(--ovas-s); font-size: 13.5px; color: var(--ovas-sec); }
++.ovas-empty { padding: 18px; text-align: center; color: var(--ovas-mut); font-size: 13.5px; border: 1px dashed var(--ovas-b); border-radius: 14px; }
++.ovas [hidden] { display: none !important; }
++@keyframes ovas-beat { 0%, 100% { transform: translateY(1px) scale(.8); opacity: .6; } 50% { transform: translateY(1px) scale(1); opacity: 1; } }
++@media (hover: hover) { .ovas-group > summary:hover, .ovas-step > summary:hover { background: var(--bg-hover, rgba(255,255,255,.04)); } }
++@media (max-width: 640px) {
++ .ovas-head { padding: 10px 12px; border-radius: 14px; } .ovas-msg { font-size: 14.5px; }
++ .ovas-code, .ovas-out, .ovas-diff { font-size: 12px; max-height: 45vh; }
++ .ovas-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding: 0 2px; } .ovas-bar::-webkit-scrollbar { display: none; }
++ .ovas-chip { flex: none; } .ovas-icons .ovas-kind + .ovas-kind { display: none; }
++}
++
++/* Files changed (4.32.0): every file the agents edited or wrote, with its +/−; a tap opens its step. */
++.ovas-files { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
++.ovas-file { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 46px; padding: 6px 12px; border-radius: 14px; border: 1px solid var(--ovas-b); background: var(--ovas-s); color: inherit; font: inherit; text-align: left; cursor: pointer; }
++.ovas-file:hover { background: var(--bg-hover, rgba(255,255,255,.04)); }
++.ovas-file:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.ovas-file-n { flex: none; font-size: 12px; color: var(--ovas-mut); }
++/* A step's raw input, folded. */
++.ovas-raw > summary { display: flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; font-size: 11px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--ovas-mut); }
++.ovas-raw > summary::-webkit-details-marker { display: none; }
++.ovas-raw > summary .ovas-chev { width: 12px; height: 12px; }
++.ovas-raw[open] > summary .ovas-chev { transform: rotate(90deg); }
++.ovas-raw .ovas-label { margin: 8px 0 0; }
++.ovas-raw pre { margin-top: 8px; }
++/* Cost: per agent in the head, per step on its row when the agent reports it. */
++.ovas-agent-cost { margin-left: 6px; font-weight: 600; color: var(--ovas-mut); font-variant-numeric: tabular-nums; }
++.ovas-cost { flex: none; font-size: 12px; font-weight: 650; color: var(--ovas-mut); font-variant-numeric: tabular-nums; }
++/* The action bar (Approve / Continue / Stop), sticky at the bottom while it is needed. */
++.ovas-actions { position: sticky; bottom: max(8px, env(safe-area-inset-bottom, 0px)); z-index: 2; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; border-radius: 16px; border: 1px solid var(--ovas-b); background: color-mix(in srgb, var(--ovas-s) 94%, transparent); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); box-shadow: 0 10px 30px rgba(0,0,0,.25); }
++.ovas-actions p { flex: 1 1 260px; min-width: 0; margin: 0; font-size: 13px; line-height: 1.4; color: var(--ovas-sec); }
++.ovas-actions { justify-content: flex-end; }
++.ovas-act { min-height: 38px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--ovas-b); background: transparent; color: var(--text-primary, #e6edf7); font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; }
++.ovas-act.is-primary { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, var(--on-accent, #fff)); }
++.ovas-act.is-danger { color: var(--ovas-bad); border-color: color-mix(in srgb, var(--ovas-bad) 45%, var(--ovas-b)); }
++.ovas-act:disabled { opacity: .55; cursor: default; }
++.ovas-act:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++
++/* Attachments (4.50.0): the images and files a step read, wrote or produced. A group's header shows them while the
++ group is folded; a message's sit under its text. The URLs come from the product; only the page's own origin and
++ Media's load. */
++.ovas-group > summary, .ovas-step > summary { flex-wrap: wrap; row-gap: 8px; }
++.ovas-att-slot { flex-basis: 100%; order: 9; display: block; min-width: 0; }
++.ovas-att-slot:empty { display: none; }
++.ovas-att { display: grid; gap: 8px; width: 100%; min-width: 0; max-width: 100%; }
++.ovas-att-row { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px; min-width: 0; }
++.ovas-att-img { box-sizing: border-box; display: block; width: 136px; flex: none; padding: 0; border: 1px solid var(--ovas-b); border-radius: 12px; overflow: hidden; background: var(--ovas-t); color: inherit; font: inherit; text-align: left; cursor: pointer; }
++.ovas-att-img img { display: block; width: 136px; height: 92px; object-fit: cover; background: var(--ovas-t); }
++.ovas-att-chip { box-sizing: border-box; display: inline-flex; align-items: center; gap: 7px; flex: none; max-width: 220px; min-height: 36px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--ovas-b); background: var(--ovas-t); color: var(--ovas-sec); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; }
++.ovas-att-chip .ovas-ic { width: 15px; height: 15px; flex: none; color: var(--accent-light, var(--accent, #60a5fa)); }
++.ovas-att-chip[data-role="read"] .ovas-ic { color: #38bdf8; }
++.ovas-att-chip[data-role="wrote"] .ovas-ic { color: #4ade80; }
++.ovas-att-cap { display: block; min-width: 0; font-size: 11.5px; line-height: 1.3; color: var(--ovas-mut); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.ovas-att-img .ovas-att-cap { padding: 5px 7px; }
++.ovas-att-chip .ovas-att-cap { padding: 0; }
++.ovas-att:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.ovas-att-img:hover, .ovas-att-chip:hover { background: var(--bg-hover, rgba(255,255,255,.06)); }
++/* The preview sheet: one attachment, opened by a tap. */
++.ovas-sheet { box-sizing: border-box; position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 16px; background: color-mix(in srgb, var(--bg-primary, #0b1020) 80%, transparent); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
++.ovas-sheet[hidden] { display: none; }
++.ovas-sheet-panel { box-sizing: border-box; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; gap: 10px; width: min(960px, 92vw); max-height: min(92vh, 920px); padding: 14px; border-radius: 18px; border: 1px solid var(--ovas-b); background: var(--ovas-s); color: var(--text-primary, #e6edf7); box-shadow: 0 24px 70px rgba(0,0,0,.45); }
++.ovas-sheet-bar { display: flex; align-items: center; gap: 10px; min-width: 0; }
++.ovas-sheet-bar b { flex: 1; min-width: 0; font-size: 14px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovas-sheet-x { width: 40px; height: 40px; flex: none; border-radius: 999px; border: 1px solid var(--ovas-b); background: transparent; color: inherit; font: inherit; font-size: 15px; line-height: 1; cursor: pointer; }
++.ovas-sheet-x:focus-visible, .ovas-sheet-foot a:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.ovas-sheet-body { min-width: 0; min-height: 0; overflow: auto; display: grid; }
++.ovas-prev { min-width: 0; display: grid; }
++.ovas-prev-img { display: block; width: 100%; height: auto; max-height: 74vh; object-fit: contain; border-radius: 12px; background: var(--ovas-t); touch-action: none; }
++.ovas-prev-media { display: block; width: 100%; max-height: 74vh; border-radius: 12px; background: #000; }
++.ovas-prev-audio { width: 100%; }
++.ovas-prev-pdf { width: 100%; height: 72vh; border: 0; border-radius: 12px; background: #fff; }
++.ovas-prev-code { margin: 0; padding: 12px; border-radius: 12px; border: 1px solid var(--ovas-b); background: var(--ovas-code); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; line-height: 1.5; overflow: auto; max-height: 70vh; white-space: pre; }
++.ovas-prev-text { min-width: 0; }
++.ovas-prev-text .ovas-msg { padding: 0; }
++.ovas-prev-note { margin: 0 0 10px; font-size: 13px; color: var(--ovas-mut); }
++.ovas-sheet-foot { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
++.ovas-sheet-foot a { display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--ovas-b); background: transparent; color: inherit; text-decoration: none; font-size: 13px; font-weight: 700; }
++.ovas-sheet-foot .ovas-sheet-open { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, #fff); }
++.ovas-csv { width: 100%; border-collapse: collapse; font-size: 12.5px; }
++.ovas-csv th, .ovas-csv td { border: 1px solid var(--ovas-b); padding: 5px 8px; text-align: left; white-space: nowrap; }
++.ovas-csv th { background: var(--ovas-t); font-weight: 700; }
++.ovas-tok-key { color: var(--accent-light, var(--accent, #60a5fa)); }
++.ovas-tok-str { color: var(--ovas-ok); }
++.ovas-tok-num { color: #fbbf24; }
++.ovas-tok-kw { color: #c084fc; }
++.ovas-tok-com { color: var(--ovas-mut); font-style: italic; }
++
++@media (max-width: 760px) {
++ /* A phone: the head does not stick, so it never floats over the steps it sits above. */
++ .ovas-head { position: static; }
++}
++@media (max-width: 640px) {
++ /* Phones: bigger tap targets, and the preview sheet takes the whole screen. */
++ .ovas-att-chip { min-height: 44px; padding: 0 14px; }
++ .ovas-att-chip .ovas-att-cap { font-size: 12px; }
++ .ovas-sheet { padding: 0; align-items: stretch; }
++ .ovas-sheet-panel { width: 100%; max-height: 100vh; height: 100vh; border-radius: 0; border: 0; padding: 12px; }
++ .ovas-sheet-x, .ovas-sheet-foot a { min-height: 44px; }
++ .ovas-prev-img { max-height: 68vh; }
++}
++
++/* ── Protocol 1.2 (4.205.0, ask 354): phases, thinking, sources, card mode ───────────────────────────────────── */
++.ovas-phase { display: flex; align-items: center; gap: 9px; margin: 6px 2px 0; font-size: 12.5px; letter-spacing: .02em; color: var(--ovas-mut, var(--text-secondary, #9fb0cf)); }
++.ovas-phase b { font-weight: 750; color: var(--text-primary, #e6edf7); }
++.ovas-phase-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; background: var(--accent, #3b82f6); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }
++.ovas-phase[data-s="running"] .ovas-phase-dot { animation: ovasBreathe 1.6s ease-in-out infinite; }
++.ovas-phase[data-s="done"] .ovas-phase-dot { background: var(--ovas-ok, #22c55e); box-shadow: none; }
++.ovas-phase::after { content: ""; flex: 1; height: 1px; background: var(--ovas-b, rgba(255,255,255,.1)); }
++@keyframes ovasBreathe { 50% { transform: scale(calc(1 - .25 * var(--ov-move, 1))); opacity: calc(1 - .5 * var(--ov-move, 1)); } }
++.ovas-think details { border-radius: 12px; }
++.ovas-think summary { display: flex; align-items: center; gap: 8px; cursor: pointer; list-style: none; font-size: 13px; color: var(--ovas-mut, var(--text-secondary, #9fb0cf)); }
++.ovas-think summary::-webkit-details-marker { display: none; }
++.ovas-think-dots { width: 18px; height: 6px; flex: none; background: radial-gradient(circle, currentColor 2px, transparent 2.5px) 0 50% / 6px 6px repeat-x; animation: ovasDots 1.1s linear infinite; }
++.ovas-think.is-done .ovas-think-dots { animation: none; opacity: .5; }
++@keyframes ovasDots { 50% { opacity: .35; } }
++.ovas-think-body { margin: 6px 0 0 26px; padding: 8px 12px; border-left: 2px solid var(--ovas-b, rgba(255,255,255,.1)); font-size: 13px; line-height: 1.5; white-space: pre-wrap; color: var(--ovas-mut, var(--text-secondary, #9fb0cf)); max-height: 320px; overflow: auto; }
++.ovas-sources { border: 1px solid var(--ovas-b, rgba(255,255,255,.1)); border-radius: 14px; padding: 8px 12px; background: var(--ovas-t, rgba(255,255,255,.04)); }
++.ovas-sources > summary { display: flex; align-items: center; gap: 10px; cursor: pointer; list-style: none; font-size: 13px; font-weight: 650; }
++.ovas-sources > summary::-webkit-details-marker { display: none; }
++.ovas-src-tiles { display: inline-flex; }
++.ovas-src-tile { display: inline-grid; place-items: center; width: 22px; height: 22px; margin-left: -6px; border-radius: 7px; font-size: 11px; font-weight: 800; color: #fff;
++ background: hsl(var(--h, 210) 55% 42%); box-shadow: 0 0 0 2px var(--ovas-s, #121826); }
++.ovas-src-tile:first-child { margin-left: 0; }
++.ovas-sources ol { display: grid; gap: 6px; margin: 10px 0 2px; padding: 0; list-style: none; max-height: 340px; overflow: auto; }
++.ovas-sources li { display: grid; gap: 1px; min-width: 0; }
++.ovas-sources li a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; font-size: 13.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovas-sources li a:hover { text-decoration: underline; }
++.ovas-sources li small { color: var(--ovas-mut, var(--text-secondary, #9fb0cf)); font-size: 11.5px; }
++.ovas--card { gap: 8px; }
++.ovas--card .ovas-head { position: static; padding: 10px 12px; }
++.ovas--card :is(.ovas-bar, .ovas-files) { display: none !important; }
++.ovas--card .ovas-feed > li:not(:nth-last-child(-n+3)) { display: none; }
++.ovas--card .ovas-feed > li .ovas-out { max-height: 120px; }
++.ovas-open { justify-self: start; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px; border-radius: 10px; font-size: 13px; font-weight: 700; text-decoration: none;
++ color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }
++html[data-ov-motion="off"] :is(.ovas-phase-dot, .ovas-think-dots) { animation: none !important; }
++/* 3. A card keeps its groups folded: the head says what they did. */
++.ovas--card .ovas-group:not(.is-one) > :not(summary) { display: none; }
++.ovas--card .ovas-group:not(.is-one) > summary .ovas-chev { display: none; }
++
++/* ── Compact runs (2026-11-11, the owner with the portal's run view open: "consider stuff like this … parse and expandable
++ and compact mode … smart"): a group is one line, its state first, its words, the step it is on in mono, the chevron
++ last; the steps inside are light rows; the head says what it is doing now; three dots while it works; a box to talk
++ to it. Every motion follows --ov-move. */
++.ovas-feed { gap: 6px; }
++.ovas-group > summary { min-height: 42px; padding: 4px 10px; gap: 9px; }
++.ovas-group:not(.is-one) > summary .ovas-state { order: -1; }
++.ovas-group:not(.is-one) > summary .ovas-icons { display: none; }
++.ovas-group:not(.is-one) > summary .ovas-chev { order: 99; transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease); }
++.ovas-group[open]:not(.is-one) > summary .ovas-chev { transform: rotate(90deg); }
++.ovas-group:not(.is-one) > summary .ovas-sum { display: flex; align-items: baseline; gap: 9px; }
++.ovas-group:not(.is-one) > summary .ovas-sum b { flex: none; max-width: 60%; }
++.ovas-group:not(.is-one) > summary .ovas-sum small { flex: 1 1 auto; min-width: 0; }
++.ovas-group > ol .ovas-step { border: 0; background: transparent; border-radius: 10px; }
++.ovas-group > ol .ovas-step > summary { min-height: 38px; padding: 3px 8px; }
++.ovas-group > ol .ovas-step > summary:hover { background: color-mix(in srgb, var(--text-primary, #fff) 5%, transparent); }
++.ovas-group > ol .ovas-kind { width: 24px; height: 24px; border-radius: 7px; }
++.ovas-meta [data-m="now"] { flex: 1 1 100%; min-width: 0; order: -1; }
++.ovas-now { display: flex; align-items: center; gap: 7px; min-width: 0; font: 12.5px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text-primary, #e6edf7); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
++.ovas-now::before { content: ""; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovasBreathe 1.6s ease-in-out infinite; }
++.ovas-working { display: flex; gap: 5px; padding: 4px 6px; }
++.ovas-working[hidden] { display: none; }
++.ovas-working i { width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); opacity: .35; animation: ovasWork 1.2s ease-in-out infinite; }
++.ovas-working i:nth-child(2) { animation-delay: .16s; } .ovas-working i:nth-child(3) { animation-delay: .32s; }
++@keyframes ovasWork { 40% { opacity: 1; transform: translateY(calc(-3px * var(--ov-move, 1))); } }
++.ovas-you { justify-self: end; max-width: 85%; padding: 8px 12px; border-radius: 14px 14px 4px 14px; background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); font-size: 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
++.ovas-you:not(.is-sent):not(.is-failed) { opacity: .6; }
++.ovas-you.is-failed { outline: 1px solid var(--ovas-bad, #ef4444); }
++.ovas-say { position: sticky; bottom: 0; display: grid; gap: 4px; padding: 8px; border-radius: 16px; border: 1px solid var(--ovas-b, rgba(255,255,255,.1)); background: var(--ovas-s, #121826); }
++.ovas-say textarea { flex: 1; min-height: 40px; max-height: 160px; resize: none; padding: 9px 12px; border: 0; border-radius: 12px; background: var(--ovas-t, rgba(255,255,255,.05)); color: inherit; font: inherit; font-size: 15px; }
++.ovas-say textarea:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 0; }
++.ovas-say-send { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 12px; background: var(--accent-strong, var(--accent, #2563eb)); color: #fff; cursor: pointer; }
++.ovas-say.is-busy .ovas-say-send { opacity: .5; }
++html[data-ov-motion="off"] :is(.ovas-now, .ovas-working i)::before, html[data-ov-motion="off"] .ovas-working i { animation: none !important; }
++.ovas-you-files { display: block; margin-top: 4px; font-size: 12px; opacity: .8; }
++.ovas-say .ovt-c-row { display: flex; align-items: center; gap: 6px; }
++.ovas-say .ovt-c-tools { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
++.ovas-say .ovt-c-note { margin: 0; font-size: 12px; color: var(--ovas-mut, var(--text-secondary, #9fb0cf)); }
++.ovas-say .ovt-c-note:empty { display: none; }
ddiff --git a/browser/shared/agent-session.js b/browser/shared/agent-session.js
nnew file mode 100644
iindex 0000000..3f5ff4c
---- /dev/null
++++ b/browser/shared/agent-session.js
@@@ -0,0 +1,1056 @@
++/**
++ * openvibe-shared/agent-session.js — a coding agent's session as people read it: what it said, the steps it took
++ * (commands, files read and changed, searches, sub-agents) grouped and folded, each step's command, diff and OUTPUT
++ * (live while it runs), hand-offs between agents, the plan it keeps, and a summary that stays at the top (4.24.0).
++ *
++ * The owner, 2026-10-28, showing the Claude app's session view ("Ran 3 commands", a Bash sheet saying "Output
++ * unavailable"): "OpenVibe.Codes should… actually show the output from the agent… more user friendly… handle that
++ * between all agents… in a smart way… way better than this idea."
++ *
++ * const view = OpenVibeAgentSession.mount(el, { title, status: 'running', startedAt, link })
++ * link(url) → boolean: which https addresses in the agents' text become links (default:
++ * all; a site whose agents read untrusted pages links only its own, e.g. a person's desk)
++ * view.push(event) one event: a Codes job line ({ t: 'tool', … }), any Claude Code stream-json event
++ * ({ type: 'assistant', … }) or an OpenVibe.Actor task event ({ kind: 'output', step, … },
++ * actor.task-event@1); every OpenVibe.Codes harness adapter (Claude Code, Codex, OpenCode,
++ * Command Code, Aider, DeepSeek, OpenVibe.AI, your own model) and every Actor agent speaks one
++ * view.pushAll(events)
++ * view.status('succeeded' | 'failed' | 'cancelled' | 'running' | 'queued' | …, label?)
++ * view.actions([{ id, label, kind: 'primary' | 'danger' | undefined }], note?)
++ * the sticky bar at the bottom (Approve, Continue, Stop…); opts.onAction(id) runs when one is
++ * pressed (a returned promise keeps the buttons disabled until it settles); [] hides it
++ * view.summary() → { agents, steps, commands, files, added, removed, problems, costUsd, durationMs }
++ * view.destroy()
++ * OpenVibeAgentSession.normalize(event) → [line] the stream-json → line mapping, for servers and other readers
++ *
++ * The lines (Codes' job protocol 1.1, additive to 1.0):
++ * { t: 'agent', agent, model } an agent started (a hand-off starts the next one)
++ * { t: 'text', text, parent? } what it said (light Markdown: paragraphs, lists, code, links)
++ * { t: 'tool', id, name, what, input?, parent? } a step began: Bash, Read, Edit, Write, Grep, Glob, LS,
++ * WebFetch, WebSearch, Task (a sub-agent), TodoWrite (the plan)
++ * { t: 'tool_progress', id, chunk } output while it runs (agents whose steps the harness runs)
++ * { t: 'tool_result', id, ok, output, cost_usd?, duration_ms? } how it ended, with its output (and its cost,
++ * when the agent reports one: shown on the step's row)
++ * { t: 'handoff', from, to?, reason } one agent stopped and the next took over
++ * { t: 'note', text, level? } a warning or an error along the way
++ * { t: 'step', text } the job's own progress (cloning, the branch, files changed, pushing)
++ * { t: 'usage', input_tokens, output_tokens }
++ * { t: 'done', ok, text, duration_ms, cost_usd, turns }
++ * A step or a message may carry the images and files the agent read, wrote or produced:
++ * attachments: [{ url, name, mime, size?, kind: 'image'|'video'|'audio'|'pdf'|'text'|'file', role: 'read'|'wrote'|'output' }]
++ * The product supplies the URLs; the component never builds one from a local path. Any line may carry attachments,
++ * and the component shows them for every line it draws. A line of a type it does not know (Codes' proof lines) is
++ * drawn as a minimal row: its label or summary, then its attachments. A step's attachments show in its
++ * group's header, visible while the group is folded; a message's show under its text. A tap opens a preview sheet: the
++ * image at full width (pinch to zoom), video and audio in their players, a PDF in a frame, text as code, Markdown, a CSV
++ * table or pretty JSON, plus Open and Download. An attachment loads only from the product's own origin or Media's,
++ * never a third-party address the model printed; SVG is only ever an <img>, and text is set as text, never as markup.
++ * A line from a sub-agent carries `parent`: the id of the Task step that started it, so it nests under that step.
++ * Any line may carry `agent` (the head then follows who is working); a step without an id gets one, and the next result
++ * of the same tool without an id is its result. { t: 'state', state } moves the head (Actor's task states).
++ *
++ * Everything is text: no line's content ever becomes markup (links are https only). The look is agent-session.css
++ * (4.32.0), linked from beside this script unless the page links it; it follows the page's theme tokens, light or dark.
++ * The Files tab lists every file edited or written with its +/−; each step keeps its raw input (folded); the head shows
++ * each agent's cost from its done line.
++ */
++(function (root) {
++ 'use strict';
++
++ // ── What each step is ─────────────────────────────────────
++ const KIND = {
++ Bash: 'command', bash: 'command', run_command: 'command', shell: 'command', exec: 'command',
++ Read: 'read', read_file: 'read', Grep: 'search', grep: 'search', Glob: 'search', LS: 'search', list_files: 'search', search: 'search',
++ Edit: 'edit', MultiEdit: 'edit', edit_file: 'edit', NotebookEdit: 'edit', Write: 'write', write_file: 'write', apply_patch: 'edit',
++ WebFetch: 'web', WebSearch: 'web', Task: 'agent', Agent: 'agent', TodoWrite: 'plan',
++ };
++ // A tool no table knows is judged by the words of its name (run_shell, readFile, web-fetch), never by letters inside
++ // a word ("Frobnicate" is not a cat).
++ function kindOf(name) {
++ if (KIND[name]) return KIND[name];
++ const w = new Set(String(name || '').replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z]+/).filter(Boolean));
++ const any = (...xs) => xs.some((x) => w.has(x));
++ // The web first: web_search and web_fetch are looking things up, not searching files.
++ return any('web', 'fetch', 'http', 'browse', 'url', 'internet') ? 'web' : any('bash', 'shell', 'command', 'exec', 'run', 'terminal') ? 'command' : any('edit', 'patch', 'replace') ? 'edit'
++ : any('write', 'create') ? 'write' : any('read', 'cat', 'view', 'open') ? 'read' : any('grep', 'glob', 'find', 'search', 'list', 'ls') ? 'search' : 'tool';
++ }
++ const NOUN = { command: ['command', 'commands'], read: ['read', 'reads'], search: ['search', 'searches'], edit: ['edit', 'edits'], write: ['new file', 'new files'], web: ['lookup', 'lookups'], agent: ['helper', 'helpers'], plan: ['plan', 'plan'], tool: ['tool', 'tools'] };
++ const VERB = {
++ command: ['Ran a command', 'Ran %d commands'], read: ['Read a file', 'Read %d files'], search: ['Searched once', 'Searched %d times'],
++ edit: ['Edited a file', 'Edited %d files'], write: ['Wrote a file', 'Wrote %d files'], web: ['Looked something up', 'Looked up %d things'],
++ agent: ['Started a helper', 'Started %d helpers'], plan: ['Updated the plan', 'Updated the plan'], tool: ['Used a tool', 'Used %d tools'],
++ };
++ const ICON = {
++ command: '<path d="M4 6l5 5-5 5"/><path d="M11 17h9"/>',
++ read: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/>',
++ search: '<circle cx="11" cy="11" r="6"/><path d="m20 20-4.2-4.2"/>',
++ edit: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13 7 4 4"/>',
++ write: '<path d="M6 3h8l4 4v14H6z"/><path d="M12 11v6M9 14h6"/>',
++ web: '<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17M12 3.5c2.5 2.6 2.5 14.4 0 17M12 3.5c-2.5 2.6-2.5 14.4 0 17"/>',
++ agent: '<rect x="5" y="7" width="14" height="11" rx="3"/><path d="M12 3v4M9 12h.01M15 12h.01"/>',
++ plan: '<path d="M9 6h11M9 12h11M9 18h11"/><path d="m3 6 1.5 1.5L7 5M3 12l1.5 1.5L7 11M3 18l1.5 1.5L7 17"/>',
++ tool: '<path d="M14 7a4 4 0 0 0-5.6 5.1L4 16.5 7.5 20l4.4-4.4A4 4 0 0 0 17 10l-2.5 2.5-3-3z"/>',
++ ok: '<path d="m5 12.5 4.5 4.5L19 7.5"/>', fail: '<path d="M6 6l12 12M18 6 6 18"/>', copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>',
++ handoff: '<path d="M4 12h13"/><path d="m13 7 5 5-5 5"/>', warn: '<path d="M12 4 2.5 20h19z"/><path d="M12 10v4M12 17h.01"/>',
++ chevron: '<path d="m9 6 6 6-6 6"/>',
++ img: '<rect x="3" y="4.5" width="18" height="15" rx="2"/><circle cx="8.5" cy="9.5" r="1.6"/><path d="m4 17 4.5-4.5 3.5 3.5 3-3L20 17"/>',
++ video: '<rect x="3" y="5" width="13" height="14" rx="2"/><path d="m16 10 5-3v10l-5-3z"/>',
++ audio: '<path d="M9 17V6l10-2v11"/><circle cx="6.5" cy="17.5" r="2.5"/><circle cx="16.5" cy="15.5" r="2.5"/>',
++ doc: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/><path d="M9 12.5h6M9 16h6"/>',
++ code: '<path d="m9 8-5 4 5 4M15 8l5 4-5 4"/>',
++ file: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/>',
++ };
++ const svg = (name, cls) => `<svg class="${cls || 'ovas-ic'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICON[name] || ICON.tool}</svg>`;
++ const AGENT_NAME = { claude: 'Claude Code', 'claude-code': 'Claude Code', codex: 'Codex', opencode: 'OpenCode', 'command-code': 'Command Code', cmd: 'Command Code', aider: 'Aider', deepseek: 'DeepSeek', 'openvibe-ai': 'OpenVibe.AI', ai: 'OpenVibe.AI', own: 'Your model' };
++ const agentName = (a) => AGENT_NAME[String(a || '').toLowerCase()] || String(a || 'Agent');
++
++ // ── stream-json → lines (the same mapping as harness/job.js) ─
++ const MAX_TEXT = 6000;
++ const cut = (s, n) => { const t = String(s == null ? '' : s); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
++ function resultText(content) {
++ if (typeof content === 'string') return content;
++ if (Array.isArray(content)) return content.map((c) => (c && typeof c.text === 'string' ? c.text : '')).join('\n');
++ return content == null ? '' : String(content);
++ }
++ // OpenVibe.Actor's task events (actor.task-event@1): { kind: 'state' | 'end', state } and { kind: 'output', step, agent,
++ // text, tool, input, is_error }, step one of plan, text, handoff, check, tool_call, tool_result.
++ function fromActor(e) {
++ const agent = e.agent ? { agent: String(e.agent) } : {};
++ if (e.kind === 'state' || e.kind === 'end') return e.state ? [{ t: 'state', state: String(e.state), ...agent }] : [];
++ if (e.kind !== 'output') return [];
++ const text = cut(e.text, MAX_TEXT);
++ const att = e.attachments ? { attachments: e.attachments } : {};
++ if (e.step === 'text') return [e.is_error ? { t: 'note', level: 'error', text, ...agent } : { t: 'text', text, ...att, ...agent }];
++ if (e.step === 'plan') return [{ t: 'step', text, ...agent }];
++ if (e.step === 'handoff') return [{ t: 'handoff', from: null, to: e.agent || null, reason: text, ...agent }];
++ if (e.step === 'check') return [{ t: 'note', level: /^checked/i.test(text) ? 'info' : 'warning', text, ...agent }];
++ if (e.step === 'tool_call') return [{ t: 'tool', name: String(e.tool || 'tool'), what: typeof e.input === 'string' ? cut(e.input, 300) : '', input: e.input && typeof e.input === 'object' ? e.input : { description: typeof e.input === 'string' ? cut(e.input, 300) : '' }, ...att, ...agent }];
++ if (e.step === 'tool_result') return [{ t: 'tool_result', name: String(e.tool || 'tool'), ok: !e.is_error, output: cut(e.text, 8000), ...att, ...agent }];
++ return text ? [{ t: 'text', text, ...agent }] : [];
++ }
++
++ // ── AG-UI 1.0 (protocol 1.2, ask 354, 2026-11-11): every OpenVibe agent run streams AG-UI events (docs.ag-ui.com,
++ // spec 1.0 of 2026-11-07) with OpenVibe's own words in metadata.ov (title, kind, sources, …), so another tool's agent
++ // renders here and ours in theirs. Deltas (text, tool arguments, thinking) carry their ids; the view joins them.
++ const pick = (e, ...k) => { for (const n of k) if (e[n] != null) return e[n]; return null; };
++ function fromAgui(e) {
++ const ov = (e.metadata && typeof e.metadata === 'object' && e.metadata.ov && typeof e.metadata.ov === 'object') ? e.metadata.ov : {};
++ const src = Array.isArray(ov.sources) && ov.sources.length ? [{ t: 'sources', items: ov.sources }] : [];
++ const id = (...k) => String(pick(e, ...k) || '');
++ switch (e.type) {
++ case 'RUN_STARTED': return [{ t: 'state', state: 'running' }];
++ case 'RUN_FINISHED': return [...src, { t: 'done', ok: true, text: cut(typeof e.result === 'string' ? e.result : '', MAX_TEXT) }];
++ case 'RUN_ERROR': return [{ t: 'done', ok: false, text: cut(String(e.message || 'The run failed.'), 2000) }];
++ case 'STEP_STARTED': return [{ t: 'phase', id: id('stepName', 'step_name'), title: String(ov.title || pick(e, 'stepName', 'step_name') || 'Step'), status: 'running' }];
++ case 'STEP_FINISHED': return [{ t: 'phase', id: id('stepName', 'step_name'), status: 'done' }];
++ case 'TEXT_MESSAGE_START': return [{ t: 'text_start', id: id('messageId', 'message_id') }];
++ case 'TEXT_MESSAGE_CONTENT': case 'TEXT_MESSAGE_CHUNK': return [{ t: 'text_delta', id: id('messageId', 'message_id'), delta: String(e.delta || '') }];
++ case 'TOOL_CALL_START': return [{ t: 'tool', id: id('toolCallId', 'tool_call_id'), name: String(pick(e, 'toolCallName', 'tool_call_name') || 'tool'), input: {}, title: ov.title ? cut(ov.title, 160) : '', kind: ov.kind || null }];
++ case 'TOOL_CALL_ARGS': case 'TOOL_CALL_CHUNK': return [{ t: 'tool_args', id: id('toolCallId', 'tool_call_id'), delta: String(e.delta || '') }];
++ case 'TOOL_CALL_RESULT': return [...src, { t: 'tool_result', id: id('toolCallId', 'tool_call_id'), ok: !ov.error, output: cut(typeof e.content === 'string' ? e.content : JSON.stringify(e.content || ''), 8000), duration_ms: Number(ov.duration_ms) || 0, cost_usd: Number(ov.cost_usd) || 0 }];
++ case 'REASONING_START': case 'REASONING_MESSAGE_START': return [{ t: 'thinking', id: id('messageId', 'message_id') || 'r' }];
++ case 'REASONING_MESSAGE_CONTENT': case 'REASONING_MESSAGE_CHUNK': return [{ t: 'thinking_delta', id: id('messageId', 'message_id') || 'r', delta: String(e.delta || '') }];
++ case 'REASONING_END': case 'REASONING_MESSAGE_END': return [{ t: 'thinking', id: id('messageId', 'message_id') || 'r', done: true }];
++ case 'REASONING_ENCRYPTED_VALUE': return [{ t: 'thinking', id: id('messageId', 'message_id') || 'r', hidden: true }];
++ case 'STATE_SNAPSHOT': {
++ const plan = e.snapshot && Array.isArray(e.snapshot.plan) ? e.snapshot.plan : null;
++ return plan ? [{ t: 'phases', items: plan.slice(0, 30).map((p) => ({ title: String(p.title || p.content || ''), status: String(p.status || 'pending') })) }] : [];
++ }
++ case 'ACTIVITY_SNAPSHOT': return e.content && e.content.title ? [{ t: 'activity', text: cut(String(e.content.title), 200) }] : [];
++ case 'CUSTOM': {
++ if (e.name === 'ov.sources' && Array.isArray(e.value)) return [{ t: 'sources', items: e.value }];
++ if (e.name === 'ov.handoff' && e.value) return [{ t: 'handoff', from: e.value.from, to: e.value.to || null, reason: cut(e.value.reason, 600) }];
++ if (e.name === 'ov.approval' && e.value) return [{ t: 'note', level: 'warning', text: cut(e.value.question || 'Waiting for your approval.', 600) }];
++ return [];
++ }
++ default: return src;
++ }
++ }
++ function normalize(e) {
++ if (!e || typeof e !== 'object') return [];
++ if (e.t) return [e];
++ if (typeof e.type === 'string' && /^[A-Z][A-Z_]+$/.test(e.type)) return fromAgui(e);
++ if (e.kind) return fromActor(e);
++ const parent = e.parent_tool_use_id || null;
++ if (e.type === 'system' && e.subtype === 'init') return [{ t: 'agent', agent: e.agent || 'claude', model: e.model || null }];
++ if (e.type === 'system' && e.subtype === 'handoff') return [{ t: 'handoff', from: e.from, to: e.to || null, reason: cut(e.reason, 600) }];
++ if (e.type === 'system' && (e.subtype === 'error' || e.subtype === 'warning')) return [{ t: 'note', level: e.subtype, text: cut(e.text, 600) }];
++ if (e.type === 'system' && e.subtype === 'tool_progress') return [{ t: 'tool_progress', id: String(e.tool_use_id || ''), chunk: String(e.text || '') }];
++ if (e.type === 'system' && e.subtype === 'usage' && e.usage) return [{ t: 'usage', input_tokens: e.usage.input_tokens || 0, output_tokens: e.usage.output_tokens || 0 }];
++ if (e.type === 'assistant' && e.message) {
++ const out = [];
++ for (const c of e.message.content || []) {
++ if (c.type === 'text' && c.text) out.push({ t: 'text', text: cut(c.text, MAX_TEXT), parent });
++ if (c.type === 'tool_use') out.push({ t: 'tool', id: String(c.id || ''), name: String(c.name || ''), input: c.input || {}, parent });
++ }
++ if (e.message.usage) out.push({ t: 'usage', input_tokens: e.message.usage.input_tokens || 0, output_tokens: e.message.usage.output_tokens || 0 });
++ return out;
++ }
++ if (e.type === 'user' && e.message && Array.isArray(e.message.content)) {
++ return e.message.content.filter((c) => c && c.type === 'tool_result').map((c) => ({ t: 'tool_result', id: String(c.tool_use_id || ''), ok: !c.is_error, output: cut(resultText(c.content), 8000) }));
++ }
++ if (e.type === 'result') return [{ t: 'done', ok: !e.is_error, text: cut(e.result, MAX_TEXT), duration_ms: e.duration_ms || 0, cost_usd: e.total_cost_usd || 0, turns: e.num_turns || 0 }];
++ return [];
++ }
++
++ // ── Small helpers ─────────────────────────────────────────
++ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
++ const plural = (k, n) => (n === 1 ? VERB[k][0] : VERB[k][1].replace('%d', n));
++ function dur(ms) {
++ const s = Math.max(0, Math.round(ms / 1000));
++ if (s < 60) return `${s} s`;
++ const m = Math.floor(s / 60);
++ return m < 60 ? `${m} min ${s % 60 ? `${s % 60} s` : ''}`.trim() : `${Math.floor(m / 60)} h ${m % 60} min`;
++ }
++ const base = (p) => String(p || '').split('/').filter(Boolean).pop() || String(p || '');
++
++ // ── Attachments: the images and files a step read, wrote or produced (4.50.0) ──────────
++ // The shape a step or a message carries:
++ // attachments: [{ url, name, mime, size?, kind: 'image'|'video'|'audio'|'pdf'|'text'|'file', role: 'read'|'wrote'|'output' }]
++ // The product supplies the URLs; the component never builds one from a local path.
++ const ATT_KINDS = new Set(['image', 'video', 'audio', 'pdf', 'text', 'file']);
++ const ATT_ROLES = new Set(['read', 'wrote', 'output']);
++ const ATT_ROLE_LABEL = { read: 'read', wrote: 'wrote', output: 'produced' };
++ const ATT_ICON = { image: 'img', video: 'video', audio: 'audio', pdf: 'doc', text: 'code', file: 'file' };
++ const ATT_EXT = {
++ image: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp', 'ico'],
++ video: ['mp4', 'webm', 'mov', 'm4v', 'ogv'],
++ audio: ['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac'],
++ pdf: ['pdf'],
++ text: ['md', 'markdown', 'txt', 'json', 'jsonl', 'csv', 'tsv', 'log', 'yaml', 'yml', 'html', 'css', 'js', 'mjs', 'cjs',
++ 'ts', 'tsx', 'jsx', 'py', 'go', 'rs', 'sh', 'sql', 'xml', 'toml', 'ini', 'env', 'conf'],
++ };
++ const attExt = (name) => (String(name || '').split('?')[0].split('#')[0].split('.').pop() || '').toLowerCase();
++ /** What kind of thing an attachment is: what it says, or what its name ends in. */
++ function attKind(mime, name, kind) {
++ if (ATT_KINDS.has(kind)) return kind;
++ const m = String(mime || '').toLowerCase().split(';')[0].trim();
++ if (m.startsWith('image/')) return 'image';
++ if (m.startsWith('video/')) return 'video';
++ if (m.startsWith('audio/')) return 'audio';
++ if (m === 'application/pdf') return 'pdf';
++ if (m.startsWith('text/')) return 'text';
++ if (m === 'application/json' || m === 'application/xml' || m === 'application/x-yaml') return 'text';
++ const ext = attExt(name);
++ for (const k of Object.keys(ATT_EXT)) if (ATT_EXT[k].includes(ext)) return k;
++ return 'file';
++ }
++ /** The attachments of a step or a message, in the shape the component draws them. */
++ function normAttachments(list) {
++ const out = [], seen = new Set();
++ for (const a of Array.isArray(list) ? list : []) {
++ if (!a || typeof a !== 'object') continue;
++ const url = String(a.url || '').trim();
++ if (!url || seen.has(url)) continue;
++ seen.add(url);
++ const name = String(a.name || '').trim() || base(String(url).split('?')[0]) || 'file';
++ out.push({ url, name, mime: String(a.mime || ''), size: Number(a.size) || 0, kind: attKind(a.mime, name, a.kind), role: ATT_ROLES.has(a.role) ? a.role : 'output' });
++ }
++ return out;
++ }
++ /** The page's own origin, or Media's: an attachment loads from there and nowhere else. */
++ function attAllowed(url, extra) {
++ const raw = String(url || '').trim();
++ if (!raw) return false;
++ if (/^(blob:|data:image\/(png|jpe?g|gif|webp);)/i.test(raw)) return true; // the page made it itself
++ if (raw.startsWith('/') && !raw.startsWith('//')) return true; // the product's own origin
++ const here = (typeof location !== 'undefined' && location && location.hostname) ? String(location.hostname).toLowerCase() : '';
++ let host = '';
++ try { host = new URL(raw, here ? 'https://' + here : 'https://openvibe.network/').hostname.toLowerCase(); } catch { return false; }
++ if (here && host === here) return true;
++ if (host === 'openvibe.media' || host.endsWith('.openvibe.media')) return true;
++ for (const o of Array.isArray(extra) ? extra : []) {
++ const h = String(o || '').toLowerCase().replace(/^https?:\/\//, '').split('/')[0].split(':')[0];
++ if (h && (host === h || host.endsWith('.' + h))) return true;
++ }
++ return false;
++ }
++ /** The tiles of a line: the images in one row, the other files as chips in a row under them. */
++ function attStrip(list, onOpen) {
++ const strip = el('div', 'ovas-att');
++ const imgs = el('div', 'ovas-att-row');
++ const chips = el('div', 'ovas-att-row');
++ for (const a of list) {
++ const b = el('button', a.kind === 'image' ? 'ovas-att-img' : 'ovas-att-chip');
++ b.type = 'button';
++ b.dataset.kind = a.kind;
++ b.dataset.role = a.role;
++ const role = ATT_ROLE_LABEL[a.role] || 'produced';
++ b.title = role + ': ' + a.name;
++ b.setAttribute('aria-label', 'Open ' + a.name + ' (' + role + ')');
++ if (a.kind === 'image') {
++ const img = el('img');
++ img.loading = 'lazy'; img.decoding = 'async'; img.alt = a.name; img.src = a.url;
++ img.addEventListener('error', () => { img.remove(); b.hidden = true; });
++ b.appendChild(img);
++ imgs.appendChild(b);
++ } else {
++ b.insertAdjacentHTML('beforeend', svg(ATT_ICON[a.kind] || 'file'));
++ chips.appendChild(b);
++ }
++ b.appendChild(el('span', 'ovas-att-cap', a.name));
++ b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onOpen(a); });
++ }
++ if (imgs.childNodes.length) strip.appendChild(imgs);
++ if (chips.childNodes.length) strip.appendChild(chips);
++ return strip;
++ }
++
++ /** A text file as the preview shows it: a CSV as a table, JSON pretty-printed, Markdown read, code highlighted. */
++ function attText(name, text, linkOk) {
++ const ext = attExt(name);
++ let body = String(text == null ? '' : text);
++ if (ext === 'json' || ext === 'jsonl') { try { body = JSON.stringify(JSON.parse(body), null, 2); } catch { /* not one document */ } }
++ if (ext === 'csv' || ext === 'tsv') { const table = attCsv(body, ext === 'tsv' ? '\t' : ','); if (table) return table; }
++ if (ext === 'md' || ext === 'markdown') {
++ const frag = markdown(body, linkOk);
++ frag.querySelectorAll('pre code').forEach((code) => { const hi = attHighlight(code.textContent, ''); code.textContent = ''; code.appendChild(hi); });
++ return frag;
++ }
++ const pre = el('pre', 'ovas-prev-code');
++ const code = el('code');
++ code.appendChild(attHighlight(body, ext === 'json' ? 'json' : ''));
++ pre.appendChild(code);
++ return pre;
++ }
++ /** Pinch to zoom on a phone, double tap to double, so a screenshot can be read. */
++ function attZoom(img) {
++ let scale = 1, start = null;
++ const apply = () => { img.style.transform = scale > 1 ? 'scale(' + scale + ')' : ''; };
++ img.addEventListener('touchstart', (e) => {
++ if (e.touches.length === 2) { const a = e.touches[0], b = e.touches[1]; start = { d: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY), s: scale }; }
++ }, { passive: true });
++ img.addEventListener('touchmove', (e) => {
++ if (!start || e.touches.length !== 2) return;
++ e.preventDefault();
++ const a = e.touches[0], b = e.touches[1];
++ const d = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
++ scale = Math.min(4, Math.max(1, start.s * (d / (start.d || 1)))); apply();
++ }, { passive: false });
++ img.addEventListener('touchend', () => { start = null; });
++ img.addEventListener('dblclick', () => { scale = scale > 1 ? 1 : 2; apply(); });
++ img.addEventListener('wheel', (e) => { if (!e.ctrlKey) return; e.preventDefault(); scale = Math.min(4, Math.max(1, scale - e.deltaY / 200)); apply(); }, { passive: false });
++ }
++ /** A small highlighter for the preview: strings, numbers, keywords and comments, as spans (never markup). */
++ function attHighlight(text, lang) {
++ const frag = document.createDocumentFragment();
++ const json = lang === 'json';
++ const re = json
++ ? /("(?:[^"\\]|\\.)*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g
++ : /(\/\/[^\n]*|#[^\n]*|--[^\n]*)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|\b(const|let|var|function|return|if|else|for|while|do|import|export|from|default|class|extends|new|await|async|try|catch|finally|throw|def|then|null|true|false|None|True|False)\b|(\b\d+(?:\.\d+)?\b)/g;
++ const push = (s, cls) => { if (!s) return; frag.appendChild(cls ? el('span', cls, s) : document.createTextNode(s)); };
++ let at = 0, m;
++ while ((m = re.exec(text))) {
++ push(text.slice(at, m.index));
++ if (json) {
++ if (m[1] != null) { push(m[1], m[2] != null ? 'ovas-tok-key' : 'ovas-tok-str'); if (m[2] != null) push(m[2], ''); }
++ else if (m[3] != null) push(m[3], 'ovas-tok-kw');
++ else push(m[4], 'ovas-tok-num');
++ } else if (m[1] != null) push(m[1], 'ovas-tok-com');
++ else if (m[2] != null) push(m[2], 'ovas-tok-str');
++ else if (m[3] != null) push(m[3], 'ovas-tok-kw');
++ else push(m[4], 'ovas-tok-num');
++ at = m.index + m[0].length;
++ }
++ push(text.slice(at));
++ return frag;
++ }
++ /** A CSV as a table (quoted cells kept whole). */
++ function attCsv(text, delim) {
++ const rows = String(text || '').split(/\r?\n/).filter((l) => l.trim() !== '');
++ if (!rows.length) return null;
++ const split = (line) => {
++ const out = []; let cur = '', q = false;
++ for (let i = 0; i < line.length; i++) {
++ const ch = line[i];
++ if (q) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; } else cur += ch; }
++ else if (ch === '"') q = true;
++ else if (ch === delim) { out.push(cur.trim()); cur = ''; }
++ else cur += ch;
++ }
++ out.push(cur.trim());
++ return out;
++ };
++ const head = split(rows[0]);
++ const table = el('table', 'ovas-csv');
++ const thead = el('thead'), tr = el('tr');
++ head.forEach((h) => tr.appendChild(el('th', '', h || ' ')));
++ thead.appendChild(tr); table.appendChild(thead);
++ const tb = el('tbody');
++ for (const line of rows.slice(1, 200)) {
++ const r = el('tr'), cells = split(line);
++ for (let i = 0; i < head.length; i++) r.appendChild(el('td', '', cells[i] == null ? '' : cells[i]));
++ tb.appendChild(r);
++ }
++ table.appendChild(tb);
++ return table;
++ }
++
++ /** Light Markdown, text-safe: paragraphs, bullet and numbered lists, `code`, ```blocks```, **bold**, https links (only
++ * those `linkOk(url)` allows; the others stay text). */
++ function markdown(text, linkOk) {
++ const frag = document.createDocumentFragment();
++ const inline = (s, into) => {
++ const re = /(`[^`]+`|\*\*[^*]+\*\*|https:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/g;
++ let at = 0, m;
++ while ((m = re.exec(s))) {
++ if (m.index > at) into.appendChild(document.createTextNode(s.slice(at, m.index)));
++ const tok = m[0];
++ if (tok[0] === '`') into.appendChild(el('code', '', tok.slice(1, -1)));
++ else if (tok.startsWith('**')) into.appendChild(el('strong', '', tok.slice(2, -2)));
++ else if (linkOk && !linkOk(tok)) into.appendChild(document.createTextNode(tok));
++ else { const a = el('a', '', tok); a.href = tok; a.target = '_blank'; a.rel = 'noopener noreferrer'; into.appendChild(a); }
++ at = m.index + tok.length;
++ }
++ if (at < s.length) into.appendChild(document.createTextNode(s.slice(at)));
++ };
++ const blocks = String(text || '').replace(/\r/g, '').split(/\n```/);
++ String(text || '').replace(/\r/g, '').split(/(```[\s\S]*?(?:```|$))/).forEach((part) => {
++ if (!part) return;
++ if (part.startsWith('```')) { const pre = el('pre', 'ovas-code'); pre.appendChild(el('code', '', part.replace(/^```[^\n]*\n?/, '').replace(/```$/, ''))); frag.appendChild(pre); return; }
++ part.split(/\n{2,}/).forEach((para) => {
++ const lines = para.split('\n').filter((l) => l.trim());
++ if (!lines.length) return;
++ if (lines.every((l) => /^\s*([-*•]|\d+[.)])\s+/.test(l))) {
++ const ol = /^\s*\d/.test(lines[0]);
++ const list = el(ol ? 'ol' : 'ul');
++ lines.forEach((l) => { const li = el('li'); inline(l.replace(/^\s*([-*•]|\d+[.)])\s+/, ''), li); list.appendChild(li); });
++ frag.appendChild(list);
++ } else { const p = el('p'); lines.forEach((l, i) => { if (i) p.appendChild(el('br')); inline(l.replace(/^#{1,6}\s+/, ''), p); }); frag.appendChild(p); }
++ });
++ });
++ void blocks;
++ return frag;
++ }
++
++ /** A line diff of two texts (LCS for small inputs, both sides whole beyond): [['=', line] | ['-', line] | ['+', line]]. */
++ function diffLines(a, b) {
++ const x = String(a || '').split('\n'), y = String(b || '').split('\n');
++ if (x.length * y.length > 90000) return x.map((l) => ['-', l]).concat(y.map((l) => ['+', l]));
++ const n = x.length, m = y.length, L = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
++ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i][j] = x[i] === y[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
++ const out = []; let i = 0, j = 0;
++ while (i < n && j < m) { if (x[i] === y[j]) { out.push(['=', x[i]]); i++; j++; } else if (L[i + 1][j] >= L[i][j + 1]) out.push(['-', x[i++]]); else out.push(['+', y[j++]]); }
++ while (i < n) out.push(['-', x[i++]]);
++ while (j < m) out.push(['+', y[j++]]);
++ return out;
++ }
++
++ /** What a step shows on its row and inside: title, the thing it touched, a diff or content, the counts. */
++ function describe(step) {
++ const i = step.input || {};
++ const k = step.kind;
++ const out = { title: '', sub: '', command: '', diff: null, preview: '', added: 0, removed: 0 };
++ if (k === 'command') { out.command = String(i.command || i.cmd || step.what || ''); out.title = String(i.description || '') || out.command.split('\n')[0]; out.sub = i.description ? out.command.split('\n')[0] : ''; }
++ else if (k === 'read') { const p = i.file_path || i.path || step.what; out.title = `Read ${base(p)}`; out.sub = String(p || ''); }
++ else if (k === 'search') { out.title = i.pattern ? `Searched for ${i.pattern}` : `Listed ${base(i.path || step.what) || 'files'}`; out.sub = String(i.path || i.glob || step.what || ''); }
++ else if (k === 'edit' || k === 'write') {
++ const p = i.file_path || i.path || i.notebook_path || step.what;
++ out.title = `${k === 'write' ? 'Wrote' : 'Edited'} ${base(p) || 'a file'}`; out.sub = String(p || '');
++ const edits = Array.isArray(i.edits) ? i.edits : (i.old_string != null || i.new_string != null) ? [{ old_string: i.old_string, new_string: i.new_string }] : [];
++ if (edits.length) { out.diff = []; edits.forEach((e, n) => { if (n) out.diff.push(['@', '…']); out.diff.push(...diffLines(e.old_string, e.new_string)); }); }
++ else if (typeof i.content === 'string') { out.preview = i.content; out.diff = i.content.split('\n').map((l) => ['+', l]); }
++ if (out.diff) out.diff.forEach(([op]) => { if (op === '+') out.added++; else if (op === '-') out.removed++; });
++ } else if (k === 'web') {
++ const asked = i.query || (!i.url && !/^https?:\/\//.test(String(i.description || step.what || '')) ? i.description || step.what : '');
++ const url = i.url || (/^https?:\/\//.test(String(i.description || step.what || '')) ? i.description || step.what : '');
++ out.title = asked ? `Looked up ${asked}` : `Opened ${String(url).replace(/^https?:\/\//, '').slice(0, 60)}`; out.sub = asked ? '' : String(url);
++ }
++ else if (k === 'agent') { out.title = String(i.description || 'A helper agent'); out.sub = i.subagent_type ? `${i.subagent_type} helper` : 'helper'; out.preview = String(i.prompt || ''); }
++ else if (k === 'plan') { out.title = 'Updated the plan'; }
++ else { out.title = String(step.name || 'Tool'); out.sub = String(step.what || ''); }
++ if (!out.title) out.title = String(step.what || step.name || 'Step');
++ if (step.title) out.title = String(step.title); // the agent's own words for it (AG-UI metadata.ov.title)
++ return out;
++ }
++
++ // ── Styles: agent-session.css, from beside this script (theme tokens; light and dark) ─────────────────
++ const SELF_SRC = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
++ function styles() {
++ if (typeof document === 'undefined' || document.getElementById('openvibe-agent-session-styles') || document.querySelector('link[href*="agent-session.css"]')) return;
++ const m = /^(https?:\/\/[^?#]*\/)agent-session\.js(?:[?#]|$)/.exec(SELF_SRC);
++ const link = document.createElement('link');
++ link.id = 'openvibe-agent-session-styles';
++ link.rel = 'stylesheet';
++ link.href = `${m ? m[1] : 'https://openvibe.network/shared/'}agent-session.css`;
++ document.head.appendChild(link);
++ }
++
++ // ── The view ──────────────────────────────────────────────
++ function mount(host, opts = {}) {
++ styles();
++ const linkOk = typeof opts.link === 'function' ? (u) => { try { return !!opts.link(u); } catch { return false; } } : null;
++ // Attachments load from the product's own origin or Media's; a product that serves files from another host
++ // says so: mount(el, { origins: ['files.openvibe.codes'] }).
++ const origins = Array.isArray(opts.origins) ? opts.origins : (opts.origins ? [opts.origins] : []);
++ const safeAtts = (list) => normAttachments(list).filter((a) => attAllowed(a.url, origins));
++ const LIVE = ['running', 'placed', 'queued', 'verifying'];
++ // The clock runs from startedAt, or from now for a run that is live as it mounts; a finished run replayed without
++ // a start time shows the time only once its done line says it.
++ const clock = Boolean(opts.startedAt) || LIVE.includes(opts.status || 'running');
++ const S = { phases: [], thinks: new Map(), msgs: new Map(), sources: new Map(), activity: '', ownPlan: false, status: opts.status || 'running', label: opts.label || '', startedAt: opts.startedAt || Date.now(), endedAt: null, agents: [], steps: new Map(), plan: null, tokens: { in: 0, out: 0 }, cost: 0, agentCost: new Map(), durationMs: 0, problems: 0, filter: 'all', follow: opts.follow !== false, lastGroup: null };
++ host.textContent = '';
++ const box = el('div', 'ovas');
++ box.innerHTML = `<div class="ovas-head"><div class="ovas-top"><span class="ovas-pill" data-s="${esc(S.status)}"></span><span class="ovas-title"></span></div><div class="ovas-meta"><span class="ovas-agents"></span><span data-m="now" hidden></span><span data-m="time"></span><span data-m="steps"></span><span data-m="files"></span><span data-m="cost"></span></div><details class="ovas-plan" hidden><summary><span class="ovas-plan-now"></span><span class="ovas-plan-n"></span></summary><div class="ovas-plan-bar"><i style="width:0"></i></div><ol></ol></details></div>`
++ + '<div class="ovas-bar" role="toolbar" aria-label="Show"></div><ol class="ovas-files" hidden></ol><ol class="ovas-feed" aria-live="polite"></ol><div class="ovas-actions" hidden></div>';
++ host.appendChild(box);
++ const head = box.querySelector('.ovas-head'), feed = box.querySelector('.ovas-feed'), bar = box.querySelector('.ovas-bar');
++ const filesEl = box.querySelector('.ovas-files'), actBar = box.querySelector('.ovas-actions');
++ // The sources strip (protocol 1.2) under the feed; card mode (opts.mode 'card': a chat message, the Dock, a feed card)
++ // keeps the head, the plan row, the newest three lines and the sources, with a way to the whole run (opts.href).
++ const srcBox = el('details', 'ovas-sources'); srcBox.hidden = true;
++ srcBox.innerHTML = '<summary><span class="ovas-src-tiles"></span><span class="ovas-src-n"></span></summary><ol></ol>';
++ box.insertBefore(srcBox, actBar);
++ if (opts.mode === 'card') {
++ box.classList.add('ovas--card');
++ if (typeof opts.href === 'string' && /^(https:\/\/|\/)/.test(opts.href)) { const more = el('a', 'ovas-open', 'Open the whole run'); more.href = opts.href; box.insertBefore(more, actBar); }
++ }
++ head.querySelector('.ovas-title').textContent = opts.title || 'Agent session';
++ const FILTERS = [['all', 'Everything'], ['said', 'What it said'], ['steps', 'Steps'], ['files', 'Files'], ['problems', 'Problems']];
++ FILTERS.forEach(([id, label]) => { const b = el('button', 'ovas-chip', label); b.type = 'button'; b.dataset.f = id; b.setAttribute('aria-pressed', String(id === 'all')); bar.appendChild(b); });
++ const follow = el('button', 'ovas-chip ovas-follow', 'Follow'); follow.type = 'button'; follow.setAttribute('aria-pressed', String(S.follow)); follow.title = 'Keep the newest step in view'; bar.appendChild(follow);
++ bar.addEventListener('click', (e) => {
++ const b = e.target.closest('button'); if (!b) return;
++ if (b === follow) { S.follow = !S.follow; follow.setAttribute('aria-pressed', String(S.follow)); return; }
++ S.filter = b.dataset.f; bar.querySelectorAll('[data-f]').forEach((x) => x.setAttribute('aria-pressed', String(x === b))); applyFilter();
++ });
++ box.addEventListener('click', (e) => {
++ const c = e.target.closest('.ovas-copy'); if (!c) return;
++ e.preventDefault(); e.stopPropagation();
++ const pre = c.parentElement && c.parentElement.nextElementSibling;
++ const text = pre ? pre.textContent : '';
++ try { navigator.clipboard.writeText(text); c.lastChild.textContent = 'Copied'; setTimeout(() => { c.lastChild.textContent = 'Copy'; }, 1400); } catch { /* no clipboard */ }
++ });
++ const empty = el('li', 'ovas-empty', opts.empty || 'Waiting for the agent to start…'); feed.appendChild(empty);
++ // The run is still working: three dots after the newest line (gone when it ends).
++ const working = el('div', 'ovas-working'); working.setAttribute('aria-hidden', 'true'); working.innerHTML = '<i></i><i></i><i></i>';
++ working.hidden = !LIVE.includes(S.status); box.insertBefore(working, box.querySelector('.ovas-actions'));
++
++ // ── The preview sheet: one attachment, opened by a tap ──────────────────────────
++ const sheet = el('div', 'ovas-sheet');
++ sheet.hidden = true;
++ sheet.innerHTML = '<div class="ovas-sheet-panel" role="dialog" aria-modal="true" aria-label="Preview"><div class="ovas-sheet-bar"><b class="ovas-sheet-name"></b><button type="button" class="ovas-sheet-x" aria-label="Close the preview">✕</button></div><div class="ovas-sheet-body"></div><div class="ovas-sheet-foot"><a class="ovas-sheet-open" target="_blank" rel="noopener noreferrer">Open</a><a class="ovas-sheet-dl" download>Download</a></div></div>';
++ host.appendChild(sheet);
++ let sheetBack = null;
++ function closeSheet() {
++ if (sheet.hidden) return;
++ sheet.hidden = true;
++ sheet.querySelector('.ovas-sheet-body').textContent = '';
++ if (sheetBack && sheetBack.focus) sheetBack.focus();
++ sheetBack = null;
++ }
++ function openSheet(a) {
++ const body = sheet.querySelector('.ovas-sheet-body');
++ body.textContent = '';
++ sheet.querySelector('.ovas-sheet-name').textContent = a.name;
++ const open = sheet.querySelector('.ovas-sheet-open'); open.href = a.url; open.setAttribute('aria-label', 'Open ' + a.name);
++ const dl = sheet.querySelector('.ovas-sheet-dl'); dl.href = a.url; dl.setAttribute('download', a.name);
++ const wrap = el('div', 'ovas-prev');
++ if (a.kind === 'image') {
++ const img = el('img', 'ovas-prev-img'); img.alt = a.name; img.src = a.url; wrap.appendChild(img); attZoom(img);
++ } else if (a.kind === 'video') {
++ const v = el('video', 'ovas-prev-media'); v.controls = true; v.playsInline = true; v.preload = 'metadata'; v.src = a.url; wrap.appendChild(v);
++ } else if (a.kind === 'audio') {
++ const au = el('audio', 'ovas-prev-audio'); au.controls = true; au.preload = 'metadata'; au.src = a.url; wrap.appendChild(au);
++ } else if (a.kind === 'pdf') {
++ const f = el('iframe', 'ovas-prev-pdf'); f.src = a.url; f.title = a.name; wrap.appendChild(f);
++ } else if (a.kind === 'text') {
++ const holder = el('div', 'ovas-prev-text');
++ holder.appendChild(el('p', 'ovas-prev-note', 'Loading…'));
++ wrap.appendChild(holder);
++ fetch(a.url, { credentials: 'same-origin' })
++ .then((r) => (r.ok ? r.text() : Promise.reject(new Error('HTTP ' + r.status))))
++ .then((t) => { holder.textContent = ''; holder.appendChild(attText(a.name, t, linkOk)); })
++ .catch((err) => { holder.textContent = 'Could not open it: ' + err.message; });
++ } else {
++ wrap.appendChild(el('p', 'ovas-prev-note', 'There is no preview for this file. Open it or download it.'));
++ }
++ body.appendChild(wrap);
++ sheetBack = (typeof document !== 'undefined' && document.activeElement) || null;
++ sheet.hidden = false;
++ const x = sheet.querySelector('.ovas-sheet-x'); if (x && x.focus) x.focus();
++ }
++ sheet.querySelector('.ovas-sheet-x').addEventListener('click', closeSheet);
++ sheet.addEventListener('click', (e) => { if (e.target === sheet) closeSheet(); });
++ document.addEventListener('keydown', (e) => {
++ if (sheet.hidden) return;
++ if (e.key === 'Escape') { e.preventDefault(); closeSheet(); return; }
++ if (e.key !== 'Tab') return;
++ const f = [...sheet.querySelectorAll('button, a[href], iframe')].filter((n) => !n.disabled && n.offsetParent !== null);
++ if (!f.length) return;
++ const first = f[0], last = f[f.length - 1];
++ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
++ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
++ });
++
++ function applyFilter() {
++ const files = S.filter === 'files';
++ feed.hidden = files; filesEl.hidden = !files;
++ if (files) { paintFiles(); return; }
++ feed.querySelectorAll(':scope > li').forEach((li) => {
++ const k = li.dataset.k || '';
++ li.hidden = S.filter === 'all' ? false : S.filter === 'said' ? !(k === 'msg' || k === 'done' || k === 'handoff') : S.filter === 'steps' ? !(k === 'steps' || k === 'handoff') : !(li.dataset.p === '1');
++ });
++ }
++ // The Files tab: one row per file, newest change last; a tap shows everything and opens that file's last step.
++ function changedFiles() {
++ const by = new Map();
++ for (const st of S.steps.values()) {
++ if (st.hidden || (st.kind !== 'edit' && st.kind !== 'write') || !st.info) continue;
++ const p = st.info.sub || st.info.title;
++ const f = by.get(p) || { path: p, added: 0, removed: 0, n: 0, last: null, wrote: false };
++ f.added += st.info.added || 0; f.removed += st.info.removed || 0; f.n++; f.last = st; f.wrote = f.wrote || st.kind === 'write';
++ by.set(p, f);
++ }
++ return [...by.values()];
++ }
++ function paintFiles() {
++ filesEl.textContent = '';
++ const list = changedFiles();
++ if (!list.length) { filesEl.appendChild(el('li', 'ovas-empty', 'No files changed yet.')); return; }
++ for (const f of list) {
++ const li = el('li');
++ const b = el('button', 'ovas-file'); b.type = 'button'; b.dataset.step = f.last.id;
++ b.innerHTML = `<span class="ovas-kind" data-k="${f.wrote ? 'write' : 'edit'}">${svg(f.wrote ? 'write' : 'edit')}</span><span class="ovas-sum"><b></b><small></small></span><span class="ovas-delta"><span class="a">+${f.added}</span><span class="r">−${f.removed}</span></span><span class="ovas-file-n"></span>`;
++ b.querySelector('b').textContent = base(f.path) || f.path;
++ b.querySelector('small').textContent = f.path;
++ b.querySelector('.ovas-file-n').textContent = f.n > 1 ? `${f.n} changes` : '';
++ li.appendChild(b); filesEl.appendChild(li);
++ }
++ }
++ filesEl.addEventListener('click', (e) => {
++ const b = e.target.closest('.ovas-file'); if (!b) return;
++ const st = S.steps.get(b.dataset.step); if (!st || !st.el) return;
++ S.filter = 'all'; bar.querySelectorAll('[data-f]').forEach((x) => x.setAttribute('aria-pressed', String(x.dataset.f === 'all'))); applyFilter();
++ for (let p = st.el; p; p = p.parentElement) if (p.tagName === 'DETAILS') p.open = true;
++ if (st.el.scrollIntoView) st.el.scrollIntoView({ block: 'center', behavior: 'smooth' });
++ });
++
++ // The action bar: what the person can do now (the page decides; this only draws and reports the press).
++ function paintActions(list, note) {
++ actBar.textContent = '';
++ const acts = (Array.isArray(list) ? list : []).filter((a) => a && a.id && a.label).slice(0, 4);
++ actBar.hidden = !acts.length;
++ if (!acts.length) return;
++ if (note) actBar.appendChild(el('p', '', note));
++ for (const a of acts) { const b = el('button', `ovas-act${a.kind === 'primary' ? ' is-primary' : a.kind === 'danger' ? ' is-danger' : ''}`, a.label); b.type = 'button'; b.dataset.act = a.id; actBar.appendChild(b); }
++ }
++ actBar.addEventListener('click', async (e) => {
++ const b = e.target.closest('.ovas-act'); if (!b || typeof opts.onAction !== 'function') return;
++ const all = [...actBar.querySelectorAll('.ovas-act')]; all.forEach((x) => { x.disabled = true; });
++ try { await opts.onAction(b.dataset.act); } catch { /* the page says what went wrong */ } finally { all.forEach((x) => { x.disabled = false; }); }
++ });
++ if (Array.isArray(opts.actions)) paintActions(opts.actions, opts.actionsNote);
++
++ const keep = () => { if (S.follow && S.status === 'running') { const last = feed.lastElementChild; if (last && last.scrollIntoView) last.scrollIntoView({ block: 'end', behavior: 'smooth' }); } };
++
++ // A step's element: <details> with the summary row and, once opened, its detail.
++ function stepEl(step) {
++ const d = el('details', 'ovas-step');
++ d.dataset.id = step.id;
++ const sum = el('summary');
++ const info = describe(step); step.info = info;
++ sum.innerHTML = `${svg('chevron', 'ovas-chev')}<span class="ovas-kind" data-k="${esc(step.kind)}">${svg(step.kind)}</span><span class="ovas-sum"><b></b><small></small></span>${info.added || info.removed ? `<span class="ovas-delta"><span class="a">+${info.added}</span><span class="r">−${info.removed}</span></span>` : ''}<span class="ovas-state" data-s="run"><span class="ovas-spin" aria-label="Running"></span><span data-t></span></span><span class="ovas-att-slot"></span>`;
++ sum.querySelector('b').textContent = info.title;
++ sum.querySelector('small').textContent = info.sub;
++ if (!info.sub) sum.querySelector('small').hidden = true;
++ d.appendChild(sum);
++ const detail = el('div', 'ovas-detail'); d.appendChild(detail);
++ const section = (label, node, copy) => { const l = el('div', 'ovas-label'); l.appendChild(el('span', '', label)); if (copy) { const c = el('button', 'ovas-copy'); c.type = 'button'; c.innerHTML = `${svg('copy')}<span>Copy</span>`; l.appendChild(c); } detail.append(l, node); };
++ if (step.kind === 'command' && info.command) { const pre = el('pre', 'ovas-code'); pre.textContent = info.command; section('Command', pre, true); }
++ if (info.diff) { const pre = el('pre', 'ovas-diff'); info.diff.slice(0, 600).forEach(([op, line]) => { const s = el('span', op === '+' ? 'a' : op === '-' ? 'r' : op === '@' ? 'h' : '', `${op === '=' ? ' ' : op === '@' ? '' : op} ${line}`); pre.appendChild(s); }); section(step.kind === 'write' ? 'New file' : 'Change', pre, false); }
++ if (step.kind === 'agent' && info.preview) { const p = el('div', 'ovas-msg'); p.appendChild(markdown(info.preview.slice(0, 1600), linkOk)); section('Asked to', p, false); }
++ if (step.kind !== 'plan' && step.input && typeof step.input === 'object' && Object.keys(step.input).length) {
++ const raw = el('details', 'ovas-raw');
++ raw.innerHTML = `<summary>${svg('chevron', 'ovas-chev')}<span>Input</span></summary>`;
++ const l = el('div', 'ovas-label'); l.appendChild(el('span', '', 'What the agent sent')); const c = el('button', 'ovas-copy'); c.type = 'button'; c.innerHTML = `${svg('copy')}<span>Copy</span>`; l.appendChild(c);
++ let text = ''; try { text = JSON.stringify(step.input, null, 2); } catch { text = String(step.input); }
++ const pre = el('pre', 'ovas-code'); pre.textContent = text.length > 8000 ? `${text.slice(0, 8000)}\n…` : text;
++ raw.append(l, pre); detail.appendChild(raw);
++ }
++ if (step.kind === 'agent') { const sub = el('div', 'ovas-sub'); step.subEl = sub; d.appendChild(sub); d.open = true; }
++ if (step.kind !== 'agent' && step.kind !== 'plan') {
++ const pre = el('pre', 'ovas-out'); pre.hidden = true; step.outEl = pre;
++ const wait = el('div', 'ovas-wait'); wait.innerHTML = '<span class="ovas-spin"></span><span>Running — the output shows here as it arrives</span>'; step.waitEl = wait;
++ const l = el('div', 'ovas-label'); l.appendChild(el('span', '', 'Output')); const c = el('button', 'ovas-copy'); c.type = 'button'; c.innerHTML = `${svg('copy')}<span>Copy</span>`; l.appendChild(c); step.outLabel = l;
++ detail.append(l, pre, wait);
++ }
++ step.el = d; step.stateEl = sum.querySelector('.ovas-state');
++ return d;
++ }
++ function setStepState(step) {
++ const st = step.stateEl; if (!st) return;
++ const t = step.ended ? dur(step.ended - step.started) : dur(Date.now() - step.started);
++ if (step.done) {
++ st.dataset.s = step.ok ? 'ok' : 'fail';
++ const c = step.cost > 0 ? `$${step.cost < 0.01 ? step.cost.toFixed(4).replace(/0+$/, '') : step.cost.toFixed(2)}` : '';
++ st.innerHTML = `${c ? `<span class="ovas-cost">${esc(c)}</span>` : ''}${svg(step.ok ? 'ok' : 'fail')}<span>${esc(step.ended - step.started > 1500 ? t : '')}</span>`;
++ } else { const tt = st.querySelector('[data-t]'); if (tt && Date.now() - step.started > 2000) tt.textContent = t; }
++ }
++ // The group a new step goes in: the last item of its feed when that is a group of steps, else a new one.
++ function groupFor(parentFeed) {
++ const last = parentFeed.lastElementChild;
++ if (last && last.classList.contains('ovas-group-li')) return last;
++ const li = el('li', 'ovas-group-li'); li.dataset.k = 'steps';
++ const g = el('details', 'ovas-group');
++ g.innerHTML = `<summary>${svg('chevron', 'ovas-chev')}<span class="ovas-icons"></span><span class="ovas-sum"><b></b><small></small></span><span class="ovas-delta" hidden><span class="a"></span><span class="r"></span></span><span class="ovas-state"></span><span class="ovas-att-slot"></span></summary><ol></ol>`;
++ li.appendChild(g); parentFeed.appendChild(li);
++ li.steps = [];
++ return li;
++ }
++ function paintGroup(li) {
++ const steps = li.steps, g = li.firstChild, count = {};
++ steps.forEach((s) => { count[s.kind] = (count[s.kind] || 0) + 1; });
++ const order = ['command', 'edit', 'write', 'read', 'search', 'web', 'agent', 'plan', 'tool'];
++ const kinds = order.filter((k) => count[k]);
++ g.querySelector('.ovas-icons').innerHTML = kinds.slice(0, 3).map((k) => `<span class="ovas-kind" data-k="${k}">${svg(k)}</span>`).join('');
++ // A few steps read as what was done ("Ran 2 commands"); more as counts ("2 commands · 1 edit · 3 reads").
++ // Steps the agent named itself (AG-UI metadata.ov.title) read as its words: the newest title and how many there are.
++ const titled = steps.filter((s2) => s2.title);
++ // Rows under one shared title say what each one did instead of the title again: its address, query or file.
++ const sharedTitle = titled.length > 1 && titled.every((s2) => s2.title === titled[0].title);
++ for (const s2 of steps) {
++ const b2 = s2.el && s2.el.querySelector(':scope>summary .ovas-sum b');
++ if (!b2 || !s2.info) continue;
++ // An address reads as its page and its site ("OpenVibe.Codes · github.com"), anything else as it is.
++ let own = String(s2.info.sub || '');
++ try { if (/^https?:\/\//.test(own)) { const u = new URL(own); const leaf = decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() || ''); own = leaf ? `${leaf} · ${u.hostname.replace(/^www\./, '')}` : u.hostname.replace(/^www\./, ''); } } catch { /* as it is */ }
++ b2.textContent = sharedTitle && own ? own : s2.info.title;
++ }
++ g.querySelector('.ovas-sum b').textContent = titled.length
++ ? `${titled[titled.length - 1].title}${steps.length > 1 ? ` · ${steps.length}` : ''}`
++ : kinds.length === 1 ? plural(kinds[0], count[kinds[0]]) : kinds.map((k) => `${count[k]} ${NOUN[k][count[k] === 1 ? 0 : 1]}`).join(' · ');
++ const running = steps.find((s) => !s.done) || steps[steps.length - 1];
++ const small = g.querySelector('.ovas-sum small');
++ small.textContent = running ? (running.info.sub || running.info.title) : '';
++ const added = steps.reduce((n, s) => n + (s.info.added || 0), 0), removed = steps.reduce((n, s) => n + (s.info.removed || 0), 0);
++ const delta = g.querySelector('.ovas-delta'); delta.hidden = !(added || removed); delta.firstChild.textContent = `+${added}`; delta.lastChild.textContent = `−${removed}`;
++ const st = g.querySelector('.ovas-state'), fails = steps.filter((s) => s.done && !s.ok).length, live = steps.some((s) => !s.done);
++ st.dataset.s = live ? 'run' : fails ? 'fail' : 'ok';
++ st.innerHTML = live ? '<span class="ovas-spin" aria-label="Running"></span>' : fails ? `${svg('fail')}<span>${fails} failed</span>` : svg('ok');
++ if (fails) li.dataset.p = '1';
++ // One step alone reads as itself, not as a group of one.
++ g.classList.toggle('is-one', steps.length === 1);
++ if (steps.length === 1) g.open = true;
++ // The group's attachments, in its header row, so they show while the group is folded.
++ const atts = [], seenAtt = new Set();
++ for (const s of steps) for (const a of (s.attachments || [])) { if (!seenAtt.has(a.url)) { seenAtt.add(a.url); atts.push(a); } }
++ const slot = g.querySelector('.ovas-att-slot');
++ slot.textContent = '';
++ // A group of one hides its header, so there the step's own row carries the attachments instead.
++ if (atts.length && steps.length > 1) slot.appendChild(attStrip(atts, openSheet));
++ // A group of one shows the step's own row; there the attachments belong to the step.
++ for (const s of steps) {
++ const ss = s.el && s.el.querySelector(':scope>summary .ovas-att-slot');
++ if (!ss) continue;
++ ss.textContent = '';
++ if (steps.length === 1 && s.attachments && s.attachments.length) ss.appendChild(attStrip(s.attachments, openSheet));
++ }
++ }
++ function feedFor(parent) { const p = parent && S.steps.get(parent); return p && p.subEl ? p.subEl : feed; }
++ function appendLi(target, li) { if (target === feed) { if (empty.parentNode) empty.remove(); feed.appendChild(li); } else target.appendChild(li); }
++
++ const pending = new Map(); // tool name → ids of its steps still waiting for a result (events without ids)
++ // Any line may carry attachments, and a line of a type this component does not know still shows: a minimal
++ // row with its label or summary, then its attachments (Codes' proof lines, for one).
++ const KNOWN = new Set(['agent', 'text', 'tool', 'tool_progress', 'tool_result', 'handoff', 'note', 'step', 'usage', 'done', 'state',
++ 'phase', 'phases', 'activity', 'thinking', 'thinking_delta', 'text_start', 'text_delta', 'tool_args', 'sources']);
++ function extraLine(x, target) {
++ const li = el('li', 'ovas-extra');
++ li.dataset.k = 'steps'; // it is part of the work, so the Steps filter keeps it
++ const label = String(x.label || x.kind || '');
++ const summary = String(x.summary || x.text || '');
++ const text = label && summary && label !== summary ? label + ' — ' + summary : (summary || label);
++ if (text) li.appendChild(el('span', '', text));
++ const atts = safeAtts(x.attachments);
++ if (atts.length) li.appendChild(attStrip(atts, openSheet));
++ appendLi(target, li);
++ }
++ function onLine(x) {
++ const target = feedFor(x.parent);
++ if (!KNOWN.has(x.t)) { extraLine(x, target); return; }
++ // Who is working: a line that names a different agent than the last moves the head along.
++ // A done line reports an agent's cost and time; it never moves the head.
++ if (x.agent && x.t !== 'agent' && x.t !== 'handoff' && x.t !== 'done' && (!S.agents.length || S.agents[S.agents.length - 1].agent !== x.agent)) { S.agents.push({ agent: x.agent }); paintHead(); }
++ if (x.t === 'tool' && !x.id) { x = { ...x, id: `a${S.steps.size + 1}` }; if (!pending.has(x.name)) pending.set(x.name, []); pending.get(x.name).push(x.id); }
++ if (x.t === 'tool_result' && !x.id) { const q = pending.get(x.name); x = { ...x, id: q && q.length ? q.shift() : '' }; }
++ if (x.t === 'state') { status(x.state); return; }
++ if (x.t === 'agent') {
++ const last = S.agents[S.agents.length - 1];
++ if (last && last.agent === x.agent) { if (x.model) last.model = x.model; } else S.agents.push({ agent: x.agent, model: x.model });
++ paintHead(); return;
++ }
++ if (x.t === 'text') {
++ const li = el('li', 'ovas-msg'); li.dataset.k = 'msg'; li.appendChild(markdown(x.text, linkOk));
++ const atts = safeAtts(x.attachments);
++ if (atts.length) li.appendChild(attStrip(atts, openSheet));
++ appendLi(target, li); return;
++ }
++ if (x.t === 'tool') {
++ const kind = x.kind && VERB[x.kind] ? x.kind : kindOf(x.name);
++ const step = { id: x.id || `s${S.steps.size}`, name: x.name, kind, input: x.input || {}, what: x.what || '', title: x.title || '', started: Date.now(), done: false, ok: null, out: '', attachments: safeAtts(x.attachments) };
++ if (kind === 'plan') { if (Array.isArray(step.input.todos)) { S.ownPlan = true; S.plan = step.input.todos; paintPlan(); } step.hidden = true; S.steps.set(step.id, step); return; }
++ S.steps.set(step.id, step);
++ const g = groupFor(target === feed ? feed : target);
++ if (target === feed && empty.parentNode) empty.remove();
++ g.steps.push(step);
++ g.firstChild.querySelector('ol').appendChild(stepEl(step));
++ paintGroup(g); step.group = g; paintHead(); return;
++ }
++ if (x.t === 'phase' || x.t === 'phases' || x.t === 'activity') { onPhase(x, target); return; }
++ if (x.t === 'thinking' || x.t === 'thinking_delta') { onThinking(x, target); return; }
++ if (x.t === 'text_start' || x.t === 'text_delta') {
++ let m = S.msgs.get(x.id);
++ if (!m) { const li = el('li', 'ovas-msg'); li.dataset.k = 'msg'; m = { li, text: '' }; S.msgs.set(x.id, m); appendLi(target, li); }
++ if (x.t === 'text_delta') { m.text = (m.text + x.delta).slice(0, MAX_TEXT); m.li.textContent = ''; m.li.appendChild(markdown(m.text, linkOk)); }
++ return;
++ }
++ if (x.t === 'tool_args') {
++ const step = S.steps.get(String(x.id)); if (!step) return;
++ step.argText = (step.argText || '') + x.delta;
++ try { step.input = JSON.parse(step.argText); step.info = describe(step); if (step.group) paintGroup(step.group); } catch { /* not whole yet */ }
++ return;
++ }
++ if (x.t === 'sources') { addSources(x.items); return; }
++ if (x.t === 'tool_progress') {
++ const step = S.steps.get(String(x.id)); if (!step || !step.outEl) return;
++ step.out = (step.out + x.chunk).slice(-20000); step.outEl.textContent = step.out; step.outEl.hidden = false; step.waitEl.hidden = true;
++ step.outEl.scrollTop = step.outEl.scrollHeight; return;
++ }
++ if (x.t === 'tool_result') {
++ const step = S.steps.get(String(x.id)); if (!step || step.hidden) return;
++ // A result may carry the files the step produced; the group's header shows them.
++ const extra = safeAtts(x.attachments);
++ if (extra.length) {
++ const have = step.attachments || (step.attachments = []);
++ for (const a of extra) if (!have.some((b) => b.url === a.url)) have.push(a);
++ }
++ step.done = true; step.ok = x.ok !== false; step.ended = x.duration_ms > 0 ? step.started + Number(x.duration_ms) : Date.now();
++ if (Number(x.cost_usd) > 0) step.cost = Number(x.cost_usd);
++ if (step.outEl) {
++ const out = String(x.output == null ? '' : x.output) || step.out || '';
++ step.waitEl.hidden = true;
++ if (out.trim()) { step.outEl.textContent = out; step.outEl.hidden = false; step.outEl.classList.toggle('is-fail', !step.ok); }
++ else { step.outEl.hidden = true; step.outLabel.hidden = true; }
++ }
++ if (!step.ok) { S.problems++; if (step.el) step.el.open = true; }
++ setStepState(step); if (step.group) paintGroup(step.group); paintHead(); return;
++ }
++ if (x.t === 'handoff') {
++ const li = el('li', 'ovas-handoff'); li.dataset.k = 'handoff'; li.dataset.p = '1';
++ const s = el('div', 'ovas-hand');
++ const row = el('div', 'ovas-hand-row');
++ const from = x.from || (S.agents.length ? S.agents[S.agents.length - 1].agent : '');
++ if (x.to && (!S.agents.length || S.agents[S.agents.length - 1].agent !== x.to)) S.agents.push({ agent: x.to });
++ row.appendChild(el('span', 'ovas-agent', agentName(from))); row.insertAdjacentHTML('beforeend', svg('handoff')); row.appendChild(el('span', 'ovas-agent is-now', x.to ? agentName(x.to) : 'the next agent'));
++ s.appendChild(row); if (x.reason) s.appendChild(el('small', '', x.reason));
++ const handAtts = safeAtts(x.attachments);
++ if (handAtts.length) s.appendChild(attStrip(handAtts, openSheet));
++ li.appendChild(s); appendLi(feed, li); S.problems++; paintHead(); return;
++ }
++ if (x.t === 'note') {
++ const li = el('li', 'ovas-note'); li.dataset.k = 'note'; li.dataset.level = x.level === 'warning' || x.level === 'info' ? x.level : 'error';
++ if (li.dataset.level !== 'info') li.dataset.p = '1';
++ li.innerHTML = svg(li.dataset.level === 'info' ? 'ok' : 'warn'); li.appendChild(el('span', '', x.text));
++ const noteAtts = safeAtts(x.attachments);
++ if (noteAtts.length) li.appendChild(attStrip(noteAtts, openSheet));
++ appendLi(target, li); if (li.dataset.level !== 'info') S.problems++; paintHead(); return;
++ }
++ if (x.t === 'step') {
++ const li = el('li', 'ovas-line'); li.dataset.k = 'line'; li.appendChild(el('span', '', x.text));
++ const lineAtts = safeAtts(x.attachments);
++ if (lineAtts.length) li.appendChild(attStrip(lineAtts, openSheet));
++ appendLi(feed, li); return;
++ }
++ if (x.t === 'usage') { S.tokens.in += Number(x.input_tokens) || 0; S.tokens.out += Number(x.output_tokens) || 0; return; }
++ if (x.t === 'done') {
++ if (x.parent) return;
++ S.cost += Number(x.cost_usd) || 0; S.durationMs += Number(x.duration_ms) || 0;
++ const who = x.agent || (S.agents[S.agents.length - 1] || {}).agent;
++ if (who && Number(x.cost_usd) > 0) S.agentCost.set(who, (S.agentCost.get(who) || 0) + Number(x.cost_usd));
++ const li = el('li', 'ovas-done'); li.dataset.k = 'done'; li.dataset.ok = x.ok ? '1' : '0';
++ const b = el('b'); b.innerHTML = svg(x.ok ? 'ok' : 'fail'); b.appendChild(document.createTextNode(`${agentName((S.agents[S.agents.length - 1] || {}).agent)} ${x.ok ? 'finished' : 'stopped'}${x.duration_ms ? ` in ${dur(x.duration_ms)}` : ''}`));
++ li.appendChild(b);
++ if (x.text) { const m = el('div', 'ovas-msg'); m.appendChild(markdown(x.text, linkOk)); li.appendChild(m); }
++ const doneAtts = safeAtts(x.attachments);
++ if (doneAtts.length) li.appendChild(attStrip(doneAtts, openSheet));
++ if (!x.ok) li.dataset.p = '1';
++ appendLi(feed, li); paintHead();
++ }
++ }
++ // Phases: named parts of the run (AG-UI steps). The plan row in the head shows where the run is ("2 of 5"), and a
++ // phase that starts is a divider in the feed. A plan the agent keeps itself (TodoWrite) wins over phases.
++ function onPhase(x, target) {
++ if (x.t === 'activity') { S.activity = x.text; paintPlan(); return; }
++ if (x.t === 'phases') { S.phases = x.items.map((p, i) => ({ id: `p${i}`, title: p.title, status: p.status })); }
++ else {
++ const same = (a, b) => String(a || '').trim().toLowerCase() === String(b || '').trim().toLowerCase();
++ let p = S.phases.find((q) => q.id === x.id) || S.phases.find((q) => same(q.title, x.title || x.id));
++ if (!p) { p = { id: x.id, title: x.title || x.id, status: 'running' }; S.phases.push(p); }
++ if (x.title) p.title = x.title;
++ p.status = x.status;
++ if (x.status === 'running' && !p.li) {
++ const li = el('li', 'ovas-phase'); li.dataset.k = 'phase';
++ li.appendChild(el('span', 'ovas-phase-dot')); li.appendChild(el('b', '', p.title));
++ p.li = li; appendLi(target === feed ? feed : target, li);
++ }
++ if (p.li) { p.li.dataset.s = p.status; p.li.querySelector('b').textContent = p.title; }
++ }
++ if (!S.ownPlan) {
++ S.plan = S.phases.map((p) => ({ content: p.title, status: /done|complete/.test(p.status) ? 'completed' : /run|progress/.test(p.status) ? 'in_progress' : 'pending' }));
++ paintPlan();
++ }
++ }
++ // Thinking: folded to one line, "Thinking…" while it streams and "Thought for 1m 20s" after; its words open on a tap.
++ function onThinking(x, target) {
++ let th = S.thinks.get(x.id);
++ if (!th) {
++ const li = el('li', 'ovas-think'); li.dataset.k = 'msg';
++ const d = el('details'); const sm = el('summary'); sm.appendChild(el('span', 'ovas-think-dots'));
++ const label = el('span', '', 'Thinking…'); sm.appendChild(label); d.appendChild(sm);
++ const body = el('div', 'ovas-think-body'); d.appendChild(body); li.appendChild(d);
++ th = { li, label, body, text: '', started: Date.now() }; S.thinks.set(x.id, th); appendLi(target, li);
++ }
++ if (x.t === 'thinking_delta') { th.text = (th.text + x.delta).slice(-20000); th.body.textContent = th.text; }
++ if (x.hidden) th.body.textContent = 'This model keeps its thinking to itself.';
++ if (x.done || x.hidden) { th.li.classList.add('is-done'); th.label.textContent = `Thought for ${dur(x.duration_ms || Date.now() - th.started)}`; }
++ }
++ // Sources: every page the run read, once each, in one strip under the feed: the sites as letter tiles and a count;
++ // opened, the titles as links (https only, a new tab).
++ function addSources(items) {
++ for (const it of (items || []).slice(0, 200)) {
++ const url = it && String(it.url || '');
++ if (!/^https:\/\//.test(url) || S.sources.has(url)) continue;
++ let site = String(it.site || ''); try { if (!site) site = new URL(url).hostname.replace(/^www\./, ''); } catch { continue; }
++ S.sources.set(url, { url, site, title: cut(String(it.title || site), 160) });
++ }
++ paintSources();
++ }
++ function paintSources() {
++ const list = [...S.sources.values()];
++ srcBox.hidden = !list.length; if (!list.length) return;
++ const tiles = srcBox.querySelector('.ovas-src-tiles'); tiles.textContent = '';
++ const sites = [...new Set(list.map((s) => s.site))];
++ sites.slice(0, 5).forEach((site) => { const t = el('span', 'ovas-src-tile', site.charAt(0).toUpperCase()); t.title = site; let h = 0; for (const c of site) h = (h * 31 + c.charCodeAt(0)) % 360; t.style.setProperty('--h', String(h)); tiles.appendChild(t); });
++ srcBox.querySelector('.ovas-src-n').textContent = `${list.length} source${list.length === 1 ? '' : 's'}${sites.length > 1 ? ` from ${sites.length} sites` : ''}`;
++ const ol = srcBox.querySelector('ol'); ol.textContent = '';
++ list.forEach((s) => { const li = el('li'); const a = el('a', '', s.title); a.href = s.url; a.target = '_blank'; a.rel = 'noopener noreferrer'; li.appendChild(a); li.appendChild(el('small', '', s.site)); ol.appendChild(li); });
++ }
++ function push(event) { normalize(event).forEach((x) => { try { onLine(x); } catch { /* one odd line never stops the view */ } }); applyFilter(); keep(); }
++
++ function paintPlan() {
++ const box2 = head.querySelector('.ovas-plan'); const todos = (S.plan || []).filter((t) => t && (t.content || t.activeForm));
++ if (!todos.length && S.activity) { box2.hidden = false; box2.querySelector('.ovas-plan-now').textContent = S.activity; box2.querySelector('.ovas-plan-n').textContent = ''; box2.querySelector('i').style.width = '0%'; return; }
++ box2.hidden = !todos.length; if (!todos.length) return;
++ const doneN = todos.filter((t) => t.status === 'completed').length;
++ box2.querySelector('i').style.width = `${Math.round((doneN / todos.length) * 100)}%`;
++ const now = todos.find((t) => t.status === 'in_progress') || todos.find((t) => t.status !== 'completed');
++ box2.querySelector('.ovas-plan-now').textContent = now ? (now.status === 'in_progress' && now.activeForm ? now.activeForm : now.content || now.activeForm) : 'Plan done';
++ box2.querySelector('.ovas-plan-n').textContent = `${doneN} of ${todos.length}`;
++ const ol = box2.querySelector('ol'); ol.textContent = '';
++ todos.slice(0, 12).forEach((t) => { const li = el('li', '', t.status === 'in_progress' && t.activeForm ? t.activeForm : t.content || t.activeForm); li.dataset.s = t.status || 'pending'; ol.appendChild(li); });
++ }
++ function summary() {
++ const steps = [...S.steps.values()].filter((s) => !s.hidden);
++ const files = new Set(steps.filter((s) => s.kind === 'edit' || s.kind === 'write').map((s) => s.info && s.info.sub).filter(Boolean));
++ return {
++ agents: S.agents.map((a) => agentName(a.agent)), steps: steps.length, commands: steps.filter((s) => s.kind === 'command').length, files: files.size,
++ added: steps.reduce((n, s) => n + ((s.info && s.info.added) || 0), 0), removed: steps.reduce((n, s) => n + ((s.info && s.info.removed) || 0), 0),
++ problems: S.problems, costUsd: Math.round(S.cost * 1e4) / 1e4, costByAgent: Object.fromEntries([...S.agentCost].map(([k, v]) => [agentName(k), Math.round(v * 1e4) / 1e4])), durationMs: S.durationMs || (clock ? (S.endedAt || Date.now()) - S.startedAt : 0), tokens: { ...S.tokens },
++ };
++ }
++ const LABEL = { queued: 'Waiting', placed: 'Starting', running: 'Running', verifying: 'Checking the answer', succeeded: 'Finished', failed: 'Failed', cancelled: 'Cancelled', expired: 'Expired' };
++ function paintHead() {
++ const sm = summary();
++ const pill = head.querySelector('.ovas-pill');
++ pill.dataset.s = S.status;
++ pill.innerHTML = `${S.status === 'running' || S.status === 'placed' || S.status === 'verifying' ? '<span class="ovas-spin"></span>' : S.status === 'succeeded' ? svg('ok') : S.status === 'failed' || S.status === 'expired' ? svg('fail') : ''}<span></span>`;
++ pill.lastChild.textContent = S.label || LABEL[S.status] || S.status;
++ const ag = head.querySelector('.ovas-agents'); ag.textContent = '';
++ const shown = new Set();
++ S.agents.forEach((a, i) => {
++ if (i) ag.appendChild(el('span', '', '→'));
++ const c = el('span', `ovas-agent${i === S.agents.length - 1 ? ' is-now' : ''}`, agentName(a.agent)); if (a.model) c.title = a.model;
++ // An agent's cost once, on its last chip (an agent may come back after a hand-off).
++ const cost = S.agentCost.get(a.agent);
++ if (cost && !shown.has(a.agent) && !S.agents.slice(i + 1).some((b) => b.agent === a.agent)) { shown.add(a.agent); c.appendChild(el('span', 'ovas-agent-cost', `$${cost < 0.01 ? cost.toFixed(4).replace(/0+$/, '') : cost.toFixed(2)}`)); }
++ ag.appendChild(c);
++ });
++ const fc = bar.querySelector('[data-f="files"]'); if (fc) fc.textContent = sm.files ? `Files ${sm.files}` : 'Files';
++ const m = (k, html) => { const n = head.querySelector(`[data-m="${k}"]`); n.innerHTML = html; n.hidden = !html; };
++ // What it is doing now (the portal's sticky line, 2026-11-11): the newest step that has not ended.
++ const nowStep = LIVE.includes(S.status) ? [...S.steps.values()].reverse().find((st) => !st.done && !st.hidden) : null;
++ m('now', nowStep && nowStep.info ? `<span class="ovas-now">${esc(String(nowStep.info.command || nowStep.info.title || '').split('\n')[0].slice(0, 90))}</span>` : '');
++ m('time', sm.durationMs ? `<b>${esc(dur(sm.durationMs))}</b>` : '');
++ m('steps', sm.steps ? `<b>${sm.steps}</b> step${sm.steps === 1 ? '' : 's'}${sm.commands ? ` · <b>${sm.commands}</b> command${sm.commands === 1 ? '' : 's'}` : ''}` : '');
++ m('files', sm.files ? `<b>${sm.files}</b> file${sm.files === 1 ? '' : 's'} <span style="color:var(--ovas-ok)">+${sm.added}</span> <span style="color:var(--ovas-bad)">−${sm.removed}</span>` : '');
++ m('cost', sm.costUsd ? `<b>$${sm.costUsd.toFixed(sm.costUsd < 1 ? 3 : 2)}</b>` : '');
++ }
++ function status(s, label) {
++ S.status = s; S.label = label || '';
++ working.hidden = !LIVE.includes(s);
++ if (!LIVE.includes(s)) { S.endedAt = S.endedAt || Date.now(); empty.textContent = 'Nothing was recorded for this run.'; }
++ paintHead();
++ }
++ // Talking to the run while it works (opts.onSend(text, { files, uploads }) → a promise; the product sends it to its
++ // agent: Codes' job message, Actor's task input, the portal's session). The box is the one composer on every site
++ // (owner, 2026-11-11: "uses our custom modular composer across all sites"): files by picker, paste and drop, "/"
++ // commands and the person's own, drafts, voice and their settings; loaded from beside this file when the page has
++ // not loaded it. opts.composer: false keeps a plain box.
++ if (typeof opts.onSend === 'function') {
++ // The one composer's markup (thread.js composer()): the box, then a row with its tools and Send, then its note,
++ // so ov-composer.js puts its + menu (files), "/" commands, voice and settings where every site has them.
++ const form = el('form', 'ovas-say ovt-composer'); form.setAttribute('data-ovt-composer', '');
++ const ta = el('textarea'); ta.name = 'text'; ta.rows = 1; ta.maxLength = 4000; ta.placeholder = opts.sayPlaceholder || 'Message the agent'; ta.setAttribute('aria-label', 'Message the agent'); ta.setAttribute('data-ovt-text', '');
++ const row = el('div', 'ovt-c-row'); const toolsEl = el('div', 'ovt-c-tools');
++ const send = el('button', 'ovas-say-send ovt-send'); send.type = 'submit'; send.setAttribute('aria-label', 'Send'); send.setAttribute('data-ovt-send', ''); send.innerHTML = svg('handoff');
++ const noteEl = el('p', 'ovt-c-note'); noteEl.setAttribute('data-ovt-note', ''); noteEl.setAttribute('role', 'status');
++ row.appendChild(toolsEl); row.appendChild(send);
++ // A file input makes the box one that takes files (the composer's + menu, paste and drop); the files go to onSend.
++ if (opts.files !== false) {
++ // The shared "+" (thread.js attachHtml): a label holding the file input, first in the tools row.
++ const plus = el('label', 'ovt-btn ovt-attach'); plus.title = 'Attach files';
++ plus.innerHTML = '<svg class="ovt-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span class="ovt-sr">Attach files</span>';
++ const fi = el('input'); fi.type = 'file'; fi.name = 'files'; fi.multiple = true; fi.hidden = true; fi.setAttribute('data-ovt-files', ''); if (opts.accept) fi.accept = String(opts.accept);
++ plus.appendChild(fi); toolsEl.insertBefore(plus, toolsEl.firstChild);
++ }
++ form.appendChild(ta); form.appendChild(row); form.appendChild(noteEl);
++ box.appendChild(form);
++ ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit ? form.requestSubmit() : form.dispatchEvent(new Event('submit', { cancelable: true })); } });
++ form.addEventListener('submit', async (e) => {
++ e.preventDefault();
++ const ovc = form.__ovc;
++ const files = ovc ? ovc.files() : [], uploads = ovc && ovc.uploads ? ovc.uploads() : [];
++ const text = ta.value.trim(); if ((!text && !files.length) || form.classList.contains('is-busy')) return;
++ form.classList.add('is-busy');
++ const li = el('li', 'ovas-you'); li.dataset.k = 'msg'; li.appendChild(el('span', '', text));
++ if (files.length) li.appendChild(el('small', 'ovas-you-files', files.map((f) => f.name || 'a file').join(' · ')));
++ appendLi(feed, li); keep();
++ try { await opts.onSend(text, { files, uploads }); if (ovc && ovc.clear) ovc.clear(); else ta.value = ''; li.classList.add('is-sent'); }
++ catch (err) { li.classList.add('is-failed'); li.title = (err && err.message) || 'Not sent'; }
++ finally { form.classList.remove('is-busy'); }
++ });
++ if (opts.composer !== false) {
++ form.id = form.id || `ovas-say-${Math.random().toString(36).slice(2, 8)}`;
++ const join = () => { try { if (root.OpenVibeComposer && form.isConnected) root.OpenVibeComposer.enhance(form); } catch { /* a plain box still sends */ } };
++ if (root.OpenVibeComposer) join();
++ else {
++ const m = /^(https?:\/\/[^?#]*\/)agent-session(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(SELF_SRC);
++ if (m) {
++ let sc = document.getElementById('ov-composer-script');
++ if (!sc) { sc = document.createElement('script'); sc.id = 'ov-composer-script'; sc.async = true; sc.src = m[1] + 'ov-composer.js'; document.head.appendChild(sc); }
++ sc.addEventListener('load', join, { once: true });
++ }
++ }
++ }
++ }
++ const tick = setInterval(() => { if (S.status === 'running' || S.status === 'placed') { S.steps.forEach((st) => { if (!st.done) setStepState(st); }); paintHead(); } }, 1000);
++ paintHead();
++ return { push, pushAll: (list) => (list || []).forEach(push), status, summary, actions: (list, note) => paintActions(list, note), files: () => changedFiles().map((f) => ({ path: f.path, added: f.added, removed: f.removed, changes: f.n })), destroy() { clearInterval(tick); host.textContent = ''; } };
++ }
++
++ const api = { mount, normalize, diffLines, kindOf };
++ root.OpenVibeAgentSession = api;
++ if (typeof module === 'object' && module.exports) module.exports = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this);
ddiff --git a/browser/shared/agent-sheet.js b/browser/shared/agent-sheet.js
nnew file mode 100644
iindex 0000000..97fb6eb
---- /dev/null
++++ b/browser/shared/agent-sheet.js
@@@ -0,0 +1,236 @@
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — an agent's sheet (openvibe-shared 4.211.0; Opus, 2026-11-11)
++//
++// One window (ov-window.js: a panel beside what opened it on a computer, a sheet on a phone) that says everything about
++// one agent: who it is and its state, what it is doing now, what it asks you, the work it did, when it runs, what it
++// remembers and its computer. The data is the agent's sheet (Contracts actor.agent-sheet@1 / codes.agent-sheet@1),
++// read through Network's same-origin relay with the person's token:
++// OVAgentSheet.open({ kind: 'actor' | 'codes', id, name, token, frameBase, base, from, openHref }) → { close() }
++// The parts are tabs (a segmented row; arrow keys move between them); a part with nothing in it says so in its own
++// words (asks_empty) rather than disappearing. While it reads, the window shows the mark's wait; a failed read says so
++// with Retry. Everything an agent or a person wrote is set as text, never markup.
++// ═══════════════════════════════════════════════════════════════
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OVAgentSheet) return;
++
++ const CSS = `.ovas { display: grid; gap: 12px; padding: 4px 2px 6px; }
++.ovas-who { display: flex; align-items: center; gap: 12px; min-width: 0; }
++.ovas-face { flex: none; width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; overflow: hidden; font-weight: 800; font-size: 17px;
++ color: #fff; background: linear-gradient(135deg, var(--accent, #3b82f6), color-mix(in srgb, var(--accent, #3b82f6) 45%, #7c3aed)); box-shadow: 0 0 0 3px var(--ovas-ring, transparent); }
++.ovas-face img { width: 100%; height: 100%; object-fit: cover; }
++.ovas[data-state="working"] { --ovas-ring: color-mix(in srgb, #22c55e 55%, transparent); }
++.ovas[data-state="paused"] { --ovas-ring: color-mix(in srgb, #f59e0b 55%, transparent); }
++.ovas-name { display: grid; gap: 2px; min-width: 0; flex: 1; }
++.ovas-name b { font-size: 16px; overflow-wrap: anywhere; }
++.ovas-name small { color: var(--text-secondary, #a8b3c4); font-size: 12.5px; overflow-wrap: anywhere; }
++.ovas-state { display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 650; width: fit-content;
++ background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }
++.ovas-state::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ovas-dot, #94a3b8); }
++.ovas[data-state="working"] .ovas-state { --ovas-dot: #22c55e; }
++.ovas[data-state="paused"] .ovas-state { --ovas-dot: #f59e0b; }
++.ovas-open { flex: none; display: inline-flex; align-items: center; min-height: 36px; padding: 0 12px; border-radius: 10px; color: var(--accent-light, #60a5fa);
++ font-size: 13px; font-weight: 650; text-decoration: none; }
++.ovas-open:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
++.ovas-tabs { display: flex; gap: 4px; padding: 4px; border-radius: 999px; overflow-x: auto; scrollbar-width: none; background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
++.ovas-tabs::-webkit-scrollbar { display: none; }
++@media (max-width: 520px) { .ovas-tabs { -webkit-mask-image: linear-gradient(90deg, #000 85%, transparent); mask-image: linear-gradient(90deg, #000 85%, transparent); } }
++.ovas-tab { flex: none; display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 0 13px; border: 0; border-radius: 999px; background: none;
++ color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13px; cursor: pointer; }
++.ovas-tab[aria-selected="true"] { background: var(--bg-primary, #0b1220); color: var(--text-primary, #e6edf7); font-weight: 650; box-shadow: 0 1px 3px rgba(0, 0, 0, .25); }
++.ovas-tab em { font-style: normal; font-size: 11px; font-weight: 700; padding: 0 6px; border-radius: 999px; background: color-mix(in srgb, #f59e0b 22%, transparent); color: #fbbf24; }
++.ovas-tab:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
++.ovas-part { display: grid; align-content: start; gap: 8px; min-height: 90px; }
++.ovas-part[hidden] { display: none; }
++.ovas-empty { margin: 0; padding: 18px 8px; text-align: center; color: var(--text-secondary, #a8b3c4); font-size: 13.5px; }
++.ovas-card { display: grid; gap: 4px; padding: 11px 13px; border-radius: 14px; border: 1px solid var(--border, rgba(255, 255, 255, .1));
++ background: color-mix(in srgb, var(--text-primary, #e6edf7) 3%, transparent); min-width: 0; }
++.ovas-card b { font-size: 14px; font-weight: 650; overflow-wrap: anywhere; }
++.ovas-card p { margin: 0; color: var(--text-secondary, #a8b3c4); font-size: 13px; overflow-wrap: anywhere; }
++.ovas-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; color: var(--text-muted, #7d8aa0); font-size: 12px; font-variant-numeric: tabular-nums; }
++.ovas-chip { display: inline-flex; align-items: center; padding: 1px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 650;
++ background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); color: var(--text-primary, #e6edf7); }
++.ovas-chip.is-ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #4ade80; }
++.ovas-chip.is-bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #f87171; }
++.ovas-chip.is-run { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, #60a5fa); }
++.ovas-card.is-now { border-color: color-mix(in srgb, #22c55e 45%, transparent); }
++.ovas-card.is-ask { border-color: color-mix(in srgb, #f59e0b 50%, transparent); background: color-mix(in srgb, #f59e0b 7%, transparent); }
++.ovas-go { justify-self: start; display: inline-flex; align-items: center; min-height: 40px; padding: 0 16px; border-radius: 12px; text-decoration: none; font-weight: 650; font-size: 14px;
++ color: #fff; background: var(--accent, #3b82f6); }
++.ovas-wait { display: grid; justify-items: center; gap: 10px; padding: 26px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13.5px; }
++.ovas-retry { min-height: 40px; padding: 0 16px; border-radius: 12px; border: 1px solid var(--border, rgba(255, 255, 255, .14)); background: none; color: inherit; font: inherit; cursor: pointer; }
++@media (pointer: coarse) { .ovas-tab { min-height: 44px; } .ovas-open { min-height: 44px; } }`;
++
++ function styles(d) {
++ if (d.getElementById('ovas-css')) return;
++ const s = d.createElement('style');
++ s.id = 'ovas-css';
++ s.textContent = CSS;
++ d.head.appendChild(s);
++ }
++ const money = (n) => (Number(n) > 0 ? `$${Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: Number(n) < 0.1 ? 4 : 2 })}` : '$0');
++ function when(iso) {
++ const t = Date.parse(iso || '');
++ if (!Number.isFinite(t)) return '';
++ const s = (t - Date.now()) / 1000;
++ const a = Math.abs(s);
++ const unit = a < 3600 ? [Math.max(1, Math.round(a / 60)), 'min'] : a < 86400 ? [Math.round(a / 3600), 'h'] : [Math.round(a / 86400), a < 172800 ? 'day' : 'days'];
++ if (a < 60) return s > 0 ? 'in a moment' : 'just now';
++ return s > 0 ? `in ${unit[0]} ${unit[1]}` : `${unit[0]} ${unit[1]} ago`;
++ }
++ const tone = (state) => (/^(done|succeeded|ok|passed|completed)$/i.test(state) ? 'is-ok' : /^(failed|error|refused|stopped)$/i.test(state) ? 'is-bad' : /^(running|working|started)$/i.test(state) ? 'is-run' : '');
++ const STATE_WORD = { working: 'Working', idle: 'Resting', paused: 'Paused', ready: 'Ready' };
++
++ function open(opts = {}) {
++ const W = root.OVWindow;
++ if (!W) throw new Error('ov-window.js is not loaded');
++ const d = document;
++ styles(d);
++ const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = String(text); return n; };
++ const kind = opts.kind === 'codes' ? 'codes' : 'actor';
++ const frameBase = String(opts.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
++ const aid = encodeURIComponent(String(opts.id || ''));
++ // Through Network's relay on a site; `base` reaches the service itself (its own site, or a test).
++ const direct = opts.base ? String(opts.base).replace(/\/+$/, '') : '';
++ const url = direct ? (kind === 'codes' ? `${direct}/api/v1/me/agents/${aid}/sheet` : `${direct}/api/v1/me/agents/${aid}?view=sheet`)
++ : `${frameBase}/${kind === 'codes' ? 'codes-agents' : 'agents'}/${aid}/sheet`;
++ const body = h('div', 'ovas');
++ // The window's title names what it is; the agent's own name leads the sheet under it (said once).
++ const win = W.open({ id: `agent-sheet-${kind}`, title: kind === 'codes' ? 'Coding agent' : 'Your Actor', body, kind: 'panel', size: 'md', from: opts.from || null,
++ onClose: () => { gone = true; } });
++ let gone = false;
++
++ function wait() {
++ const box = h('div', 'ovas-wait');
++ const mark = h('span', 'ov-mark');
++ mark.setAttribute('data-state', 'busy'); mark.setAttribute('data-size', '34'); mark.setAttribute('aria-hidden', 'true');
++ box.append(mark, h('span', '', 'Reading your agent…'));
++ body.replaceChildren(box);
++ }
++ function failed(text) {
++ const box = h('div', 'ovas-wait');
++ const retry = h('button', 'ovas-retry', 'Retry'); retry.type = 'button';
++ retry.addEventListener('click', load);
++ box.append(h('span', '', text), retry);
++ body.replaceChildren(box);
++ }
++
++ function card(cls, title, line, more) {
++ const c = h('div', `ovas-card${cls ? ` ${cls}` : ''}`);
++ c.append(h('b', '', title));
++ if (more) c.append(h('p', '', more));
++ if (line && line.childNodes.length) c.append(line);
++ return c;
++ }
++ function workCard(w, isNow) {
++ const line = h('div', 'ovas-line');
++ if (w.state) line.append(h('span', `ovas-chip ${tone(w.state)}`, w.state));
++ if (Number.isFinite(Number(w.cost_usd))) line.append(h('span', '', money(w.cost_usd)));
++ if (w.updated_at || w.started_at || w.at) line.append(h('span', '', when(w.updated_at || w.started_at || w.at)));
++ const result = typeof w.result === 'string' ? w.result : w.result && (w.result.summary || w.result.text) ? (w.result.summary || w.result.text) : '';
++ return card(isNow ? 'is-now' : '', w.task || 'A task', line, result ? String(result).slice(0, 400) : '');
++ }
++
++ function render(sheet) {
++ const a = sheet.agent || {};
++ body.dataset.state = a.state || '';
++ const who = h('div', 'ovas-who');
++ const face = h('span', 'ovas-face');
++ if (a.face && /^https:\/\//.test(a.face)) { const img = h('img'); img.src = a.face; img.alt = ''; face.append(img); } else face.textContent = String(a.name || opts.name || 'A').trim().charAt(0).toUpperCase();
++ const name = h('span', 'ovas-name');
++ name.append(h('b', '', a.name || opts.name || 'Your agent'), h('span', 'ovas-state', STATE_WORD[a.state] || a.state || 'Ready'));
++ if (a.instructions) name.append(h('small', '', String(a.instructions).slice(0, 160)));
++ else if (a.kind) name.append(h('small', '', a.kind));
++ who.append(face, name);
++ if (opts.openHref) { const go = h('a', 'ovas-open', 'Open ↗'); go.href = opts.openHref; go.target = '_blank'; go.rel = 'noopener'; who.append(go); }
++
++ const asks = Array.isArray(sheet.asks) ? sheet.asks : [];
++ const work = Array.isArray(sheet.work) ? sheet.work : [];
++ const parts = [
++ ['now', 'Now', () => (sheet.now ? [workCard(sheet.now, true)] : [h('p', 'ovas-empty', a.state === 'paused' ? 'Paused. Nothing runs until you resume it.' : 'Nothing running right now.')])],
++ ['asks', 'Asks', () => (asks.length ? asks.map((x) => {
++ const line = h('div', 'ovas-line');
++ if (x.state) line.append(h('span', 'ovas-chip', x.state));
++ if (x.touch && typeof x.touch === 'string') line.append(h('span', '', x.touch));
++ return card('is-ask', x.what || 'A request', line, x.why || '');
++ }) : [h('p', 'ovas-empty', sheet.asks_empty || 'Nothing to approve.')]), asks.length],
++ ['work', 'Work', () => (work.length ? work.map((w) => workCard(w, false)) : [h('p', 'ovas-empty', 'No work yet.')])],
++ ['when', 'Schedule', () => {
++ const rows = (Array.isArray(sheet.schedules) ? sheet.schedules : []).filter((s) => s && s.schedule !== 'off');
++ if (!rows.length) return [h('p', 'ovas-empty', 'It runs when you ask, on no schedule.')];
++ return rows.map((s) => {
++ const line = h('div', 'ovas-line');
++ if (s.next_run_at) line.append(h('span', '', `Next ${when(s.next_run_at)}`));
++ if (s.last_run_at) line.append(h('span', '', `Last ${when(s.last_run_at)}`));
++ if (s.last_result) line.append(h('span', `ovas-chip ${tone(s.last_result)}`, s.last_result));
++ return card('', String(s.schedule || 'On a schedule'), line, '');
++ });
++ }],
++ ['memory', 'Memory', () => {
++ const rows = Array.isArray(sheet.memory) ? sheet.memory : [];
++ return rows.length ? rows.slice(0, 30).map((m) => { const line = h('div', 'ovas-line'); if (m.source) line.append(h('span', '', m.source)); return card('', String(m.text || '').slice(0, 300), line, ''); }) : [h('p', 'ovas-empty', 'Nothing remembered yet.')];
++ }],
++ ['computer', 'Computer', () => {
++ const c = sheet.computer;
++ if (!c) return [h('p', 'ovas-empty', 'No computer is attached to this agent.')];
++ const out = [card('', c.name || 'Its computer', null, c.state || c.status || '')];
++ const href = [c.url, c.open].find((u) => typeof u === 'string' && /^https:\/\//.test(u));
++ if (href) { const go = h('a', 'ovas-go', 'Watch its computer'); go.href = href; go.target = '_blank'; go.rel = 'noopener'; out.push(go); }
++ return out;
++ }],
++ ];
++ const tabs = h('div', 'ovas-tabs');
++ tabs.setAttribute('role', 'tablist');
++ tabs.setAttribute('aria-label', 'About this agent');
++ const panes = [];
++ const first = asks.length ? 'asks' : 'now';
++ parts.forEach(([id, label, build, count]) => {
++ const tab = h('button', 'ovas-tab', label);
++ tab.type = 'button';
++ tab.setAttribute('role', 'tab');
++ tab.id = `ovas-t-${id}`;
++ tab.setAttribute('aria-controls', `ovas-p-${id}`);
++ if (count) tab.append(h('em', '', count));
++ const pane = h('div', 'ovas-part');
++ pane.id = `ovas-p-${id}`;
++ pane.setAttribute('role', 'tabpanel');
++ pane.setAttribute('aria-labelledby', tab.id);
++ pane.append(...build());
++ const on = id === first;
++ tab.setAttribute('aria-selected', on ? 'true' : 'false');
++ tab.tabIndex = on ? 0 : -1;
++ pane.hidden = !on;
++ tab.addEventListener('click', () => select(id));
++ tabs.append(tab);
++ panes.push([id, tab, pane]);
++ });
++ function select(id) {
++ panes.forEach(([pid, tab, pane]) => { const on = pid === id; tab.setAttribute('aria-selected', on ? 'true' : 'false'); tab.tabIndex = on ? 0 : -1; pane.hidden = !on; });
++ }
++ tabs.addEventListener('keydown', (e) => {
++ if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
++ const i = panes.findIndex(([, tab]) => tab.getAttribute('aria-selected') === 'true');
++ const next = panes[(i + (e.key === 'ArrowRight' ? 1 : panes.length - 1)) % panes.length];
++ select(next[0]); next[1].focus(); e.preventDefault();
++ });
++ body.replaceChildren(who, tabs, ...panes.map(([, , pane]) => pane));
++ }
++
++ async function load() {
++ wait();
++ try {
++ const t = typeof opts.token === 'function' ? await opts.token() : opts.token;
++ if (!t) { failed('Sign in to see your agent.'); return; }
++ const r = await fetch(url, { headers: { accept: 'application/json', authorization: `Bearer ${t}` }, credentials: 'omit' });
++ const b = await r.json().catch(() => null);
++ if (gone) return;
++ if (!r.ok || !b) { failed(r.status === 404 ? 'This agent is not there any more.' : 'Its sheet could not be read just now.'); return; }
++ render(b.sheet || b);
++ } catch { if (!gone) failed('Its sheet could not be read just now.'); }
++ }
++ load();
++ return { close: () => win.close() };
++ }
++
++ root.OVAgentSheet = { open };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/browser/shared/ai-marks-client.js b/browser/shared/ai-marks-client.js
nnew file mode 100644
iindex 0000000..ec3d416
---- /dev/null
++++ b/browser/shared/ai-marks-client.js
@@@ -0,0 +1,16 @@
++/* Generated by scripts/build-ai-marks-client.js from ai-marks-data.js. */
++(function (root) {
++ 'use strict';
++ if (!root || root.OpenVibeAIMarks) return;
++ const DATA = {"OpenAI":"<path d=\"M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z\"></path>","Anthropic":"<path d=\"M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z\"></path>","Google":"<path d=\"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z\"></path>","Gemma":"<path d=\"M12.34 5.953a8.233 8.233 0 01-.247-1.125V3.72a8.25 8.25 0 015.562 2.232H12.34zm-.69 0c.113-.373.199-.755.257-1.145V3.72a8.25 8.25 0 00-5.562 2.232h5.304zm-5.433.187h5.373a7.98 7.98 0 01-.267.696 8.41 8.41 0 01-1.76 2.65L6.216 6.14zm-.264-.187H2.977v.187h2.915a8.436 8.436 0 00-2.357 5.767H0v.186h3.535a8.436 8.436 0 002.357 5.767H2.977v.186h2.976v2.977h.187v-2.915a8.436 8.436 0 005.767 2.357V24h.186v-3.535a8.436 8.436 0 005.767-2.357v2.915h.186v-2.977h2.977v-.186h-2.915a8.436 8.436 0 002.357-5.767H24v-.186h-3.535a8.436 8.436 0 00-2.357-5.767h2.915v-.187h-2.977V2.977h-.186v2.915a8.436 8.436 0 00-5.767-2.357V0h-.186v3.535A8.436 8.436 0 006.14 5.892V2.977h-.187v2.976zm6.14 14.326a8.25 8.25 0 005.562-2.233H12.34c-.108.367-.19.743-.247 1.126v1.107zm-.186-1.087a8.015 8.015 0 00-.258-1.146H6.345a8.25 8.25 0 005.562 2.233v-1.087zm-8.186-7.285h1.107a8.23 8.23 0 001.125-.247V6.345a8.25 8.25 0 00-2.232 5.562zm1.087.186H3.72a8.25 8.25 0 002.232 5.562v-5.304a8.012 8.012 0 00-1.145-.258zm15.47-.186a8.25 8.25 0 00-2.232-5.562v5.315c.367.108.743.19 1.126.247h1.107zm-1.086.186c-.39.058-.772.144-1.146.258v5.304a8.25 8.25 0 002.233-5.562h-1.087zm-1.332 5.69V12.41a7.97 7.97 0 00-.696.267 8.409 8.409 0 00-2.65 1.76l3.346 3.346zm0-6.18v-5.45l-.012-.013h-5.451c.076.235.162.468.26.696a8.698 8.698 0 001.819 2.688 8.698 8.698 0 002.688 1.82c.228.097.46.183.696.259zM6.14 17.848V12.41c.235.078.468.167.696.267a8.403 8.403 0 012.688 1.799 8.404 8.404 0 011.799 2.688c.1.228.19.46.267.696H6.152l-.012-.012zm0-6.245V6.326l3.29 3.29a8.716 8.716 0 01-2.594 1.728 8.14 8.14 0 01-.696.259zm6.257 6.257h5.277l-3.29-3.29a8.716 8.716 0 00-1.728 2.594 8.135 8.135 0 00-.259.696zm-2.347-7.81a9.435 9.435 0 01-2.88 1.96 9.14 9.14 0 012.88 1.94 9.14 9.14 0 011.94 2.88 9.435 9.435 0 011.96-2.88 9.14 9.14 0 012.88-1.94 9.435 9.435 0 01-2.88-1.96 9.434 9.434 0 01-1.96-2.88 9.14 9.14 0 01-1.94 2.88z\"></path>","Meta":"<path d=\"M6.897 4c1.915 0 3.516.932 5.43 3.376l.282-.373c.19-.246.383-.484.58-.71l.313-.35C14.588 4.788 15.792 4 17.225 4c1.273 0 2.469.557 3.491 1.516l.218.213c1.73 1.765 2.917 4.71 3.053 8.026l.011.392.002.25c0 1.501-.28 2.759-.818 3.7l-.14.23-.108.153c-.301.42-.664.758-1.086 1.009l-.265.142-.087.04a3.493 3.493 0 01-.302.118 4.117 4.117 0 01-1.33.208c-.524 0-.996-.067-1.438-.215-.614-.204-1.163-.56-1.726-1.116l-.227-.235c-.753-.812-1.534-1.976-2.493-3.586l-1.43-2.41-.544-.895-1.766 3.13-.343.592C7.597 19.156 6.227 20 4.356 20c-1.21 0-2.205-.42-2.936-1.182l-.168-.184c-.484-.573-.837-1.311-1.043-2.189l-.067-.32a8.69 8.69 0 01-.136-1.288L0 14.468c.002-.745.06-1.49.174-2.23l.1-.573c.298-1.53.828-2.958 1.536-4.157l.209-.34c1.177-1.83 2.789-3.053 4.615-3.16L6.897 4zm-.033 2.615l-.201.01c-.83.083-1.606.673-2.252 1.577l-.138.199-.01.018c-.67 1.017-1.185 2.378-1.456 3.845l-.004.022a12.591 12.591 0 00-.207 2.254l.002.188c.004.18.017.36.04.54l.043.291c.092.503.257.908.486 1.208l.117.137c.303.323.698.492 1.17.492 1.1 0 1.796-.676 3.696-3.641l2.175-3.4.454-.701-.139-.198C9.11 7.3 8.084 6.616 6.864 6.616zm10.196-.552l-.176.007c-.635.048-1.223.359-1.82.933l-.196.198c-.439.462-.887 1.064-1.367 1.807l.266.398c.18.274.362.56.55.858l.293.475 1.396 2.335.695 1.114c.583.926 1.03 1.6 1.408 2.082l.213.262c.282.326.529.54.777.673l.102.05c.227.1.457.138.718.138.176.002.35-.023.518-.073.338-.104.61-.32.813-.637l.095-.163.077-.162c.194-.459.29-1.06.29-1.785l-.006-.449c-.08-2.871-.938-5.372-2.2-6.798l-.176-.189c-.67-.683-1.444-1.074-2.27-1.074z\"></path>","DeepSeek":"<path d=\"M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z\"></path>","Qwen":"<path d=\"M19.713 2.825h-5.907c0-.251-.066-.503-.196-.728L12.819.728A1.46 1.46 0 0 0 11.559 0h-1.58c-.52 0-1 .275-1.26.728l-2.42 4.19a1.45 1.45 0 0 0 0 1.453l.791 1.37c.26.448.74.727 1.26.727h11.36c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451l-.79-1.37a1.46 1.46 0 0 0-1.26-.728zM.195 9.905l2.951 5.115a1.46 1.46 0 0 0-.531.531l-.791 1.37a1.45 1.45 0 0 0 0 1.452l.79 1.37c.26.448.74.727 1.26.727h4.84c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451L5.086 7.083a1.46 1.46 0 0 0-1.26-.728h-1.58c-.52 0-1 .276-1.26.728l-.79 1.37a1.45 1.45 0 0 0 0 1.452m15.888 13.367 2.951-5.115c.217.125.469.196.728.196h1.582c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451l-2.42-4.191a1.46 1.46 0 0 0-1.259-.728h-1.582c-.52 0-1 .276-1.259.728l-5.678 9.838a1.45 1.45 0 0 0 0 1.452l.79 1.37c.26.448.74.727 1.26.727h1.582c.52 0 1-.275 1.26-.728z\"></path>","Mistral":"<path clip-rule=\"evenodd\" d=\"M3.428 3.4h3.429v3.428h3.429v3.429h-.002 3.431V6.828h3.427V3.4h3.43v13.714H24v3.429H13.714v-3.428h-3.428v-3.429h-3.43v3.428h3.43v3.429H0v-3.429h3.428V3.4zm10.286 13.715h3.428v-3.429h-3.427v3.429z\"></path>","Kimi":"<path d=\"M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z\"></path><path d=\"M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z\"></path>","GLM":"<path d=\"M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z\"></path>","MiniMax":"<path d=\"M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z\"></path>","Microsoft":"<path d=\"M11.49 2H2v9.492h9.492V2h-.002z\"></path><path d=\"M22 2h-9.492v9.492H22V2z\"></path><path d=\"M11.49 12.508H2V22h9.492v-9.492h-.002z\"></path><path d=\"M22 12.508h-9.492V22H22v-9.492z\"></path>","xAI":"<path d=\"M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815\"></path>","NVIDIA":"<path d=\"M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z\"></path>","Cohere":"<path clip-rule=\"evenodd\" d=\"M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z\"></path><path clip-rule=\"evenodd\" d=\"M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z\"></path><path d=\"M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z\"></path>","Perplexity":"<path d=\"M19.785 0v7.272H22.5V17.62h-2.935V24l-7.037-6.194v6.145h-1.091v-6.152L4.392 24v-6.465H1.5V7.188h2.884V0l7.053 6.494V.19h1.09v6.49L19.786 0zm-7.257 9.044v7.319l5.946 5.234V14.44l-5.946-5.397zm-1.099-.08l-5.946 5.398v7.235l5.946-5.234V8.965zm8.136 7.58h1.844V8.349H13.46l6.105 5.54v2.655zm-8.982-8.28H2.59v8.195h1.8v-2.576l6.192-5.62zM5.475 2.476v4.71h5.115l-5.115-4.71zm13.219 0l-5.115 4.71h5.115v-4.71z\"></path>"};
++ const ALIAS = { openai: 'OpenAI', anthropic: 'Anthropic', google: 'Google', gemma: 'Gemma', meta: 'Meta', deepseek: 'DeepSeek', qwen: 'Qwen', mistral: 'Mistral', kimi: 'Kimi', glm: 'GLM', minimax: 'MiniMax', microsoft: 'Microsoft', xai: 'xAI', nvidia: 'NVIDIA', cohere: 'Cohere', perplexity: 'Perplexity' };
++ const mark = (maker, model) => {
++ const text = String(maker || model || '').toLowerCase();
++ const key = Object.keys(ALIAS).find((name) => text.includes(name)) || (/gpt|\bo[134]\b/.test(text) ? 'openai' : /claude/.test(text) ? 'anthropic' : /gemini/.test(text) ? 'google' : /grok/.test(text) ? 'xai' : null);
++ if (!key || !DATA[ALIAS[key]]) return null;
++ const tile = document.createElement('span'); tile.className = 'ovdk-run-maker'; tile.setAttribute('role', 'img'); tile.setAttribute('aria-label', ALIAS[key]);
++ tile.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true">' + DATA[ALIAS[key]] + '</svg>';
++ return tile;
++ };
++ root.OpenVibeAIMarks = { mark };
++})(typeof window !== 'undefined' ? window : null);
ddiff --git a/browser/shared/brands.js b/browser/shared/brands.js
nnew file mode 100644
iindex 0000000..31fd8b1
---- /dev/null
++++ b/browser/shared/brands.js
@@@ -0,0 +1,30 @@
++'use strict';
++/* Generated by scripts/build-brands.js from simple-icons 16.36.0 (CC0-1.0, https://simpleicons.org). Do not edit. */
++/**
++ * openvibe-shared/brands — the brand logos OpenVibe shows, in place of Font Awesome's fa-brands (plan L54 K4):
++ * 49 brands on a 24-unit grid, filled, each with its own colour. Brand names are their owners' marks; Simple Icons'
++ * drawings are public domain.
++ *
++ * brands.get('youtube') → { title, hex, path } or null
++ * brands.fromFa('fa-youtube') → 'youtube' (or null)
++ * brands.svg('youtube', { size, title, color })
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else root.OVBrands = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
++ const BRANDS = {"android":{"title":"Android","hex":"#3ddc84","path":"M18.4395 5.5586c-.675 1.1664-1.352 2.3318-2.0274 3.498-.0366-.0155-.0742-.0286-.1113-.043-1.8249-.6957-3.484-.8-4.42-.787-1.8551.0185-3.3544.4643-4.2597.8203-.084-.1494-1.7526-3.021-2.0215-3.4864a1.1451 1.1451 0 0 0-.1406-.1914c-.3312-.364-.9054-.4859-1.379-.203-.475.282-.7136.9361-.3886 1.5019 1.9466 3.3696-.0966-.2158 1.9473 3.3593.0172.031-.4946.2642-1.3926 1.0177C2.8987 12.176.452 14.772 0 18.9902h24c-.119-1.1108-.3686-2.099-.7461-3.0683-.7438-1.9118-1.8435-3.2928-2.7402-4.1836a12.1048 12.1048 0 0 0-2.1309-1.6875c.6594-1.122 1.312-2.2559 1.9649-3.3848.2077-.3615.1886-.7956-.0079-1.1191a1.1001 1.1001 0 0 0-.8515-.5332c-.5225-.0536-.9392.3128-1.0488.5449zm-.0391 8.461c.3944.5926.324 1.3306-.1563 1.6503-.4799.3197-1.188.0985-1.582-.4941-.3944-.5927-.324-1.3307.1563-1.6504.4727-.315 1.1812-.1086 1.582.4941zM7.207 13.5273c.4803.3197.5506 1.0577.1563 1.6504-.394.5926-1.1038.8138-1.584.4941-.48-.3197-.5503-1.0577-.1563-1.6504.4008-.6021 1.1087-.8106 1.584-.4941z"},"apple":{"title":"Apple","hex":"#000000","path":"M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"},"bitcoin":{"title":"Bitcoin","hex":"#f7931a","path":"M23.638 14.904c-1.602 6.43-8.113 10.34-14.542 8.736C2.67 22.05-1.244 15.525.362 9.105 1.962 2.67 8.475-1.243 14.9.358c6.43 1.605 10.342 8.115 8.738 14.548v-.002zm-6.35-4.613c.24-1.59-.974-2.45-2.64-3.03l.54-2.153-1.315-.33-.525 2.107c-.345-.087-.705-.167-1.064-.25l.526-2.127-1.32-.33-.54 2.165c-.285-.067-.565-.132-.84-.2l-1.815-.45-.35 1.407s.975.225.955.236c.535.136.63.486.615.766l-1.477 5.92c-.075.166-.24.406-.614.314.015.02-.96-.24-.96-.24l-.66 1.51 1.71.426.93.242-.54 2.19 1.32.327.54-2.17c.36.1.705.19 1.05.273l-.51 2.154 1.32.33.545-2.19c2.24.427 3.93.257 4.64-1.774.57-1.637-.03-2.58-1.217-3.196.854-.193 1.5-.76 1.68-1.93h.01zm-3.01 4.22c-.404 1.64-3.157.75-4.05.53l.72-2.9c.896.23 3.757.67 3.33 2.37zm.41-4.24c-.37 1.49-2.662.735-3.405.55l.654-2.64c.744.18 3.137.524 2.75 2.084v.006z"},"bluesky":{"title":"Bluesky","hex":"#1185fe","path":"M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"},"brave":{"title":"Brave","hex":"#fb542b","path":"M15.68 0l2.096 2.38s1.84-.512 2.709.358c.868.87 1.584 1.638 1.584 1.638l-.562 1.381.715 2.047s-2.104 7.98-2.35 8.955c-.486 1.919-.818 2.66-2.198 3.633-1.38.972-3.884 2.66-4.293 2.916-.409.256-.92.692-1.38.692-.46 0-.97-.436-1.38-.692a185.796 185.796 0 01-4.293-2.916c-1.38-.973-1.712-1.714-2.197-3.633-.247-.975-2.351-8.955-2.351-8.955l.715-2.047-.562-1.381s.716-.768 1.585-1.638c.868-.87 2.708-.358 2.708-.358L8.321 0h7.36zm-3.679 14.936c-.14 0-1.038.317-1.758.69-.72.373-1.242.637-1.409.742-.167.104-.065.301.087.409.152.107 2.194 1.69 2.393 1.866.198.175.489.464.687.464.198 0 .49-.29.688-.464.198-.175 2.24-1.759 2.392-1.866.152-.108.254-.305.087-.41-.167-.104-.689-.368-1.41-.741-.72-.373-1.617-.69-1.757-.69zm0-11.278s-.409.001-1.022.206-1.278.46-1.584.46c-.307 0-2.581-.434-2.581-.434S4.119 7.152 4.119 7.849c0 .697.339.881.68 1.243l2.02 2.149c.192.203.59.511.356 1.066-.235.555-.58 1.26-.196 1.977.384.716 1.042 1.194 1.464 1.115.421-.08 1.412-.598 1.776-.834.364-.237 1.518-1.19 1.518-1.554 0-.365-1.193-1.02-1.413-1.168-.22-.15-1.226-.725-1.247-.95-.02-.227-.012-.293.284-.851.297-.559.831-1.304.742-1.8-.089-.495-.95-.753-1.565-.986-.615-.232-1.799-.671-1.947-.74-.148-.068-.11-.133.339-.175.448-.043 1.719-.212 2.292-.052.573.16 1.552.403 1.632.532.079.13.149.134.067.579-.081.445-.5 2.581-.541 2.96-.04.38-.12.63.288.724.409.094 1.097.256 1.333.256s.924-.162 1.333-.256c.408-.093.329-.344.288-.723-.04-.38-.46-2.516-.541-2.961-.082-.445-.012-.45.067-.579.08-.129 1.059-.372 1.632-.532.573-.16 1.845.009 2.292.052.449.042.487.107.339.175-.148.069-1.332.508-1.947.74-.615.233-1.476.49-1.565.986-.09.496.445 1.241.742 1.8.297.558.304.624.284.85-.02.226-1.026.802-1.247.95-.22.15-1.413.804-1.413 1.169 0 .364 1.154 1.317 1.518 1.554.364.236 1.355.755 1.776.834.422.079 1.08-.4 1.464-1.115.384-.716.039-1.422-.195-1.977-.235-.555.163-.863.355-1.066l2.02-2.149c.341-.362.68-.546.68-1.243 0-.697-2.695-3.96-2.695-3.96s-2.274.436-2.58.436c-.307 0-.972-.256-1.585-.461-.613-.205-1.022-.206-1.022-.206z"},"cloudflare":{"title":"Cloudflare","hex":"#f38020","path":"M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727"},"discord":{"title":"Discord","hex":"#5865f2","path":"M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"},"docker":{"title":"Docker","hex":"#2496ed","path":"M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"},"ethereum":{"title":"Ethereum","hex":"#3c3c3d","path":"M11.944 17.97L4.58 13.62 11.943 24l7.37-10.38-7.372 4.35h.003zM12.056 0L4.69 12.223l7.365 4.354 7.365-4.35L12.056 0z"},"facebook":{"title":"Facebook","hex":"#0866ff","path":"M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"},"firefoxbrowser":{"title":"Firefox Browser","hex":"#ff7139","path":"M8.824 7.287c.008 0 .004 0 0 0zm-2.8-1.4c.006 0 .003 0 0 0zm16.754 2.161c-.505-1.215-1.53-2.528-2.333-2.943.654 1.283 1.033 2.57 1.177 3.53l.002.02c-1.314-3.278-3.544-4.6-5.366-7.477-.091-.147-.184-.292-.273-.446a3.545 3.545 0 01-.13-.24 2.118 2.118 0 01-.172-.46.03.03 0 00-.027-.03.038.038 0 00-.021 0l-.006.001a.037.037 0 00-.01.005L15.624 0c-2.585 1.515-3.657 4.168-3.932 5.856a6.197 6.197 0 00-2.305.587.297.297 0 00-.147.37c.057.162.24.24.396.17a5.622 5.622 0 012.008-.523l.067-.005a5.847 5.847 0 011.957.222l.095.03a5.816 5.816 0 01.616.228c.08.036.16.073.238.112l.107.055a5.835 5.835 0 01.368.211 5.953 5.953 0 012.034 2.104c-.62-.437-1.733-.868-2.803-.681 4.183 2.09 3.06 9.292-2.737 9.02a5.164 5.164 0 01-1.513-.292 4.42 4.42 0 01-.538-.232c-1.42-.735-2.593-2.121-2.74-3.806 0 0 .537-2 3.845-2 .357 0 1.38-.998 1.398-1.287-.005-.095-2.029-.9-2.817-1.677-.422-.416-.622-.616-.8-.767a3.47 3.47 0 00-.301-.227 5.388 5.388 0 01-.032-2.842c-1.195.544-2.124 1.403-2.8 2.163h-.006c-.46-.584-.428-2.51-.402-2.913-.006-.025-.343.176-.389.206-.406.29-.787.616-1.136.974-.397.403-.76.839-1.085 1.303a9.816 9.816 0 00-1.562 3.52c-.003.013-.11.487-.19 1.073-.013.09-.026.181-.037.272a7.8 7.8 0 00-.069.667l-.002.034-.023.387-.001.06C.386 18.795 5.593 24 12.016 24c5.752 0 10.527-4.176 11.463-9.661.02-.149.035-.298.052-.448.232-1.994-.025-4.09-.753-5.844z"},"github":{"title":"GitHub","hex":"#181717","path":"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"},"gitlab":{"title":"GitLab","hex":"#fc6d26","path":"m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"},"google":{"title":"Google","hex":"#4285f4","path":"M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"},"googlechrome":{"title":"Google Chrome","hex":"#4285f4","path":"M12 0C8.21 0 4.831 1.757 2.632 4.501l3.953 6.848A5.454 5.454 0 0 1 12 6.545h10.691A12 12 0 0 0 12 0zM1.931 5.47A11.943 11.943 0 0 0 0 12c0 6.012 4.42 10.991 10.189 11.864l3.953-6.847a5.45 5.45 0 0 1-6.865-2.29zm13.342 2.166a5.446 5.446 0 0 1 1.45 7.09l.002.001h-.002l-5.344 9.257c.206.01.413.016.621.016 6.627 0 12-5.373 12-12 0-1.54-.29-3.011-.818-4.364zM12 16.364a4.364 4.364 0 1 1 0-8.728 4.364 4.364 0 0 1 0 8.728Z"},"instagram":{"title":"Instagram","hex":"#ff0069","path":"M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077"},"kick":{"title":"Kick","hex":"#53fc19","path":"M1.333 0h8v5.333H12V2.667h2.667V0h8v8H20v2.667h-2.667v2.666H20V16h2.667v8h-8v-2.667H12v-2.666H9.333V24h-8Z"},"kickstarter":{"title":"Kickstarter","hex":"#05ce78","path":"M7.168 0c-3.2 0-5.797 2.579-5.797 5.758v12.484C1.371 21.42 3.968 24 7.168 24c1.981 0 3.716-.978 4.768-2.479l.794.79c2.26 2.245 5.943 2.245 8.203 0a5.724 5.724 0 001.696-4.075 5.724 5.724 0 00-1.696-4.074l-2.182-2.168 2.182-2.156a5.724 5.724 0 001.696-4.074 5.724 5.724 0 00-1.696-4.074c-2.26-2.246-5.942-2.246-8.203 0l-.794.789A5.797 5.797 0 007.168 0Z"},"line":{"title":"LINE","hex":"#00c300","path":"M19.365 9.863c.349 0 .63.285.63.631 0 .345-.281.63-.63.63H17.61v1.125h1.755c.349 0 .63.283.63.63 0 .344-.281.629-.63.629h-2.386c-.345 0-.627-.285-.627-.629V8.108c0-.345.282-.63.63-.63h2.386c.346 0 .627.285.627.63 0 .349-.281.63-.63.63H17.61v1.125h1.755zm-3.855 3.016c0 .27-.174.51-.432.596-.064.021-.133.031-.199.031-.211 0-.391-.09-.51-.25l-2.443-3.317v2.94c0 .344-.279.629-.631.629-.346 0-.626-.285-.626-.629V8.108c0-.27.173-.51.43-.595.06-.023.136-.033.194-.033.195 0 .375.104.495.254l2.462 3.33V8.108c0-.345.282-.63.63-.63.345 0 .63.285.63.63v4.771zm-5.741 0c0 .344-.282.629-.631.629-.345 0-.627-.285-.627-.629V8.108c0-.345.282-.63.63-.63.346 0 .628.285.628.63v4.771zm-2.466.629H4.917c-.345 0-.63-.285-.63-.629V8.108c0-.345.285-.63.63-.63.348 0 .63.285.63.63v4.141h1.756c.348 0 .629.283.629.63 0 .344-.282.629-.629.629M24 10.314C24 4.943 18.615.572 12 .572S0 4.943 0 10.314c0 4.811 4.27 8.842 10.035 9.608.391.082.923.258 1.058.59.12.301.079.766.038 1.08l-.164 1.02c-.045.301-.24 1.186 1.049.645 1.291-.539 6.916-4.078 9.436-6.975C23.176 14.393 24 12.458 24 10.314"},"linux":{"title":"Linux","hex":"#fcc624","path":"M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.132 1.884 1.071.771-.06 1.592-.536 2.257-1.306.631-.765 1.683-1.084 2.378-1.503.348-.199.629-.469.649-.853.023-.4-.2-.811-.714-1.376v-.097l-.003-.003c-.17-.2-.25-.535-.338-.926-.085-.401-.182-.786-.492-1.046h-.003c-.059-.054-.123-.067-.188-.135a.357.357 0 00-.19-.064c.431-1.278.264-2.55-.173-3.694-.533-1.41-1.465-2.638-2.175-3.483-.796-1.005-1.576-1.957-1.56-3.368.026-2.152.236-6.133-3.544-6.139zm.529 3.405h.013c.213 0 .396.062.584.198.19.135.33.332.438.533.105.259.158.459.166.724 0-.02.006-.04.006-.06v.105a.086.086 0 01-.004-.021l-.004-.024a1.807 1.807 0 01-.15.706.953.953 0 01-.213.335.71.71 0 00-.088-.042c-.104-.045-.198-.064-.284-.133a1.312 1.312 0 00-.22-.066c.05-.06.146-.133.183-.198.053-.128.082-.264.088-.402v-.02a1.21 1.21 0 00-.061-.4c-.045-.134-.101-.2-.183-.333-.084-.066-.167-.132-.267-.132h-.016c-.093 0-.176.03-.262.132a.8.8 0 00-.205.334 1.18 1.18 0 00-.09.4v.019c.002.089.008.179.02.267-.193-.067-.438-.135-.607-.202a1.635 1.635 0 01-.018-.2v-.02a1.772 1.772 0 01.15-.768c.082-.22.232-.406.43-.533a.985.985 0 01.594-.2zm-2.962.059h.036c.142 0 .27.048.399.135.146.129.264.288.344.465.09.199.14.4.153.667v.004c.007.134.006.2-.002.266v.08c-.03.007-.056.018-.083.024-.152.055-.274.135-.393.2.012-.09.013-.18.003-.267v-.015c-.012-.133-.04-.2-.082-.333a.613.613 0 00-.166-.267.248.248 0 00-.183-.064h-.021c-.071.006-.13.04-.186.132a.552.552 0 00-.12.27.944.944 0 00-.023.33v.015c.012.135.037.2.08.334.046.134.098.2.166.268.01.009.02.018.034.024-.07.057-.117.07-.176.136a.304.304 0 01-.131.068 2.62 2.62 0 01-.275-.402 1.772 1.772 0 01-.155-.667 1.759 1.759 0 01.08-.668 1.43 1.43 0 01.283-.535c.128-.133.26-.2.418-.2zm1.37 1.706c.332 0 .733.065 1.216.399.293.2.523.269 1.052.468h.003c.255.136.405.266.478.399v-.131a.571.571 0 01.016.47c-.123.31-.516.643-1.063.842v.002c-.268.135-.501.333-.775.465-.276.135-.588.292-1.012.267a1.139 1.139 0 01-.448-.067 3.566 3.566 0 01-.322-.198c-.195-.135-.363-.332-.612-.465v-.005h-.005c-.4-.246-.616-.512-.686-.71-.07-.268-.005-.47.193-.6.224-.135.38-.271.483-.336.104-.074.143-.102.176-.131h.002v-.003c.169-.202.436-.47.839-.601.139-.036.294-.065.466-.065zm2.8 2.142c.358 1.417 1.196 3.475 1.735 4.473.286.534.855 1.659 1.102 3.024.156-.005.33.018.513.064.646-1.671-.546-3.467-1.089-3.966-.22-.2-.232-.335-.123-.335.59.534 1.365 1.572 1.646 2.757.13.535.16 1.104.021 1.67.067.028.135.06.205.067 1.032.534 1.413.938 1.23 1.537v-.043c-.06-.003-.12 0-.18 0h-.016c.151-.467-.182-.825-1.065-1.224-.915-.4-1.646-.336-1.77.465-.008.043-.013.066-.018.135-.068.023-.139.053-.209.064-.43.268-.662.669-.793 1.187-.13.533-.17 1.156-.205 1.869v.003c-.02.334-.17.838-.319 1.35-1.5 1.072-3.58 1.538-5.348.334a2.645 2.645 0 00-.402-.533 1.45 1.45 0 00-.275-.333c.182 0 .338-.03.465-.067a.615.615 0 00.314-.334c.108-.267 0-.697-.345-1.163-.345-.467-.931-.995-1.788-1.521-.63-.4-.986-.87-1.15-1.396-.165-.534-.143-1.085-.015-1.645.245-1.07.873-2.11 1.274-2.763.107-.065.037.135-.408.974-.396.751-1.14 2.497-.122 3.854a8.123 8.123 0 01.647-2.876c.564-1.278 1.743-3.504 1.836-5.268.048.036.217.135.289.202.218.133.38.333.59.465.21.201.477.335.876.335.039.003.075.006.11.006.412 0 .73-.134.997-.268.29-.134.52-.334.74-.4h.005c.467-.135.835-.402 1.044-.7zm2.185 8.958c.037.6.343 1.245.882 1.377.588.134 1.434-.333 1.791-.765l.211-.01c.315-.007.577.01.847.268l.003.003c.208.199.305.53.391.876.085.4.154.78.409 1.066.486.527.645.906.636 1.14l.003-.007v.018l-.003-.012c-.015.262-.185.396-.498.595-.63.401-1.746.712-2.457 1.57-.618.737-1.37 1.14-2.036 1.191-.664.053-1.237-.2-1.574-.898l-.005-.003c-.21-.4-.12-1.025.056-1.69.176-.668.428-1.344.463-1.897.037-.714.076-1.335.195-1.814.12-.465.308-.797.641-.984l.045-.022zm-10.814.049h.01c.053 0 .105.005.157.014.376.055.706.333 1.023.752l.91 1.664.003.003c.243.533.754 1.064 1.189 1.637.434.598.77 1.131.729 1.57v.006c-.057.744-.48 1.148-1.125 1.294-.645.135-1.52.002-2.395-.464-.968-.536-2.118-.469-2.857-.602-.369-.066-.61-.2-.723-.4-.11-.2-.113-.602.123-1.23v-.004l.002-.003c.117-.334.03-.752-.027-1.118-.055-.401-.083-.71.043-.94.16-.334.396-.4.69-.533.294-.135.64-.202.915-.47h.002v-.002c.256-.268.445-.601.668-.838.19-.201.38-.336.663-.336zm7.159-9.074c-.435.201-.945.535-1.488.535-.542 0-.97-.267-1.28-.466-.154-.134-.28-.268-.373-.335-.164-.134-.144-.333-.074-.333.109.016.129.134.199.2.096.066.215.2.36.333.292.2.68.467 1.167.467.485 0 1.053-.267 1.398-.466.195-.135.445-.334.648-.467.156-.136.149-.267.279-.267.128.016.034.134-.147.332a8.097 8.097 0 01-.69.468zm-1.082-1.583V5.64c-.006-.02.013-.042.029-.05.074-.043.18-.027.26.004.063 0 .16.067.15.135-.006.049-.085.066-.135.066-.055 0-.092-.043-.141-.068-.052-.018-.146-.008-.163-.065zm-.551 0c-.02.058-.113.049-.166.066-.047.025-.086.068-.14.068-.05 0-.13-.02-.136-.068-.01-.066.088-.133.15-.133.08-.031.184-.047.259-.005.019.009.036.03.03.05v.02h.003z"},"mastodon":{"title":"Mastodon","hex":"#6364ff","path":"M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"},"matrix":{"title":"Matrix","hex":"#000000","path":"M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033c.309-.443.683-.784 1.117-1.024.433-.245.936-.365 1.5-.365.54 0 1.033.107 1.481.314.448.208.785.582 1.02 1.108.254-.374.6-.706 1.034-.992.434-.287.95-.43 1.546-.43.453 0 .872.056 1.26.167.388.11.716.286.993.53.276.245.489.559.646.951.152.392.23.863.23 1.417v5.728h-2.349V11.52c0-.286-.01-.559-.032-.812a1.755 1.755 0 0 0-.18-.66 1.106 1.106 0 0 0-.438-.448c-.194-.11-.457-.166-.785-.166-.332 0-.6.064-.803.189a1.38 1.38 0 0 0-.48.499 1.946 1.946 0 0 0-.231.696 5.56 5.56 0 0 0-.06.785v4.768h-2.35v-4.8c0-.254-.004-.503-.018-.752a2.074 2.074 0 0 0-.143-.688 1.052 1.052 0 0 0-.415-.503c-.194-.125-.476-.19-.854-.19-.111 0-.259.024-.439.074-.18.051-.36.143-.53.282-.171.138-.319.337-.439.595-.12.259-.18.6-.18 1.02v4.966H5.46V7.81zm15.693 15.64V.55H21.72V0H24v24h-2.28v-.55z"},"medium":{"title":"Medium","hex":"#000000","path":"M4.21 0A4.201 4.201 0 0 0 0 4.21v15.58A4.201 4.201 0 0 0 4.21 24h15.58A4.201 4.201 0 0 0 24 19.79v-1.093c-.137.013-.278.02-.422.02-2.577 0-4.027-2.146-4.09-4.832a7.592 7.592 0 0 1 .022-.708c.093-1.186.475-2.241 1.105-3.022a3.885 3.885 0 0 1 1.395-1.1c.468-.237 1.127-.367 1.664-.367h.023c.101 0 .202.004.303.01V4.211A4.201 4.201 0 0 0 19.79 0Zm.198 5.583h4.165l3.588 8.435 3.59-8.435h3.864v.146l-.019.004c-.705.16-1.063.397-1.063 1.254h-.003l.003 10.274c.06.676.424.885 1.063 1.03l.02.004v.145h-4.923v-.145l.019-.005c.639-.144.994-.353 1.054-1.03V7.267l-4.745 11.15h-.261L6.15 7.569v9.445c0 .857.358 1.094 1.063 1.253l.02.004v.147H4.405v-.147l.019-.004c.705-.16 1.065-.397 1.065-1.253V6.987c0-.857-.358-1.094-1.064-1.254l-.018-.004zm19.25 3.668c-1.086.023-1.733 1.323-1.813 3.124H24V9.298a1.378 1.378 0 0 0-.342-.047Zm-1.862 3.632c-.1 1.756.86 3.239 2.204 3.634v-3.634z"},"nodedotjs":{"title":"Node.js","hex":"#5fa04e","path":"M11.998,24c-0.321,0-0.641-0.084-0.922-0.247l-2.936-1.737c-0.438-0.245-0.224-0.332-0.08-0.383 c0.585-0.203,0.703-0.25,1.328-0.604c0.065-0.037,0.151-0.023,0.218,0.017l2.256,1.339c0.082,0.045,0.197,0.045,0.272,0l8.795-5.076 c0.082-0.047,0.134-0.141,0.134-0.238V6.921c0-0.099-0.053-0.192-0.137-0.242l-8.791-5.072c-0.081-0.047-0.189-0.047-0.271,0 L3.075,6.68C2.99,6.729,2.936,6.825,2.936,6.921v10.15c0,0.097,0.054,0.189,0.139,0.235l2.409,1.392 c1.307,0.654,2.108-0.116,2.108-0.89V7.787c0-0.142,0.114-0.253,0.256-0.253h1.115c0.139,0,0.255,0.112,0.255,0.253v10.021 c0,1.745-0.95,2.745-2.604,2.745c-0.508,0-0.909,0-2.026-0.551L2.28,18.675c-0.57-0.329-0.922-0.945-0.922-1.604V6.921 c0-0.659,0.353-1.275,0.922-1.603l8.795-5.082c0.557-0.315,1.296-0.315,1.848,0l8.794,5.082c0.57,0.329,0.924,0.944,0.924,1.603 v10.15c0,0.659-0.354,1.273-0.924,1.604l-8.794,5.078C12.643,23.916,12.324,24,11.998,24z M19.099,13.993 c0-1.9-1.284-2.406-3.987-2.763c-2.731-0.361-3.009-0.548-3.009-1.187c0-0.528,0.235-1.233,2.258-1.233 c1.807,0,2.473,0.389,2.747,1.607c0.024,0.115,0.129,0.199,0.247,0.199h1.141c0.071,0,0.138-0.031,0.186-0.081 c0.048-0.054,0.074-0.123,0.067-0.196c-0.177-2.098-1.571-3.076-4.388-3.076c-2.508,0-4.004,1.058-4.004,2.833 c0,1.925,1.488,2.457,3.895,2.695c2.88,0.282,3.103,0.703,3.103,1.269c0,0.983-0.789,1.402-2.642,1.402 c-2.327,0-2.839-0.584-3.011-1.742c-0.02-0.124-0.126-0.215-0.253-0.215h-1.137c-0.141,0-0.254,0.112-0.254,0.253 c0,1.482,0.806,3.248,4.655,3.248C17.501,17.007,19.099,15.91,19.099,13.993z"},"npm":{"title":"npm","hex":"#cb3837","path":"M1.763 0C.786 0 0 .786 0 1.763v20.474C0 23.214.786 24 1.763 24h20.474c.977 0 1.763-.786 1.763-1.763V1.763C24 .786 23.214 0 22.237 0zM5.13 5.323l13.837.019-.009 13.836h-3.464l.01-10.382h-3.456L12.04 19.17H5.113z"},"patreon":{"title":"Patreon","hex":"#000000","path":"M22.957 7.21c-.004-3.064-2.391-5.576-5.191-6.482-3.478-1.125-8.064-.962-11.384.604C2.357 3.231 1.093 7.391 1.046 11.54c-.039 3.411.302 12.396 5.369 12.46 3.765.047 4.326-4.804 6.068-7.141 1.24-1.662 2.836-2.132 4.801-2.618 3.376-.836 5.678-3.501 5.673-7.031Z"},"paypal":{"title":"PayPal","hex":"#002991","path":"M15.607 4.653H8.941L6.645 19.251H1.82L4.862 0h7.995c3.754 0 6.375 2.294 6.473 5.513-.648-.478-2.105-.86-3.722-.86m6.57 5.546c0 3.41-3.01 6.853-6.958 6.853h-2.493L11.595 24H6.74l1.845-11.538h3.592c4.208 0 7.346-3.634 7.153-6.949a5.24 5.24 0 0 1 2.848 4.686M9.653 5.546h6.408c.907 0 1.942.222 2.363.541-.195 2.741-2.655 5.483-6.441 5.483H8.714Z"},"pinterest":{"title":"Pinterest","hex":"#bd081c","path":"M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.099.12.112.225.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.401.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.354-.629-2.758-1.379l-.749 2.848c-.269 1.045-1.004 2.352-1.498 3.146 1.123.345 2.306.535 3.55.535 6.607 0 11.985-5.365 11.985-11.987C23.97 5.39 18.592.026 11.985.026L12.017 0z"},"python":{"title":"Python","hex":"#3776ab","path":"M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.77l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.17l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05-.05-1.23.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.18l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09zm13.09 3.95l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08z"},"raspberrypi":{"title":"Raspberry Pi","hex":"#a22846","path":"m19.8955 10.8961-.1726-.3028c.0068-2.1746-1.0022-3.061-2.1788-3.7348.356-.0938.7237-.1711.8245-.6182.6118-.1566.7397-.4398.8011-.7398.16-.1066.6955-.4061.6394-.9211.2998-.2069.4669-.4725.3819-.8487.3222-.3515.407-.6419.2702-.9096.3868-.4805.2152-.7295.05-.9817.2897-.5254.0341-1.0887-.7758-.9944-.3221-.4733-1.0244-.3659-1.133-.3637-.1215-.1519-.2819-.2821-.7755-.219-.3197-.2851-.6771-.2364-1.0458-.0964-.4378-.3403-.7275-.0675-1.0584.0356-.53-.1706-.6513.0631-.9117.1583-.5781-.1203-.7538.1416-1.0309.4182l-.3224-.0063c-.8719.5061-1.305 1.5366-1.4585 2.0664-.1536-.5299-.5858-1.5604-1.4575-2.0664l-.3223.0063C9.942.5014 9.7663.2394 9.1883.3597 8.9279.2646 8.807.0309 8.2766.2015c-.2172-.0677-.417-.2084-.6522-.2012l.0004.0002C7.5017.0041 7.369.049 7.2185.166c-.3688-.1401-.7262-.1887-1.0459.0964-.4936-.0631-.654.0671-.7756.219C5.2887.4791 4.5862.3717 4.264.845c-.8096-.0943-1.0655.4691-.7756.9944-.1653.2521-.3366.5013.05.9819-.1367.2677-.0519.5581.2703.9096-.085.3763.0822.6418.3819.8487-.0561.515.4795.8144.6394.9211.0614.3001.1894.5832.8011.7398.1008.4472.4685.5244.8245.6183-1.1766.6737-2.1856 1.56-2.1788 3.7348l-.1724.3028c-1.3491.8082-2.5629 3.4056-.6648 5.5167.124.6609.3319 1.1355.5171 1.6609.2769 2.117 2.0841 3.1082 2.5608 3.2255.6984.524 1.4423 1.0212 2.449 1.3696.949.964 1.977 1.3314 3.0107 1.3308.0152 0 .0306.0002.0457 0 1.0337.0006 2.0618-.3668 3.0107-1.3308 1.0067-.3483 1.7506-.8456 2.4491-1.3696.4766-.1173 2.2838-1.1085 2.5607-3.2255.1851-.5253.3931-1 .517-1.6609 1.8981-2.1113.6843-4.7089-.6649-5.517zm-1.0386-.3715c-.0704.8759-4.6354-3.0504-3.8472-3.1808 2.1391-.3558 3.9191.896 3.8472 3.1808zm-2.0155 4.3649c-1.1481.7409-2.8025.2626-3.6953-1.0681-.8928-1.3306-.6858-3.0101.4623-3.7509 1.1481-.7409 2.8025-.2627 3.6953 1.068.8927 1.3307.6858 3.0101-.4623 3.751zM13.6591 1.3721c.0396.1967.0843.321.1354.3577.2537-.272.4611-.5506.7878-.8123.0011.1537-.0776.3205.1169.4425.1752-.2356.4119-.4459.7263-.6244-.1514.2611-.026.3404.0554.4486.24-.2059.4681-.4144.9109-.5759-.121.1474-.2902.2914-.1108.4607.2473-.1544.496-.3086 1.0833-.4183-.1323.1475-.4059.295-.2401.4426.3104-.1186.6539-.2047 1.034-.2546-.182.1496-.3337.2963-.1846.4122.3323-.1022.7899-.2398 1.2372-.1212l-.2832.2849c-.0314.0382.6623.0297 1.1202.0364-.167.2321-.3375.4562-.437.8548.0454.0459.2723.0204.4862 0-.2194.4618-.6004.5783-.6893.776.134.1015.32.075.5232.006-.158.3254-.4892.5484-.7509.8123.0662.047.1818.075.4555.0425-.2418.257-.5339.492-.8802.7032.0614.0708.2722.0681.4678.0727-.3136.3069-.7173.466-1.0955.6668.1885.1288.3234.0988.4678.097-.2676.2198-.7225.3342-1.1448.4668.0803.1249.1607.1589.3324.194-.447.2473-1.0873.1343-1.2679.2607.0435.1243.1665.2053.3139.2728-.7197.0418-2.6879-.0262-3.0652-1.5156.7367-.8094 2.0813-1.7593 4.394-2.934-1.7994.6022-3.4229 1.405-4.7817 2.5096-1.5978-.7436-.4965-2.6197.283-3.3645zm-1.6126 5.3718c1.1329-.0123 2.5356.8325 2.53 1.6286-.005.7027-.9851 1.2715-2.5213 1.2607-1.5043-.0177-2.5172-.7148-2.5137-1.3957.003-.5603 1.2282-1.5263 2.505-1.4936zm-5.7646-.6006c.1717-.0351.252-.0692.3323-.194-.4223-.1327-.8772-.247-1.1448-.4668.1444.0018.2792.0318.4678-.097-.3783-.2008-.782-.3599-1.0956-.6668.1955-.0048.4064-.002.4677-.0728-.3462-.2113-.6383-.4463-.8801-.7033.2738.0325.3893.0045.4555-.0425-.2617-.264-.593-.487-.7509-.8123.2032.069.3892.0954.5232-.006-.089-.1977-.47-.3142-.6894-.776.214.0204.4409.0459.4863 0-.0994-.3985-.2698-.6226-.4369-.8547.4579-.0067 1.1516.0018 1.1202-.0364l-.2831-.2849c.4472-.1186.9049.019 1.2371.1213.1492-.1159-.0026-.2626-.1847-.4123.3801.05.7236.1361 1.034.2547.1659-.1476-.1076-.2951-.24-.4426.5872.1097.8361.2639 1.0833.4183.1794-.1694.0103-.3133-.1108-.4607.4428.1615.6709.37.911.5759.0814-.1082.2068-.1875.0554-.4486.3143.1785.5511.3888.7263.6244.1945-.122.1159-.2888.1169-.4426.3267.2618.534.5404.7879.8124.0511-.0366.0959-.161.1354-.3577.7794.7448 1.8807 2.6208.2831 3.3646-1.3589-1.1039-2.9817-1.9064-4.78-2.5086 2.3115 1.174 3.6556 2.1239 4.392 2.9328-.3773 1.4895-2.3455 1.5575-3.0651 1.5157.1473-.0676.2703-.1485.3139-.2728-.1806-.1264-.8209-.0134-1.2679-.2607zm2.8175 1.1334c.7881.1304-3.7769 4.0567-3.8472 3.1809-.0719-2.2846 1.7079-3.5367 3.8472-3.1809zm-4.847 8.7567c-1.1094-.8789-1.4668-3.4529.5901-4.6097 1.2394-.3273.4184 5.051-.5901 4.6097zm4.2656 4.5989c-.6257.3719-2.1452.2187-3.2252-1.3095-.7283-1.2823-.6345-2.5872-.123-2.9705.7648-.4589 1.9464.1609 2.8559 1.2003.7923.9405 1.1536 2.5927.4923 3.0797zm-1.2415-5.6086c-1.1481-.7409-1.3551-2.4203-.4623-3.7511.8928-1.3307 2.5472-1.8089 3.6952-1.068 1.1481.7409 1.3551 2.4203.4623 3.7509-.8926 1.3308-2.5471 1.809-3.6952 1.0682zm4.7948 8.2279c-1.3763.0584-2.7258-1.1105-2.7081-1.5157-.0206-.594 1.6758-1.0578 2.782-1.0306 1.1131-.0479 2.6068.3531 2.6097.8851.0184.5166-1.3547 1.6838-2.6836 1.6612zm2.7584-5.8578c.0081 1.3899-1.226 2.5225-2.7562 2.5299-1.5302.0073-2.7773-1.1135-2.7854-2.5033v-.0265c-.008-1.3899 1.2259-2.5226 2.7562-2.5299 1.5302-.0073 2.7773 1.1134 2.7853 2.5033a.7794.7794 0 0 1 .0001.0265zm3.855 2.0029c-1.186 1.6208-2.7916 1.684-3.3896 1.2325-.6255-.5811-.148-2.3854.7094-3.3747v-.0003c.9812-1.0912 2.0302-1.8037 2.7609-1.2469.4919.4828.7805 2.3008-.0807 3.3894zm1.0724-3.4301c-1.0086.4413-1.8298-4.9372-.5901-4.61 2.0568 1.1569 1.6994 3.731.5901 4.61zm-.0256-8.3279h.2985v-.5304h.2986c.1502 0 .2053.0624.2262.2052.0152.1088.0113.2395.0477.3253h.2984c-.0533-.0763-.0515-.2358-.0571-.3213-.0097-.1373-.0513-.2796-.1977-.3176v-.0037c.1502-.061.2149-.1807.2149-.341 0-.2048-.1539-.3738-.3974-.3738h-.732v1.3573zm.2985-1.1255h.3269c.1333 0 .2054.0573.2054.188 0 .1369-.0721.1942-.2054.1942H20.03v-.3822zm-1.0337.4633c0 .7009.5682 1.2694 1.2695 1.2694s1.2695-.5684 1.2695-1.2694c0-.7013-.5683-1.2697-1.2695-1.2697-.7013 0-1.2695.5684-1.2695 1.2697zm2.3275 0c0 .5845-.4737 1.058-1.058 1.058s-1.058-.4735-1.058-1.058c0-.5849.4737-1.058 1.058-1.058s1.058.4731 1.058 1.058z"},"reddit":{"title":"Reddit","hex":"#ff4500","path":"M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z"},"signal":{"title":"Signal","hex":"#3b45fd","path":"M12 0q-.934 0-1.83.139l.17 1.111a11 11 0 0 1 3.32 0l.172-1.111A12 12 0 0 0 12 0M9.152.34A12 12 0 0 0 5.77 1.742l.584.961a10.8 10.8 0 0 1 3.066-1.27zm5.696 0-.268 1.094a10.8 10.8 0 0 1 3.066 1.27l.584-.962A12 12 0 0 0 14.848.34M12 2.25a9.75 9.75 0 0 0-8.539 14.459c.074.134.1.292.064.441l-1.013 4.338 4.338-1.013a.62.62 0 0 1 .441.064A9.7 9.7 0 0 0 12 21.75c5.385 0 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25m-7.092.068a12 12 0 0 0-2.59 2.59l.909.664a11 11 0 0 1 2.345-2.345zm14.184 0-.664.909a11 11 0 0 1 2.345 2.345l.909-.664a12 12 0 0 0-2.59-2.59M1.742 5.77A12 12 0 0 0 .34 9.152l1.094.268a10.8 10.8 0 0 1 1.269-3.066zm20.516 0-.961.584a10.8 10.8 0 0 1 1.27 3.066l1.093-.268a12 12 0 0 0-1.402-3.383M.138 10.168A12 12 0 0 0 0 12q0 .934.139 1.83l1.111-.17A11 11 0 0 1 1.125 12q0-.848.125-1.66zm23.723.002-1.111.17q.125.812.125 1.66c0 .848-.042 1.12-.125 1.66l1.111.172a12.1 12.1 0 0 0 0-3.662M1.434 14.58l-1.094.268a12 12 0 0 0 .96 2.591l-.265 1.14 1.096.255.36-1.539-.188-.365a10.8 10.8 0 0 1-.87-2.35m21.133 0a10.8 10.8 0 0 1-1.27 3.067l.962.584a12 12 0 0 0 1.402-3.383zm-1.793 3.848a11 11 0 0 1-2.345 2.345l.664.909a12 12 0 0 0 2.59-2.59zm-19.959 1.1L.357 21.48a1.8 1.8 0 0 0 2.162 2.161l1.954-.455-.256-1.095-1.953.455a.675.675 0 0 1-.81-.81l.454-1.954zm16.832 1.769a10.8 10.8 0 0 1-3.066 1.27l.268 1.093a12 12 0 0 0 3.382-1.402zm-10.94.213-1.54.36.256 1.095 1.139-.266c.814.415 1.683.74 2.591.961l.268-1.094a10.8 10.8 0 0 1-2.35-.869zm3.634 1.24-.172 1.111a12.1 12.1 0 0 0 3.662 0l-.17-1.111q-.812.125-1.66.125a11 11 0 0 1-1.66-.125"},"snapchat":{"title":"Snapchat","hex":"#fffc00","path":"M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z"},"soundcloud":{"title":"SoundCloud","hex":"#ff5500","path":"M23.999 14.165c-.052 1.796-1.612 3.169-3.4 3.169h-8.18a.68.68 0 0 1-.675-.683V7.862a.747.747 0 0 1 .452-.724s.75-.513 2.333-.513a5.364 5.364 0 0 1 2.763.755 5.433 5.433 0 0 1 2.57 3.54c.282-.08.574-.121.868-.12.884 0 1.73.358 2.347.992s.948 1.49.922 2.373ZM10.721 8.421c.247 2.98.427 5.697 0 8.672a.264.264 0 0 1-.53 0c-.395-2.946-.22-5.718 0-8.672a.264.264 0 0 1 .53 0ZM9.072 9.448c.285 2.659.37 4.986-.006 7.655a.277.277 0 0 1-.55 0c-.331-2.63-.256-5.02 0-7.655a.277.277 0 0 1 .556 0Zm-1.663-.257c.27 2.726.39 5.171 0 7.904a.266.266 0 0 1-.532 0c-.38-2.69-.257-5.21 0-7.904a.266.266 0 0 1 .532 0Zm-1.647.77a26.108 26.108 0 0 1-.008 7.147.272.272 0 0 1-.542 0 27.955 27.955 0 0 1 0-7.147.275.275 0 0 1 .55 0Zm-1.67 1.769c.421 1.865.228 3.5-.029 5.388a.257.257 0 0 1-.514 0c-.21-1.858-.398-3.549 0-5.389a.272.272 0 0 1 .543 0Zm-1.655-.273c.388 1.897.26 3.508-.01 5.412-.026.28-.514.283-.54 0-.244-1.878-.347-3.54-.01-5.412a.283.283 0 0 1 .56 0Zm-1.668.911c.4 1.268.257 2.292-.026 3.572a.257.257 0 0 1-.514 0c-.241-1.262-.354-2.312-.023-3.572a.283.283 0 0 1 .563 0Z"},"spotify":{"title":"Spotify","hex":"#1ed760","path":"M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"},"steam":{"title":"Steam","hex":"#000000","path":"M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z"},"stripe":{"title":"Stripe","hex":"#635bff","path":"M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.594-7.305h.003z"},"telegram":{"title":"Telegram","hex":"#26a5e4","path":"M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"},"threads":{"title":"Threads","hex":"#000000","path":"M18.263 11.097c-.03-3.486-1.92-5.586-5.111-5.586-2.13 0-3.922.963-4.863 2.499l2.062 1.438c.535-.843 1.272-1.543 2.628-1.543 1.528 0 2.318.85 2.544 2.431a15 15 0 0 0-2.236-.173c-4.125 0-6.068 1.867-6.068 4.336s1.943 3.99 4.804 3.99c3.139 0 5.013-2.115 5.781-4.735.798.361 1.348 1.204 1.348 2.47 0 3.387-3.907 5.232-7.22 5.232-4.885 0-8.077-3.207-8.077-8.424 0-6.392 4.223-10.487 9.9-10.487 3.808 0 5.69 1.671 6.97 3.914l2.108-1.475C21.44 2.078 18.331 0 13.663 0 6.227 0 1.168 5.277 1.168 12.934c0 7 4.953 11.066 10.856 11.066 4.878 0 9.809-2.846 9.809-7.716 0-2.545-1.46-4.231-3.569-5.187m-6.33 4.855c-1.077 0-2.026-.512-2.026-1.453 0-1.483 1.822-1.934 3.606-1.934.678 0 1.34.045 1.927.173-.422 1.927-1.671 3.215-3.508 3.214Z"},"tiktok":{"title":"TikTok","hex":"#000000","path":"M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"},"tumblr":{"title":"Tumblr","hex":"#36465d","path":"M14.563 24c-5.093 0-7.031-3.756-7.031-6.411V9.747H5.116V6.648c3.63-1.313 4.512-4.596 4.71-6.469C9.84.051 9.941 0 9.999 0h3.517v6.114h4.801v3.633h-4.82v7.47c.016 1.001.375 2.371 2.207 2.371h.09c.631-.02 1.486-.205 1.936-.419l1.156 3.425c-.436.636-2.4 1.374-4.156 1.404h-.178l.011.002z"},"twitch":{"title":"Twitch","hex":"#9146ff","path":"M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"},"ubuntu":{"title":"Ubuntu","hex":"#e95420","path":"M17.61.455a3.41 3.41 0 0 0-3.41 3.41 3.41 3.41 0 0 0 3.41 3.41 3.41 3.41 0 0 0 3.41-3.41 3.41 3.41 0 0 0-3.41-3.41zM12.92.8C8.923.777 5.137 2.941 3.148 6.451a4.5 4.5 0 0 1 .26-.007 4.92 4.92 0 0 1 2.585.737A8.316 8.316 0 0 1 12.688 3.6 4.944 4.944 0 0 1 13.723.834 11.008 11.008 0 0 0 12.92.8zm9.226 4.994a4.915 4.915 0 0 1-1.918 2.246 8.36 8.36 0 0 1-.273 8.303 4.89 4.89 0 0 1 1.632 2.54 11.156 11.156 0 0 0 .559-13.089zM3.41 7.932A3.41 3.41 0 0 0 0 11.342a3.41 3.41 0 0 0 3.41 3.409 3.41 3.41 0 0 0 3.41-3.41 3.41 3.41 0 0 0-3.41-3.41zm2.027 7.866a4.908 4.908 0 0 1-2.915.358 11.1 11.1 0 0 0 7.991 6.698 11.234 11.234 0 0 0 2.422.249 4.879 4.879 0 0 1-.999-2.85 8.484 8.484 0 0 1-.836-.136 8.304 8.304 0 0 1-5.663-4.32zm11.405.928a3.41 3.41 0 0 0-3.41 3.41 3.41 3.41 0 0 0 3.41 3.41 3.41 3.41 0 0 0 3.41-3.41 3.41 3.41 0 0 0-3.41-3.41z"},"vimeo":{"title":"Vimeo","hex":"#1ab7ea","path":"M23.9765 6.4168c-.105 2.338-1.739 5.5429-4.894 9.6088-3.2679 4.247-6.0258 6.3699-8.2898 6.3699-1.409 0-2.578-1.294-3.553-3.881l-1.9179-7.1138c-.719-2.584-1.488-3.878-2.312-3.878-.179 0-.806.378-1.8809 1.132l-1.129-1.457a315.06 315.06 0 003.501-3.1279c1.579-1.368 2.765-2.085 3.5539-2.159 1.867-.18 3.016 1.1 3.447 3.838.465 2.953.789 4.789.971 5.5069.5389 2.45 1.1309 3.674 1.7759 3.674.502 0 1.256-.796 2.265-2.385 1.004-1.589 1.54-2.797 1.612-3.628.144-1.371-.395-2.061-1.614-2.061-.574 0-1.167.121-1.777.391 1.186-3.8679 3.434-5.7568 6.7619-5.6368 2.4729.06 3.6279 1.664 3.4929 4.7969z"},"whatsapp":{"title":"WhatsApp","hex":"#25d366","path":"M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"},"wikipedia":{"title":"Wikipedia","hex":"#000000","path":"M12.09 13.119c-.936 1.932-2.217 4.548-2.853 5.728-.616 1.074-1.127.931-1.532.029-1.406-3.321-4.293-9.144-5.651-12.409-.251-.601-.441-.987-.619-1.139-.181-.15-.554-.24-1.122-.271C.103 5.033 0 4.982 0 4.898v-.455l.052-.045c.924-.005 5.401 0 5.401 0l.051.045v.434c0 .119-.075.176-.225.176l-.564.031c-.485.029-.727.164-.727.436 0 .135.053.33.166.601 1.082 2.646 4.818 10.521 4.818 10.521l.136.046 2.411-4.81-.482-1.067-1.658-3.264s-.318-.654-.428-.872c-.728-1.443-.712-1.518-1.447-1.617-.207-.023-.313-.05-.313-.149v-.468l.06-.045h4.292l.113.037v.451c0 .105-.076.15-.227.15l-.308.047c-.792.061-.661.381-.136 1.422l1.582 3.252 1.758-3.504c.293-.64.233-.801.111-.947-.07-.084-.305-.22-.812-.24l-.201-.021c-.052 0-.098-.015-.145-.051-.045-.031-.067-.076-.067-.129v-.427l.061-.045c1.247-.008 4.043 0 4.043 0l.059.045v.436c0 .121-.059.178-.193.178-.646.03-.782.095-1.023.439-.12.186-.375.589-.646 1.039l-2.301 4.273-.065.135 2.792 5.712.17.048 4.396-10.438c.154-.422.129-.722-.064-.895-.197-.172-.346-.273-.857-.295l-.42-.016c-.061 0-.105-.014-.152-.045-.043-.029-.072-.075-.072-.119v-.436l.059-.045h4.961l.041.045v.437c0 .119-.074.18-.209.18-.648.03-1.127.18-1.443.421-.314.255-.557.616-.736 1.067 0 0-4.043 9.258-5.426 12.339-.525 1.007-1.053.917-1.503-.031-.571-1.171-1.773-3.786-2.646-5.71l.053-.036z"},"x":{"title":"X","hex":"#000000","path":"M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z"},"ycombinator":{"title":"Y Combinator","hex":"#f0652f","path":"M0 24V0h24v24H0zM6.951 5.896l4.112 7.708v5.064h1.583v-4.972l4.148-7.799h-1.749l-2.457 4.875c-.372.745-.688 1.434-.688 1.434s-.297-.708-.651-1.434L8.831 5.896h-1.88z"},"youtube":{"title":"YouTube","hex":"#ff0000","path":"M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"}};
++ const FA = {"youtube":"youtube","square-youtube":"youtube","twitch":"twitch","discord":"discord","github":"github","square-github":"github","x-twitter":"x","square-x-twitter":"x","twitter":"x","kick":"kick","kickstarter-k":"kick","kickstarter":"kickstarter","google":"google","tiktok":"tiktok","threads":"threads","spotify":"spotify","soundcloud":"soundcloud","reddit":"reddit","reddit-alien":"reddit","tumblr":"tumblr","hacker-news":"ycombinator","line":"line","paypal":"paypal","patreon":"patreon","mastodon":"mastodon","linux":"linux","instagram":"instagram","facebook":"facebook","facebook-f":"facebook","cloudflare":"cloudflare","brave":"brave","bluesky":"bluesky","bitcoin":"bitcoin","apple":"apple","telegram":"telegram","whatsapp":"whatsapp","steam":"steam","snapchat":"snapchat","pinterest":"pinterest","vimeo":"vimeo","vimeo-v":"vimeo","npm":"npm","node":"nodedotjs","node-js":"nodedotjs","docker":"docker","python":"python","android":"android","chrome":"googlechrome","firefox":"firefoxbrowser","firefox-browser":"firefoxbrowser","gitlab":"gitlab","ubuntu":"ubuntu","raspberry":"raspberrypi","raspberry-pi":"raspberrypi","stripe":"stripe","cc-stripe":"stripe","signal":"signal","matrix":"matrix","medium":"medium","wikipedia":"wikipedia","wikipedia-w":"wikipedia","ethereum":"ethereum"};
++ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
++ const get = (slug) => (has(BRANDS, slug) ? BRANDS[slug] : null);
++ const fromFa = (name) => { const n = String(name || '').replace(/^fa-/, ''); return has(FA, n) ? FA[n] : null; };
++ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
++ function svg(slug, { size = 24, title = '', color = 'currentColor' } = {}) {
++ const b = get(slug);
++ if (!b) return '';
++ const label = title ? ` role="img" aria-label="${esc(title)}"><title>${esc(title)}</title>` : ' aria-hidden="true">';
++ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="${Number(size) || 24}" height="${Number(size) || 24}" fill="${color === 'brand' ? b.hex : esc(color)}"${label}<path d="${b.path}"/></svg>`;
++ }
++ return { BRANDS, FA, get, fromFa, svg, SLUGS: Object.keys(BRANDS) };
++});
ddiff --git a/browser/shared/dock-actor.js b/browser/shared/dock-actor.js
nnew file mode 100644
iindex 0000000..82e9c2b
---- /dev/null
++++ b/browser/shared/dock-actor.js
@@@ -0,0 +1,381 @@
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — the Dock's Actor block (Actor v3 plan, stage A3; redrawn 2026-11-10)
++//
++// Your Actor at the top of the Dock's Agents tab, on every OpenVibe site. dock.js loads this file the first time that
++// tab opens:
++// OpenVibeDockActor.mount(container, { token, base, active }) → { refresh(), destroy() }
++//
++// It reads OpenVibe.Actor's GET /api/v1/me/actor with the person's Network token (scope actor.me.read); the answers,
++// pause, resume and tell are POSTs with actor.me.write. On a site every call goes through Network's same-origin relay
++// (/api/frame/actor…) because every site's connect-src allows openvibe.network and refuses openvibe.actor; `base`
++// reaches Actor itself in a test, and `frameBase` names the relay. Nothing is asked without a token.
++//
++// The look (the owner, 2026-11-10: "more intuitive, minimalist, elegant"): one row and one line to type in.
++// - Who: the face with a ring in the colour of its state, the name, and one quiet line (the state, today's spend
++// against the cap). The row is the way to the Actor's page; there is no separate "Open".
++// - One action beside it, the one that fits: "Resume" in the accent when it is paused, a quiet pause when it runs.
++// - What it does, only when there is something to say: the step it is on (a soft pulse), what it looks at, the
++// steps before it, and what comes next, as a short line of dots. A paused Actor shows none of this: the row says it.
++// - What it asks you, with its own buttons, in the warning tone (a lean and a glow, never a bar).
++// - One field to tell it something, with send inside the field.
++// A person with no Actor sees nothing here. Everything people or the Actor wrote is set as text, never markup. The
++// block is built once: a refresh repaints what changed and never touches the line being typed. Every control is 44 px.
++// ═══════════════════════════════════════════════════════════════
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OpenVibeDockActor) return;
++
++ const POLL_MS = 15000;
++ const ACTOR_REV = { 'ov-window.js': 'd3d8f9e3e6d2', 'agent-sheet.js': '8d150490ad64' };
++ const SCRIPT_BASE = document.currentScript && document.currentScript.src ? new URL('.', document.currentScript.src).href : '/shared/';
++ const STATE = { working: 'Working', resting: 'Resting', paused: 'Paused', waiting: 'Waiting for you' };
++ // The lines Actor sends as "the current step" when there is none: the state already says them.
++ const NOT_A_STEP = /^(paused|resting\b.*)\.?$/i;
++ const ICON = {
++ play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.5 5.6v12.8a.9.9 0 0 0 1.37.77l10.3-6.4a.9.9 0 0 0 0-1.54l-10.3-6.4a.9.9 0 0 0-1.37.77z" fill="currentColor"/></svg>',
++ pause: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6.5" y="5" width="4" height="14" rx="1.3" fill="currentColor"/><rect x="13.5" y="5" width="4" height="14" rx="1.3" fill="currentColor"/></svg>',
++ go: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 6.5 15 12l-5.5 5.5"/></svg>',
++ send: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5.5M6 11l6-6 6 6"/></svg>',
++ ask: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.2 9.3a2.9 2.9 0 1 1 4.4 2.5c-.9.6-1.6 1.2-1.6 2.3"/><path d="M12 17.6v.1"/></svg>',
++ past: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12a8 8 0 1 0 2.6-5.9"/><path d="M4 5v3.6h3.6"/><path d="M12 8.2V12l2.6 1.7"/></svg>',
++ };
++
++ function styles(doc) {
++ const d = doc || document;
++ if (!d || d.getElementById('ovdac-styles')) return;
++ const st = d.createElement('style');
++ st.id = 'ovdac-styles';
++ st.textContent = `
++ .ovdac { display: none; flex: none; padding: 12px 12px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08));
++ --ovdac-ok: var(--success, #22c55e); --ovdac-warn: var(--warning, #f5a524); --ovdac-ink: var(--text-primary, #ececf1); }
++ .ovdac.on { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
++ .ovdac [hidden] { display: none !important; }
++ .ovdac svg { width: 18px; height: 18px; flex: none; }
++ .ovdac .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++
++ .ovdac-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
++ .ovdac-id { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 2px 8px 2px 4px; margin-left: -4px; border-radius: 14px;
++ color: inherit; text-decoration: none; transition: background .15s ease; }
++ .ovdac-id:hover { background: color-mix(in srgb, var(--ovdac-ink) 5%, transparent); }
++ .ovdac-id:focus-visible, .ovdac-go:focus-visible, .ovdac-btn:focus-visible, .ovdac-link:focus-visible, .ovdac-send:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 2px; }
++ .ovdac-face { position: relative; flex: none; width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center;
++ background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font-weight: 800; font-size: 16px; transition: filter .2s ease, opacity .2s ease; }
++ .ovdac-face img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
++ .ovdac-face::after { content: ''; position: absolute; inset: -4px; border-radius: 50%; border: 2px solid transparent; transition: border-color .2s ease; }
++ .ovdac[data-state="working"] .ovdac-face::after { border-color: var(--ovdac-ok); animation: ovdac-ring 2.4s ease-in-out infinite; }
++ .ovdac[data-state="waiting"] .ovdac-face::after { border-color: var(--ovdac-warn); }
++ .ovdac[data-state="paused"] .ovdac-face { filter: saturate(.5); opacity: .78; }
++ .ovdac-who { min-width: 0; display: grid; gap: 1px; }
++ .ovdac-name { display: flex; align-items: center; gap: 1px; min-width: 0; font-weight: 750; font-size: 15px; line-height: 1.25; color: var(--ovdac-ink); }
++ .ovdac-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++ .ovdac-name svg { width: 16px; height: 16px; color: var(--text-muted, #8b8b96); transition: transform .18s ease, color .18s ease; }
++ .ovdac-id:hover .ovdac-name svg { transform: translateX(2px); color: var(--text-secondary, #b6b6c0); }
++ .ovdac-line { display: flex; gap: 6px; min-width: 0; font-size: 12.5px; line-height: 1.35; color: var(--text-muted, #8b8b96); white-space: nowrap; }
++ .ovdac-state { flex: none; font-weight: 650; color: var(--text-secondary, #b6b6c0); }
++ .ovdac[data-state="working"] .ovdac-state { color: color-mix(in srgb, var(--ovdac-ok) 72%, var(--ovdac-ink)); }
++ .ovdac[data-state="waiting"] .ovdac-state { color: color-mix(in srgb, var(--ovdac-warn) 76%, var(--ovdac-ink)); }
++ .ovdac-spend { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
++ .ovdac-spend::before { content: '·'; margin-right: 6px; }
++
++ .ovdac-go { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 44px; height: 44px; padding: 0; border-radius: 999px;
++ border: 1px solid var(--border, rgba(255,255,255,.14)); background: transparent; color: var(--text-secondary, #b6b6c0);
++ font: inherit; font-size: 13.5px; font-weight: 700; cursor: pointer; white-space: nowrap; transition: background .15s ease, color .15s ease, transform .12s ease, box-shadow .2s ease; }
++ .ovdac-go:hover { color: var(--ovdac-ink); background: color-mix(in srgb, var(--ovdac-ink) 7%, transparent); }
++ .ovdac-go.is-main { padding: 0 18px 0 13px; border-color: transparent; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff);
++ box-shadow: 0 8px 20px -10px var(--accent, #3b82f6); }
++ .ovdac-go.is-main:hover { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); filter: brightness(1.08); }
++ .ovdac-go:active, .ovdac-btn:active, .ovdac-send:active { transform: scale(.96); }
++ .ovdac .is-busy { opacity: .6; pointer-events: none; animation: ovdac-pulse 1.1s ease-in-out infinite; }
++
++ .ovdac-rail { list-style: none; margin: 0; padding: 2px 4px 2px 6px; display: grid; gap: 9px; }
++ .ovdac-rail li { position: relative; padding-left: 22px; font-size: 12.5px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); overflow-wrap: anywhere; }
++ .ovdac-rail li::before { content: ''; position: absolute; left: 1px; top: .36em; width: 6px; height: 6px; border-radius: 50%; border: 1.5px solid var(--text-muted, #8b8b96); }
++ .ovdac-rail li:not(:last-child)::after { content: ''; position: absolute; left: 5px; top: calc(.36em + 13px); bottom: -8px; width: 1px;
++ background: color-mix(in srgb, var(--ovdac-ink) 14%, transparent); }
++ .ovdac-rail .is-live { font-size: 13.5px; font-weight: 650; color: var(--ovdac-ink); }
++ .ovdac-rail .is-live::before { border-color: var(--ovdac-ok); background: var(--ovdac-ok); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ovdac-ok) 20%, transparent); animation: ovdac-pulse 1.8s ease-in-out infinite; }
++ .ovdac-rail .is-next { color: var(--text-muted, #8b8b96); }
++ .ovdac-rail .is-next::before { border-style: dashed; }
++ .ovdac-rail b { font-weight: 650; }
++ .ovdac-look { display: block; margin-top: 2px; font-size: 12px; font-weight: 400; color: var(--text-muted, #8b8b96); }
++
++ .ovdac-ask { display: grid; gap: 10px; padding: 12px 14px 10px; border-radius: 16px;
++ background: linear-gradient(135deg, color-mix(in srgb, var(--ovdac-warn) 15%, transparent), color-mix(in srgb, var(--ovdac-warn) 5%, transparent) 62%);
++ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovdac-warn) 22%, transparent), 0 12px 28px -20px var(--ovdac-warn); }
++ .ovdac-ask-k { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; color: color-mix(in srgb, var(--ovdac-warn) 74%, var(--ovdac-ink)); }
++ .ovdac-ask-k i { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: color-mix(in srgb, var(--ovdac-warn) 24%, transparent); }
++ .ovdac-ask-k svg { width: 16px; height: 16px; }
++ .ovdac-ask p { margin: 0; font-size: 14px; line-height: 1.45; color: var(--ovdac-ink); overflow-wrap: anywhere; }
++ .ovdac-ask .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
++ .ovdac-btn { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 16px; border-radius: 999px;
++ border: 1px solid color-mix(in srgb, var(--ovdac-ink) 18%, transparent); background: transparent; color: var(--ovdac-ink);
++ font: inherit; font-size: 13.5px; font-weight: 700; cursor: pointer; white-space: nowrap; transition: background .15s ease, transform .12s ease; }
++ .ovdac-btn:hover { background: color-mix(in srgb, var(--ovdac-ink) 8%, transparent); }
++ .ovdac-btn.is-yes { background: var(--accent-strong, var(--accent, #2563eb)); border-color: transparent; color: var(--on-accent-strong, #fff); }
++ .ovdac-btn.is-yes:hover { background: var(--accent-strong, var(--accent, #2563eb)); filter: brightness(1.08); }
++ .ovdac-btn.is-quiet { padding: 0 10px; border-color: transparent; color: var(--text-secondary, #b6b6c0); font-weight: 650; }
++ .ovdac-more { display: inline-flex; align-items: center; min-height: 44px; margin: -8px 0 -8px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); text-decoration: underline; text-underline-offset: 3px; }
++
++ .ovdac-tell { position: relative; display: flex; }
++ .ovdac-tell input { flex: 1; min-width: 0; height: 52px; box-sizing: border-box; padding: 0 58px 0 18px; border-radius: 26px;
++ border: 1px solid var(--border, rgba(255,255,255,.14)); background: var(--bg-input, color-mix(in srgb, var(--ovdac-ink) 4%, transparent));
++ color: var(--ovdac-ink); font: inherit; font-size: 14.5px; outline: none; transition: border-color .15s ease, box-shadow .15s ease; }
++ .ovdac-tell input::placeholder { color: var(--text-muted, #8b8b96); }
++ .ovdac-tell input:focus { border-color: color-mix(in srgb, var(--accent, #3b82f6) 62%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); }
++ .ovdac-send { position: absolute; right: 4px; top: 4px; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%;
++ background: color-mix(in srgb, var(--ovdac-ink) 8%, transparent); color: var(--text-muted, #8b8b96); cursor: pointer;
++ transition: background .18s ease, color .18s ease, transform .12s ease, box-shadow .2s ease; }
++ .ovdac-tell.has-text .ovdac-send { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); box-shadow: 0 6px 16px -8px var(--accent, #3b82f6); }
++
++ .ovdac-foot { display: flex; align-items: center; gap: 10px; min-width: 0; margin-top: -6px; }
++ .ovdac-link { flex: none; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 8px; margin-left: -8px; border: 0; border-radius: 10px;
++ background: none; color: var(--text-muted, #8b8b96); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; transition: color .15s ease; }
++ .ovdac-link:hover, .ovdac-link[aria-expanded="true"] { color: var(--ovdac-ink); }
++ .ovdac-link svg { width: 15px; height: 15px; }
++ .ovdac-note { flex: 1; min-width: 0; text-align: right; font-size: 12.5px; line-height: 1.35; color: var(--text-muted, #8b8b96); overflow-wrap: anywhere; }
++ .ovdac-note.is-ok { color: color-mix(in srgb, var(--ovdac-ok) 70%, var(--ovdac-ink)); }
++ .ovdac-note.is-bad { color: color-mix(in srgb, var(--danger, #ef4444) 74%, var(--ovdac-ink)); }
++ .ovdac-hist { list-style: none; margin: -4px 0 4px; padding: 0; display: grid; gap: 6px; font-size: 12.5px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); overflow-wrap: anywhere; }
++ .ovdac-hist b { margin-right: 6px; font-weight: 650; color: var(--text-muted, #8b8b96); }
++
++ @keyframes ovdac-pulse { 50% { opacity: .45; } }
++ @keyframes ovdac-ring { 50% { opacity: .35; transform: scale(1.06); } }
++ @media (pointer: coarse) { .ovdac-tell input { font-size: 16px; } }
++ html[data-ov-motion=off] .ovdac *, html[data-ov-motion=off] .ovdac *::before, html[data-ov-motion=off] .ovdac *::after { animation: none !important; transition: none !important; }
++ `;
++ (d.head || d.documentElement).appendChild(st);
++ }
++
++ const money = (n) => { const v = Number(n) || 0; return v < 0.01 && v > 0 ? `$${v.toFixed(3)}` : `$${v.toFixed(2)}`; };
++ /** "in 40 min", "in 3 h", "tomorrow": when the next goal is due; '' when unknown or already due. */
++ function due(at, now = Date.now()) {
++ const t = Date.parse(at || '');
++ if (!Number.isFinite(t)) return '';
++ const min = Math.round((t - now) / 60000);
++ if (min < 1) return '';
++ if (min < 60) return `in ${min} min`;
++ if (min < 24 * 60) return `in ${Math.round(min / 60)} h`;
++ return min < 48 * 60 ? 'tomorrow' : `in ${Math.round(min / 1440)} days`;
++ }
++
++ function mount(container, opts = {}) {
++ const d = container.ownerDocument || document;
++ styles(d);
++ const o = { ...opts };
++ const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
++ const icon = (name) => { const t = d.createElement('template'); t.innerHTML = ICON[name]; return t.content.firstChild; };
++ const tip = (n, text) => { if (root.OVTip) n.setAttribute('data-ov-tip', text); else n.title = text; };
++ let gone = false, timer = 0, busy = false, shown = false, last = '', actor = null, noteTimer = 0;
++ container.replaceChildren();
++ container.classList.add('ovdac');
++ // Network's frame relay on a site; an explicit base reaches Actor itself (a test).
++ const direct = o.base ? String(o.base).replace(/\/+$/, '') : '';
++ const frameBase = String(o.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
++ const endpoint = (path) => (direct ? direct + '/api/v1/me/actor' + path : frameBase + '/actor' + path);
++ // The way to the Actor's page is a navigation, never a fetch: Actor's own site, wherever the API is.
++ const openBase = direct || 'https://openvibe.actor';
++
++ async function api(path, init = {}) {
++ const t = typeof o.token === 'function' ? await o.token() : o.token;
++ if (!t) throw Object.assign(new Error('Sign in to use your Actor'), { status: 401 });
++ const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), authorization: `Bearer ${t}` };
++ const r = await fetch(endpoint(path), { method: init.method || 'GET', headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });
++ const b = await r.json().catch(() => ({}));
++ if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status });
++ return b;
++ }
++
++ function stateOf(a) {
++ if (Array.isArray(a.asks) && a.asks.length) return 'waiting';
++ return STATE[a.status] ? a.status : 'paused';
++ }
++
++ // ── The parts, built once ────────────────────────────────────────────────────────────────────────────────
++ const head = h('div', 'ovdac-head');
++ const id = h('a', 'ovdac-id'); id.href = openBase + '/you'; id.target = '_blank'; id.rel = 'noopener';
++ function loadScript(file, ready) {
++ if (ready()) return Promise.resolve(true);
++ return new Promise((resolve) => {
++ const sid = `ovdac-${file}`;
++ let script = d.getElementById(sid);
++ if (!script) { script = d.createElement('script'); script.id = sid; script.src = SCRIPT_BASE + file + (ACTOR_REV[file] ? `?v=${ACTOR_REV[file]}` : ''); script.async = true; d.head.appendChild(script); }
++ script.addEventListener('load', () => resolve(!!ready()), { once: true });
++ script.addEventListener('error', () => resolve(false), { once: true });
++ });
++ }
++ // The row opens its sheet (agent-sheet.js in the one window: now, asks, work, schedule, memory, computer); the
++ // Actor's page is the sheet's "Open". A modified click, or a sheet that cannot load, goes to the page as before.
++ id.addEventListener('click', async (e) => {
++ if (!actor || !(actor.agent_id || actor.id) || e.metaKey || e.ctrlKey || e.shiftKey || e.button) return;
++ e.preventDefault();
++ const ok = (await Promise.all([loadScript('ov-window.js', () => !!root.OVWindow), loadScript('agent-sheet.js', () => !!root.OVAgentSheet)])).every(Boolean);
++ if (!ok) { d.defaultView.open(id.href, '_blank', 'noopener'); return; }
++ root.OVAgentSheet.open({ kind: 'actor', id: actor.agent_id || actor.id, name: actor.name || 'Your Actor', token: o.token,
++ frameBase: o.frameBase, base: direct || null, from: id, openHref: openBase + '/you' });
++ });
++ const face = h('span', 'ovdac-face');
++ const who = h('span', 'ovdac-who');
++ const name = h('span', 'ovdac-name'); const nameText = h('span'); name.append(nameText, icon('go'));
++ const line = h('span', 'ovdac-line'); const stateText = h('span', 'ovdac-state'); const spend = h('span', 'ovdac-spend'); line.append(stateText, spend);
++ who.append(name, line); id.append(face, who);
++ const go = h('button', 'ovdac-go'); go.type = 'button';
++ go.addEventListener('click', () => {
++ if (!actor) return;
++ const paused = actor.status === 'paused';
++ act(paused ? '/resume' : '/pause', {}, go, paused ? 'Resumed.' : 'Paused.');
++ });
++ head.append(id, go);
++
++ const rail = h('ul', 'ovdac-rail'); rail.hidden = true;
++ const askBox = h('div', 'ovdac-ask'); askBox.hidden = true;
++
++ const form = h('form', 'ovdac-tell');
++ const input = h('input'); input.type = 'text'; input.maxLength = 2000; input.autocomplete = 'off'; input.enterKeyHint = 'send';
++ input.placeholder = 'Tell your Actor something'; input.setAttribute('aria-label', 'Tell your Actor something');
++ const send = h('button', 'ovdac-send'); send.type = 'submit'; send.setAttribute('aria-label', 'Send'); send.append(icon('send'));
++ input.addEventListener('input', () => form.classList.toggle('has-text', !!input.value.trim()));
++ form.append(input, send);
++ form.addEventListener('submit', (e) => {
++ e.preventDefault();
++ const text = input.value.trim();
++ if (!text) { input.focus(); return; }
++ const paused = actor && actor.status === 'paused';
++ act('/tell', { text }, send, paused ? 'Sent. Your Actor reads it once you resume it.' : 'Sent. Your Actor reads it at its next step.')
++ .then((ok) => { if (ok) { input.value = ''; form.classList.remove('has-text'); } });
++ });
++
++ const foot = h('div', 'ovdac-foot');
++ const past = h('button', 'ovdac-link'); past.type = 'button'; past.setAttribute('aria-expanded', 'false'); past.append(icon('past'), d.createTextNode('Recent history'));
++ const note = h('div', 'ovdac-note'); note.setAttribute('role', 'status'); note.setAttribute('aria-live', 'polite');
++ const hist = h('ul', 'ovdac-hist'); hist.hidden = true;
++ foot.append(past, note);
++ // The button that opens the list closes it.
++ past.addEventListener('click', async () => {
++ if (!hist.hidden) { hist.hidden = true; past.setAttribute('aria-expanded', 'false'); return; }
++ past.disabled = true; past.classList.add('is-busy');
++ const one = (text) => { hist.replaceChildren(h('li', '', text)); };
++ try {
++ const t = typeof o.token === 'function' ? await o.token() : o.token;
++ if (!t) throw new Error('Sign in to read history.');
++ const url = String(o.networkBase || 'https://openvibe.network').replace(/\/+$/, '') + '/api/v1/history/agents/recent?limit=10';
++ const response = await fetch(url, { headers: { accept: 'application/json', authorization: `Bearer ${t}` }, credentials: 'omit' });
++ const result = await response.json().catch(() => ({}));
++ if (response.status === 403) one('Your agent history setting is off. Turn it on in My settings to allow this read.');
++ else if (!response.ok) one('Recent history is unavailable.');
++ else {
++ const items = (result.items || []).slice(0, 10);
++ if (!items.length) one('No recent history.');
++ else hist.replaceChildren(...items.map((x) => { const li = h('li'); li.append(h('b', '', String(x.type || '')), d.createTextNode(String(x.title || ''))); return li; }));
++ }
++ } catch { one('Recent history is unavailable.'); }
++ finally { past.disabled = false; past.classList.remove('is-busy'); }
++ hist.hidden = false; past.setAttribute('aria-expanded', 'true');
++ });
++
++ /** One line under the field: what just happened. A good word goes by itself after a moment; a problem stays. */
++ function say(text, tone) {
++ clearTimeout(noteTimer);
++ note.textContent = text || '';
++ note.className = 'ovdac-note' + (tone ? ` is-${tone}` : '');
++ if (text && tone === 'ok') noteTimer = setTimeout(() => { note.textContent = ''; note.className = 'ovdac-note'; }, 6000);
++ }
++
++ // ── A refresh repaints the row, the steps and the ask; the field and the history stay as they are ────────
++ function paint(a) {
++ if (!a || (a.enabled === false && !a.id)) {
++ actor = null; shown = false; last = '';
++ container.replaceChildren(); container.classList.remove('on'); container.removeAttribute('data-state');
++ return;
++ }
++ actor = a;
++ if (!shown) { container.replaceChildren(head, rail, askBox, form, foot, hist); container.classList.add('on'); shown = true; }
++ const sig = JSON.stringify([a.name, a.status, a.avatar, a.brain, a.caps, a.now, a.asks]);
++ if (sig === last) return;
++ last = sig;
++ const state = stateOf(a), now = a.now || {};
++ container.setAttribute('data-state', state);
++
++ // Who, and the one line about it.
++ const label = String(a.name || 'Your Actor');
++ const pic = a.avatar && typeof a.avatar.url === 'string' && /^https:\/\//.test(a.avatar.url) ? a.avatar.url : '';
++ if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.replaceChildren(img); } else face.replaceChildren(d.createTextNode(label.trim().charAt(0).toUpperCase() || 'A'));
++ nameText.textContent = label;
++ id.setAttribute('aria-label', `${label}, ${STATE[state]}. Open your Actor's page`);
++ stateText.textContent = STATE[state];
++ const cap = a.caps && Number(a.caps.usd) > 0 ? ` of ${money(a.caps.usd)}` : '';
++ spend.textContent = `${money(now.cost_today)}${cap} today`;
++ tip(spend, `Model: ${a.brain && a.brain !== 'auto' ? a.brain : 'Auto'}`);
++
++ // The one action that fits.
++ const paused = a.status === 'paused';
++ go.className = 'ovdac-go' + (paused ? ' is-main' : '');
++ go.replaceChildren(icon(paused ? 'play' : 'pause'), h('span', paused ? '' : 'sr', paused ? 'Resume' : 'Pause'));
++ tip(go, paused ? 'Your Actor does nothing until you resume it' : 'Pause your Actor');
++
++ // What it does: the step it is on, the steps before it, what comes next. Nothing when there is nothing to say.
++ const asks = Array.isArray(a.asks) ? a.asks : [];
++ const ask = asks[0];
++ const step = String(now.current_step || '').trim();
++ const live = a.status === 'working' && step && !NOT_A_STEP.test(step) && !(ask && step === String(ask.text || '').trim()) ? step : '';
++ const items = [];
++ if (live) {
++ const li = h('li', 'is-live'); li.append(d.createTextNode(live));
++ const looking = now.looking_at && (now.looking_at.title || now.looking_at);
++ if (looking) li.append(h('span', 'ovdac-look', `Looking at ${String(looking)}`));
++ items.push(li);
++ }
++ const before = (Array.isArray(now.last_three_steps) ? now.last_three_steps : []).map((x) => String((x && x.text) || x || '').trim()).filter((t) => t && t !== live).slice(0, 3);
++ if (!paused) before.forEach((t) => items.push(h('li', '', t)));
++ if (!paused && now.next && (now.next.text || typeof now.next === 'string')) {
++ const li = h('li', 'is-next'); const when = due(now.next.at);
++ li.append(h('b', '', 'Next: '), d.createTextNode(String(now.next.text || now.next) + (when ? `, ${when}` : '')));
++ items.push(li);
++ }
++ rail.replaceChildren(...items); rail.hidden = !items.length;
++
++ // The first thing it asks, with its own buttons.
++ if (ask) {
++ const k = h('div', 'ovdac-ask-k'); const chip = h('i'); chip.append(icon('ask')); k.append(chip, d.createTextNode(`${label} asks`));
++ const row = h('div', 'row');
++ const options = Array.isArray(ask.options) && ask.options.length ? ask.options : [{ id: 'yes', label: 'Yes' }];
++ options.slice(0, 3).forEach((op, i) => {
++ const b = h('button', `ovdac-btn${i === 0 ? ' is-yes' : ''}`, String(op.label || op)); b.type = 'button';
++ b.addEventListener('click', () => act(`/asks/${encodeURIComponent(ask.id)}/answer`, { option: op.id || op }, b, 'Answered.'));
++ row.append(b);
++ });
++ const no = h('button', 'ovdac-btn is-quiet', 'Not now'); no.type = 'button';
++ no.addEventListener('click', () => act(`/asks/${encodeURIComponent(ask.id)}/decline`, {}, no));
++ row.append(no);
++ askBox.replaceChildren(k, h('p', '', String(ask.text || '')), row);
++ if (asks.length > 1) { const more = h('a', 'ovdac-more', `${asks.length - 1} more on your Actor's page`); more.href = openBase + '/you'; more.target = '_blank'; more.rel = 'noopener'; askBox.append(more); }
++ } else askBox.replaceChildren();
++ askBox.hidden = !ask;
++ }
++
++ async function act(path, body, btn, done) {
++ if (busy) return false;
++ busy = true; if (btn) { btn.disabled = true; btn.classList.add('is-busy'); }
++ say('');
++ try {
++ await api(path, { method: 'POST', json: true, body });
++ last = '';
++ await refresh();
++ if (done) say(done, 'ok');
++ return true;
++ } catch (err) { say(`That did not go through: ${err.message}`, 'bad'); return false; }
++ finally { busy = false; if (btn) { btn.disabled = false; btn.classList.remove('is-busy'); } }
++ }
++
++ async function refresh() {
++ if (gone) return;
++ if (o.active && !o.active()) return;
++ try { const data = await api(''); if (!gone) paint(data); }
++ catch (err) { if (!gone && (err.status === 401 || err.status === 403 || err.status === 404)) paint(null); }
++ }
++ refresh();
++ timer = setInterval(refresh, POLL_MS);
++ return { refresh, destroy() { gone = true; clearInterval(timer); clearTimeout(noteTimer); container.replaceChildren(); container.classList.remove('on'); container.removeAttribute('data-state'); } };
++ }
++
++ root.OpenVibeDockActor = { mount };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/browser/shared/dock-agent.js b/browser/shared/dock-agent.js
iindex 3ffb3c2..8ad2c4c 100644
---- a/browser/shared/dock-agent.js
++++ b/browser/shared/dock-agent.js
@@@ -1,19 +1,22 @@
// ═══════════════════════════════════════════════════════════════
--// OpenVibe — the Dock's agent view (Dock plan 2026-10-30, K-D3; Agent Streams S1), Shared 4.120.0
++// OpenVibe — the Dock's agent view (Dock plan 2026-10-30, K-D3; Agent Streams S1), Shared 4.120.0; Float 4.154.0
//
// The person's own agent desk stream at the top of the Dock's Agents tab. dock.js loads this file the first time that
// tab opens:
// OpenVibeDockAgent.mount(container, { token, base, active }) → { refresh(), destroy() }
//
--// `base` is OpenVibe.Codes' API (GET /api/v1/me/agent-view, the same POST for pause, Curtain now, mute and End, and
--// the frame). Agent Streams S5 adds the four controls: a moderator cannot resume the owner's pause on Live, and the
++// The read is OpenVibe.Codes' GET /api/v1/me/agent-view (the same POST for pause, Curtain now, mute and End, and the
++// frame). It asks Network's same-origin relay (/api/frame/agent-view…) because every site's connect-src allows
++// openvibe.network and refuses openvibe.codes; `frameBase` overrides the relay, and `base` reaches Codes itself in a
++// test or the extension (an explicit base wins). Nothing is asked when there is no token: a signed-out page can never
++// read the view. Agent Streams S5 adds the four controls: a moderator cannot resume the owner's pause on Live, and the
// Dock is the owner's own view, so it may always resume.
// Codes mints the view ticket server-side with the person's own token — an iframe on another site has no Live cookie —
// so the frame it answers is already Live's /embed/agent/<ags_id>?vt=wvt_…: the picture, the "AI agent" label, the
// curtain state and the viewer count. The embed renews its own ticket while it plays.
//
// Nothing live: the last frame through the same API (an <img> cannot carry a token), with the task and the worked time.
--// There is no step line yet: steps are Agent Streams S2, and this view shows none until Live serves them.
++// Live's S2 current step comes through Codes when available; an older stream simply has no step line.
// Everything is set as text, never markup; the frame's address is the one Codes answered, as an https Live address.
// ═══════════════════════════════════════════════════════════════
(function (root) {
@@@ -55,6 +58,7 @@
sound: '<path d="M4 9.5h4L13 5v14l-5-4.5H4z"/><path d="M17 9.5a4 4 0 0 1 0 5"/>',
stop: '<rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/>',
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"/>',
++ float: '<rect x="3.5" y="5" width="17" height="14" rx="2.5"/><rect x="11.5" y="11" width="7" height="6" rx="1.2" fill="currentColor" stroke="none"/>',
};
// The style goes into the document the view lives in: the page, or the Dock's own window when it is popped out.
@@@ -69,6 +73,7 @@
.ovda-stage { position: relative; overflow: hidden; aspect-ratio: 16 / 9; border-radius: 14px; background: #0a0c11; border: 1px solid var(--border, rgba(255,255,255,.1)); }
.ovda-frame, .ovda-still { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; object-fit: contain; background: #0a0c11; }
.ovda-still { object-fit: cover; opacity: .92; }
++ .ovda-stage[hidden] { display: none; }
.ovda-wait { position: absolute; inset: 0; display: grid; place-items: center; color: var(--text-muted, #8b8b96); font-size: 12.5px; text-align: center; padding: 12px; }
.ovda-line { display: flex; align-items: center; gap: 6px; min-height: 34px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }
.ovda-line b { color: var(--text-primary, #ececf1); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@@ -87,9 +92,14 @@
.ovda-acts button:disabled { opacity: .5; cursor: default; }
.ovda-acts svg { width: 14px; height: 14px; }
.ovda-acts button[data-act="end"] { color: color-mix(in srgb, var(--live-red, #e5484d) 65%, var(--text-primary, #ececf1)); }
++ /* A session that ended is two lines of words with its one way out beside them, not a button on a row of its own. */
++ .ovda.is-ended .ovda-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 8px; align-items: center; padding: 2px 4px 6px; }
++ .ovda.is-ended .ovda-line, .ovda.is-ended .ovda-meta { grid-column: 1; }
++ .ovda.is-ended .ovda-acts { grid-column: 2; grid-row: 1 / span 2; margin: 0; }
++ .ovda.is-ended .ovda-acts:empty { display: none; }
++ @media (pointer: coarse) { .ovda-acts button, .ovda-acts a { min-height: 44px; min-width: 44px; justify-content: center; } }
.ovda svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
@keyframes ovda-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
-- @media (prefers-reduced-motion: reduce) { .ovda-line .dot.live { animation: none; } }
`;
(d.head || d.documentElement).appendChild(st);
}
@@@ -105,30 +115,35 @@
const o = { ...opts };
const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const codesBase = String(o.base || CODES).replace(/\/+$/, '');
++ // Network's frame relay on a site; an explicit base reaches Codes itself (a test, or the extension's panel).
++ const direct = o.base ? String(o.base).replace(/\/+$/, '') : '';
++ const frameBase = String(o.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
++ const endpoint = (path) => (direct ? direct + path : frameBase + path.replace(/^\/api\/v1\/me\/agent-view/, '/agent-view'));
let gone = false, timer = 0, busy = false, agent = null, frameUrl = null, lastFrame = '';
let readOnly = false, framedFor = '', frameRefused = '', frameTimer = 0;
container.innerHTML = '';
container.classList.add('ovda');
const stage = h('div', 'ovda-stage');
const line = h('div', 'ovda-line');
++ const step = h('div', 'ovda-meta ovda-step');
const meta = h('div', 'ovda-meta');
const watch = h('a', 'ovda-watch', 'Watch on openvibe.live');
watch.target = '_blank';
watch.rel = 'noopener';
watch.hidden = true;
const acts = h('div', 'ovda-acts');
-- const head = h('div');
-- head.append(line, meta, watch, acts);
++ const head = h('div', 'ovda-head');
++ head.append(line, meta, step, watch, acts);
const body = h('div');
body.append(stage, head);
container.append(body);
async function api(path, init = {}) {
-- const base = String(o.base || CODES).replace(/\/+$/, '');
const t = typeof o.token === 'function' ? await o.token() : o.token;
readOnly = readOnlyToken(t);
-- const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
-- const r = await fetch(base + path, { ...init, headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });
++ if (!t) throw Object.assign(new Error('Sign in to see your agent'), { status: 401 });
++ const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), authorization: `Bearer ${t}` };
++ const r = await fetch(endpoint(path), { ...init, headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });
const b = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status });
return b;
@@@ -138,8 +153,10 @@
function still(url) {
if (!url || frameUrl === url) return;
frameUrl = url;
-- Promise.resolve(typeof o.token === 'function' ? o.token() : o.token).then((t) => fetch(url, { headers: t ? { authorization: `Bearer ${t}` } : {}, credentials: 'omit' }))
-- .then((r) => (r.ok ? r.blob() : null))
++ Promise.resolve(typeof o.token === 'function' ? o.token() : o.token).then((t) => (t
++ ? fetch(endpoint('/api/v1/me/agent-view/frame'), { headers: { authorization: `Bearer ${t}`, accept: 'image/jpeg, image/webp' }, credentials: 'omit' })
++ : null))
++ .then((r) => (r && r.ok ? r.blob() : null))
.then((blob) => {
if (!blob || gone || frameUrl !== url) return;
const next = URL.createObjectURL(blob);
@@@ -164,6 +181,7 @@
const live = agent.state === 'live';
const ended = agent.state === 'ended';
++ container.classList.toggle('is-ended', ended);
// The picture: the live embed when there is one and it may be framed, else the still and a plain link.
// A refused frame looks loaded to its parent (the browser shows its own error page), so the embed
// announces itself (Live's agent-embed.js); no hello in time means frame-ancestors kept it out, and the
@@@ -192,14 +210,17 @@
if (agent.last_frame_url) {
if (!stage.querySelector('img.ovda-still')) frameUrl = null;
still(agent.last_frame_url);
-- } else if (!stage.querySelector('img.ovda-still') && !stage.querySelector('.ovda-wait')) {
-- stage.append(h('div', 'ovda-wait', ended ? 'Nothing to show' : 'Waiting for the desk'));
++ } else if (!ended && !stage.querySelector('img.ovda-still') && !stage.querySelector('.ovda-wait')) {
++ stage.append(h('div', 'ovda-wait', 'Waiting for the desk'));
}
// The plain way to the stream, when this browser cannot frame it. It is a real link, not a control.
const page = typeof agent.page_url === 'string' && /^https:\/\/openvibe\.live\//.test(agent.page_url) ? agent.page_url : null;
if (page) watch.href = page;
watch.hidden = !blocked || !page;
}
++ // A session that ended and left no picture has no stage: a black box saying "Nothing to show" told nobody
++ // anything (the owner, 2026-11-10). The line under it still says what it was and how long it worked.
++ stage.hidden = ended && !embed && !agent.last_frame_url && !stage.querySelector('img');
// The line: what it is doing, one row.
line.innerHTML = '';
@@@ -211,6 +232,11 @@
else title.textContent = text;
line.append(title);
++ const current = agent.current_step;
++ step.textContent = current && typeof current.title === 'string' && current.title.trim()
++ ? `Step: ${current.title} · ${current.status || 'started'}` : '';
++ step.hidden = !step.textContent;
++
// The meta: the state, the viewers, the curtain, the worked time.
meta.textContent = '';
const bits = [live ? 'Live' : ended ? 'Ended' : 'Waiting for the desk'];
@@@ -246,6 +272,16 @@
if (live && !(agent.curtain && agent.curtain.up)) acts.append(control('curtain', `${svg(G.eye)}Curtain now`, 'Hide the screen now; your desk lifts it when it is clear'));
if (!ended) acts.append(control('mute', `${svg(agent.voice_muted ? G.sound : G.mute)}${agent.voice_muted ? 'Unmute' : 'Mute'}`, agent.voice_muted ? 'Let the agent speak again' : 'Mute the agent\'s voice'));
if (!ended) acts.append(control('end', `${svg(G.stop)}End`, 'End the agent stream'));
++ // 4.154.0: Float puts the agent's screen in a floating card (overlay.js) that stays on screen as the person
++ // moves around this site and on to other OpenVibe sites, so the Dock can close while they watch.
++ if (embed && !ended) {
++ const b = h('button', null);
++ b.type = 'button';
++ b.dataset.act = 'float';
++ b.innerHTML = `${svg(G.float)}Float`;
++ b.title = 'Keep watching in a small window that follows you';
++ acts.append(b);
++ }
if (agent.page_url && /^https:\/\/openvibe\.live\//.test(agent.page_url)) {
const a = h('a');
a.href = agent.page_url;
@@@ -325,8 +361,19 @@
else if (b.dataset.act === 'curtain') control('curtain', b);
else if (b.dataset.act === 'mute') control('mute', b, { muted: !(agent && agent.voice_muted) });
else if (b.dataset.act === 'end') control('end', b);
++ else if (b.dataset.act === 'float') floatIt(b);
});
++ function floatIt(btn) {
++ const embed = agent && typeof agent.embed_url === 'string' && EMBED.test(agent.embed_url) ? agent.embed_url : null;
++ const nav = root.OpenVibeNavbar;
++ if (!embed || !nav || typeof nav.overlay !== 'function') { btn.title = 'Floating is not available on this page.'; return; }
++ const pageUrl = agent.page_url && /^https:\/\/openvibe\.live\//.test(agent.page_url) ? agent.page_url : null;
++ nav.overlay({ id: 'agent', src: embed, title: (agent.task && agent.task.title) || agent.title || 'Your agent', live: agent.state === 'live', href: pageUrl || undefined })
++ .then((card) => { if (card && root.OpenVibeDock && typeof root.OpenVibeDock.close === 'function') root.OpenVibeDock.close(); })
++ .catch(() => { btn.title = 'That did not work. Try again.'; });
++ }
++
refresh();
return { refresh, destroy() { gone = true; clearTimeout(timer); clearTimeout(frameTimer); root.removeEventListener('message', onFrameHello); if (lastFrame) URL.revokeObjectURL(lastFrame); container.innerHTML = ''; container.classList.remove('ovda'); } };
}
ddiff --git a/browser/shared/dock-card.js b/browser/shared/dock-card.js
nnew file mode 100644
iindex 0000000..b208110
---- /dev/null
++++ b/browser/shared/dock-card.js
@@@ -0,0 +1,71 @@
++/**
++ * openvibe-shared/dock-card.js: a known OpenVibe link's card under a message in the Dock (4.147.0).
++ *
++ * OpenVibe.Chat resolves the card when a message is sent (its server/chat/link-cards.js) and the DM API carries it on
++ * the message as `card`: { kind, url, title, line, note?, image, action: { label, href } | null }, for a video on
++ * OpenVibe.Video ("Watch together"), a party invite ("Join the party"), a shared voice or a skill on OpenVibe.Actor.
++ * dock.js loads this file the first time an open conversation shows a message with a card, so the Dock that every
++ * signed-in page carries stays small.
++ *
++ * OpenVibeDockCard(card, h) → the card's node, or null when it is not a card this can draw
++ *
++ * Every field is data: set as text, cut to size, a link only when it is https, a picture only from openvibe.media.
++ * The card is compact (a 72 px picture beside the words) because the Dock is narrow; the message stays the thing said.
++ */
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OpenVibeDockCard) return;
++
++ const CSS = `
++.ovdk-card { display: flex; align-items: center; gap: 10px; box-sizing: border-box; max-width: min(100%, 300px); margin-top: 4px; padding: 8px 10px 8px 8px; border-radius: 14px; border: 1px solid var(--border, rgba(255,255,255,.1)); background: var(--bg-primary, #12141b); text-align: left; }
++.ovdk-card-pic { flex: none; width: 72px; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 8px; background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); }
++.ovdk-card-body { min-width: 0; display: grid; justify-items: start; gap: 2px; }
++.ovdk-card-t { font-size: 13.5px; font-weight: 700; line-height: 1.3; color: var(--text-primary, #ececf1); text-decoration: none; overflow-wrap: anywhere; }
++a.ovdk-card-t:hover { text-decoration: underline; }
++.ovdk-card-l { font-size: 12.5px; line-height: 1.35; color: var(--text-secondary, #b6b6c0); overflow-wrap: anywhere; }
++.ovdk-card-go { display: inline-flex; align-items: center; min-height: 32px; margin-top: 4px; padding: 0 12px; border-radius: 999px; font-size: 13px; font-weight: 700; text-decoration: none; color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }
++.ovdk-card-go:hover { filter: brightness(1.08); }
++@media (pointer: coarse) { .ovdk-card-go { min-height: 44px; padding: 0 16px; } }
++`;
++
++ const https = (u) => (typeof u === 'string' && /^https:\/\/[^\s"'<>]+$/.test(u) ? u : null);
++ const cut = (v, n) => Array.from(String(v)).slice(0, n).join('');
++
++ function styles() {
++ if (document.getElementById('ovdk-card-styles')) return;
++ const st = document.createElement('style');
++ st.id = 'ovdk-card-styles';
++ st.textContent = CSS;
++ document.head.appendChild(st);
++ }
++
++ function card(c, h) {
++ if (!c || typeof c !== 'object' || typeof c.title !== 'string' || !c.title || typeof h !== 'function') return null;
++ styles();
++ const url = https(c.url);
++ const box = h('div', 'ovdk-card');
++ if (c.kind) box.dataset.kind = cut(c.kind, 20);
++ const pic = typeof c.image === 'string' && /^https:\/\/openvibe\.media\/[^\s"'<>]+$/.test(c.image) ? c.image : null;
++ if (pic) {
++ const img = h('img', 'ovdk-card-pic');
++ img.src = pic; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async';
++ box.append(img);
++ }
++ const body = h('div', 'ovdk-card-body');
++ const title = h(url ? 'a' : 'b', 'ovdk-card-t', cut(c.title, 120));
++ if (url) { title.href = url; title.target = '_blank'; title.rel = 'noopener nofollow'; }
++ body.append(title);
++ if (typeof c.line === 'string' && c.line) body.append(h('span', 'ovdk-card-l', cut(c.line, 200)));
++ if (typeof c.note === 'string' && c.note) body.append(h('span', 'ovdk-card-l', cut(c.note, 160)));
++ const go = c.action && typeof c.action === 'object' && https(c.action.href) && typeof c.action.label === 'string' && c.action.label ? c.action : null;
++ if (go) {
++ const a = h('a', 'ovdk-card-go', cut(go.label, 40));
++ a.href = https(go.href); a.target = '_blank'; a.rel = 'noopener nofollow';
++ body.append(a);
++ }
++ box.append(body);
++ return box;
++ }
++
++ root.OpenVibeDockCard = card;
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/browser/shared/dock-chat-edits.js b/browser/shared/dock-chat-edits.js
nnew file mode 100644
iindex 0000000..983a4ad
---- /dev/null
++++ b/browser/shared/dock-chat-edits.js
@@@ -0,0 +1,41 @@
++/* Edit events for an open Dock DM. Loaded when the conversation opens. */
++(function (root) {
++ 'use strict';
++ root.OpenVibeDockChatEdits = function ({ id, list, messages, error, tokenOf, chatCall, chatBase, active, load, h }) {
++ let socket = null, timer = null, stopped = false;
++ function apply(m) {
++ const old = messages.find((x) => Number(x.id) === Number(m && m.id));
++ const li = old && list.querySelector(`[data-id="${Number(m.id)}"]`);
++ if (!li || !m.edited_at || (old.edited_at && old.edited_at >= m.edited_at)) return;
++ Object.assign(old, { message: m.message, card: m.card, edited_at: m.edited_at });
++ li.querySelector('.ovdk-text').textContent = String(m.message || '');
++ let mark = li.querySelector('.ovdk-edited');
++ if (!mark) { mark = h('small', 'ovdk-edited'); li.append(mark); }
++ mark.textContent = `edited · ${String(m.edited_at).slice(11, 16)}`;
++ const card = li.querySelector('.ovdk-card'); if (card) card.remove();
++ if (m.card) load('dock-card.js', () => !!root.OpenVibeDockCard).then((ok) => { if (ok && active() && li.isConnected) { const c = root.OpenVibeDockCard(m.card, h); if (c) li.append(c); } });
++ }
++ async function connect() {
++ if (stopped || !active() || !root.WebSocket) return;
++ const token = await tokenOf();
++ if (!token || stopped || !active()) return;
++ const ws = new root.WebSocket(chatBase().replace(/^http/, 'ws') + '/ws/chat');
++ socket = ws;
++ ws.onopen = () => ws.send(JSON.stringify({ type: 'join', token }));
++ ws.onmessage = (event) => { let frame; try { frame = JSON.parse(event.data); } catch { return; } if (frame.type === 'dm_edited' && String(frame.conversation_id) === id) apply(frame.message); };
++ ws.onclose = () => { if (socket === ws) socket = null; if (!stopped && active()) timer = setTimeout(connect, 3000); };
++ }
++ list.addEventListener('click', async (event) => {
++ if (!event.target.classList.contains('ovdk-edit')) return;
++ const li = event.target.closest('[data-id]');
++ const old = li && messages.find((m) => Number(m.id) === Number(li.dataset.id));
++ if (!old) return;
++ const body = root.prompt('Edit message', old.message);
++ if (body == null || body.trim() === old.message.trim()) return;
++ try { const result = await chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/messages/${encodeURIComponent(old.id)}`, { method: 'PATCH', body: JSON.stringify({ message: body }) }); apply(result.message); }
++ catch (e) { error.textContent = e.message || 'Could not edit'; error.hidden = false; }
++ });
++ connect().catch(() => {});
++ return { stop() { stopped = true; clearTimeout(timer); if (socket) { socket.onclose = null; socket.close(); socket = null; } } };
++ };
++})(typeof window === 'undefined' ? globalThis : window);
ddiff --git a/browser/shared/dock-code.js b/browser/shared/dock-code.js
nnew file mode 100644
iindex 0000000..6789fa7
---- /dev/null
++++ b/browser/shared/dock-code.js
@@@ -0,0 +1,303 @@
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — the Code part (openvibe-shared 4.201.0). Ask 350, the owner: openvibe.codes "should be better interconnected
++// and shared … so that you can do chat, codes, and agents all in the same place and in one area". The menu's Vibe place
++// is Chat · Code · Agents, and the Dock has the same Code tab. dock.js loads this file the first time it opens:
++// OpenVibeDockCode.mount(container, { token, frameBase, base, codes, active, take }) → { refresh(), focus(), destroy() }
++// take() → the words another part handed over (Vibe's "Build it in Code"), which go in the box.
++//
++// One box to start a coding task, the way openvibe.codes starts one (Create: a new website, game or app; OpenVibe:
++// a change to OpenVibe itself; My code: a repository of yours), and your latest tasks under it, running ones first,
++// each with its state, its repository, how long ago, and Stop while it runs. The reads go through Network's same-origin
++// relay (/api/frame/codes-tasks…), because every site's connect-src allows openvibe.network and refuses openvibe.codes;
++// `base` reaches Codes itself in a test. While a task runs the list is read again every 8 seconds, only while the part
++// is on screen. When Codes cannot be reached, the box still works: it opens openvibe.codes with what you wrote in it.
++// Everything is set as text, never markup; a task's address is used only when it is on openvibe.codes.
++// ═══════════════════════════════════════════════════════════════
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OpenVibeDockCode) return;
++
++ const CODES = 'https://openvibe.codes';
++ const SCRIPT_BASE = document.currentScript && document.currentScript.src ? new URL('.', document.currentScript.src).href : '/shared/';
++ const POLL_MS = 8000;
++ const MODES = [['create', 'Create', 'A new website, game or app'], ['openvibe', 'OpenVibe', 'A change to OpenVibe itself'], ['repo', 'My code', 'A repository of yours']];
++ const STATE = { queued: 'Waiting', running: 'Running', 'needs-you': 'Needs you', done: 'Done', failed: 'Failed', stopped: 'Stopped' };
++ const PLACEHOLDER = { create: 'A goal bar for my sub goal', openvibe: 'Make the menu remember my last tab', repo: 'Fix the login redirect and add a test' };
++ const svg = (d) => `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;
++ const G = {
++ send: 'M5 12h13M13 6l6 6-6 6',
++ stop: 'M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zM9.5 9.5h5v5h-5z',
++ open: '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',
++ code: 'M8 7l-5 5 5 5M16 7l5 5-5 5',
++ };
++
++ function styles(d) {
++ if (d.getElementById('ovdc-styles')) return;
++ const st = d.createElement('style');
++ st.id = 'ovdc-styles';
++ st.textContent = `
++ .ovdc { display: flex; flex-direction: column; gap: 12px; padding: 10px 10px 14px; }
++ .ovdc-box { display: grid; gap: 8px; padding: 10px 10px 8px 12px; border-radius: 18px; border: 1px solid var(--border, rgba(255,255,255,.1)); background: var(--bg-card, rgba(255,255,255,.03)); }
++ .ovdc-box:focus-within { border-color: color-mix(in srgb, var(--accent, #3b82f6) 60%, var(--border, rgba(255,255,255,.1))); }
++ .ovdc-text { display: block; width: 100%; box-sizing: border-box; min-height: 44px; max-height: 30vh; padding: 2px 0; border: 0; outline: 0; resize: none; background: none; color: var(--text-primary, #ececf1); font: inherit; font-size: 15.5px; line-height: 1.45; }
++ .ovdc-repo { width: 100%; box-sizing: border-box; height: 36px; padding: 0 10px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-input, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 14px; }
++ .ovdc-repo[hidden] { display: none; }
++ .ovdc-row { display: flex; align-items: center; gap: 6px; }
++ .ovdc-modes { flex: 1; min-width: 0; display: flex; gap: 4px; overflow-x: auto; scrollbar-width: none; }
++ .ovdc-mode { flex: none; height: 32px; padding: 0 11px; border-radius: 999px; border: 1px solid transparent; background: none; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }
++ .ovdc-mode:hover { color: var(--text-primary, #ececf1); background: var(--bg-hover, rgba(255,255,255,.06)); }
++ .ovdc-mode[aria-checked="true"] { color: var(--accent-light, #93c5fd); border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }
++ .ovdc-go { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 13px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); cursor: pointer; }
++ .ovdc-go:disabled { opacity: .45; cursor: default; }
++ .ovdc-note { margin: 0 2px; font-size: 12.5px; color: var(--text-muted, #8b8b96); }
++ .ovdc-note:empty { display: none; }
++ .ovdc-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 4px; }
++ .ovdc-head b { font-size: 13px; letter-spacing: .02em; color: var(--text-secondary, #a8b3c4); }
++ .ovdc-all { display: inline-flex; align-items: center; gap: 5px; min-height: 32px; font-size: 13px; font-weight: 600; color: var(--accent-light, #93c5fd); text-decoration: none; }
++ .ovdc-all svg { width: 15px; height: 15px; }
++ .ovdc-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
++ .ovdc-task { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 8px; border-radius: 12px; }
++ .ovdc-task:hover { background: var(--bg-hover, rgba(255,255,255,.05)); }
++ .ovdc-a { min-width: 0; display: grid; gap: 3px; padding: 9px 4px 9px 10px; color: inherit; text-decoration: none; }
++ .ovdc-t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; font-weight: 600; color: var(--text-primary, #ececf1); }
++ .ovdc-m { display: flex; align-items: center; gap: 7px; min-width: 0; font-size: 12.5px; color: var(--text-muted, #8b8b96); white-space: nowrap; overflow: hidden; }
++ .ovdc-s { flex: none; display: inline-flex; align-items: center; gap: 5px; font-weight: 650; }
++ .ovdc-s::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
++ .ovdc-s[data-s="running"], .ovdc-s[data-s="queued"] { color: var(--accent-light, #93c5fd); }
++ .ovdc-s[data-s="running"]::before { animation: ovdc-beat 1.2s ease-in-out infinite; }
++ .ovdc-s[data-s="needs-you"] { color: #fbbf24; }
++ .ovdc-s[data-s="done"] { color: #4ade80; }
++ .ovdc-s[data-s="failed"] { color: var(--live-red, #f87171); }
++ .ovdc-s[data-s="stopped"] { color: var(--text-muted, #8b8b96); }
++ .ovdc-stop { display: grid; place-items: center; width: 36px; height: 36px; margin-right: 4px; border: 0; border-radius: 10px; background: none; color: var(--text-secondary, #a8b3c4); cursor: pointer; }
++ .ovdc-stop:hover, .ovdc-stop:focus-visible { background: color-mix(in srgb, var(--live-red, #ef4444) 14%, transparent); color: var(--live-red, #f87171); }
++ .ovdc-stop svg { width: 19px; height: 19px; }
++ .ovdc-none { padding: 18px 12px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }
++ .ovdc-none a { color: var(--accent-light, #93c5fd); }
++ @keyframes ovdc-beat { 50% { opacity: .35; } }
++ html[data-ov-motion=off] .ovdc-s::before { animation: none; }`;
++ d.head.appendChild(st);
++ }
++
++ const ago = (t) => {
++ const s = Math.max(0, (Date.now() - new Date(t).getTime()) / 1000);
++ if (!isFinite(s)) return '';
++ return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago`;
++ };
++ const onCodes = (u) => { try { const x = new URL(u); return x.protocol === 'https:' && (x.hostname === 'openvibe.codes' || x.hostname.endsWith('.openvibe.codes')) ? x.href : null; } catch { return null; } };
++ const repoName = (r) => String(r || '').replace(/^https?:\/\/[^/]+\//, '').replace(/\.git$/, '');
++
++ function mount(container, opts = {}) {
++ const d = container.ownerDocument || document;
++ styles(d);
++ const o = { ...opts };
++ const tok = () => (typeof o.token === 'function' ? o.token() : o.token) || null;
++ const codes = String(o.codes || CODES).replace(/\/+$/, '');
++ const direct = o.base ? String(o.base).replace(/\/+$/, '') : '';
++ const relay = String(o.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
++ const url = (what, id) => (direct
++ ? direct + ({ list: '/api/v1/me/tasks?limit=8', start: '/api/v1/tasks', stop: `/api/v1/tasks/${encodeURIComponent(id)}/stop` })[what]
++ : relay + ({ list: '/codes-tasks?limit=8', start: '/codes-tasks', stop: `/codes-tasks/${encodeURIComponent(id)}/stop` })[what]);
++ const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
++ let gone = false, timer = 0, mode = 'create', tasks = null, reachable = true;
++
++ function loadScript(file, ready) {
++ if (ready()) return Promise.resolve(true);
++ return new Promise((resolve) => {
++ const id = `ovdc-${file}`;
++ let script = d.getElementById(id);
++ if (!script) { script = d.createElement('script'); script.id = id; script.src = SCRIPT_BASE + file; script.async = true; d.head.appendChild(script); }
++ script.addEventListener('load', () => resolve(!!ready()), { once: true });
++ script.addEventListener('error', () => resolve(false), { once: true });
++ });
++ }
++ function runUrl(id) {
++ return (direct ? direct + '/api/v1/runs/' : relay + '/codes-runs/') + encodeURIComponent(id);
++ }
++ async function openRun(task, from) {
++ const href = onCodes(task.url);
++ if (!href || !tok()) return;
++ const loaded = await Promise.all([
++ loadScript('ov-window.js', () => !!root.OVWindow),
++ loadScript('agent-session.js', () => !!root.OpenVibeAgentSession)
++ ]);
++ if (loaded.some((ok) => !ok)) { d.defaultView.location.assign(href); return; }
++ const body = h('div');
++ let stopped = false, controller = null, view = null;
++ const win = root.OVWindow.open({ id: `codes-run-${task.id}`, title: task.title || 'Coding task', body, kind: 'dialog', size: 'md', from,
++ onClose: () => { stopped = true; if (controller) controller.abort(); if (view) view.destroy(); } });
++ body.appendChild(h('p', 'ovdc-none', 'Loading the run…'));
++ const headers = () => ({ Authorization: `Bearer ${tok()}`, Accept: 'application/json' });
++ try {
++ const res = await fetch(runUrl(task.id), { headers: headers(), credentials: 'omit' });
++ if (!res.ok) throw new Error('The run could not be read.');
++ const run = await res.json();
++ if (stopped) return;
++ body.replaceChildren();
++ view = root.OpenVibeAgentSession.mount(body, { title: run.title || task.title || 'Coding task',
++ status: run.status || task.status || 'running', mode: 'card', href });
++ let last = 0;
++ while (!stopped) {
++ controller = new AbortController();
++ const events = await fetch(runUrl(task.id) + '/events', { credentials: 'omit',
++ headers: { ...headers(), Accept: 'text/event-stream', 'Last-Event-ID': String(last) }, signal: controller.signal });
++ if (!events.ok || !events.body) break;
++ const reader = events.body.getReader();
++ const decoder = new TextDecoder();
++ let buffer = '', terminal = false;
++ while (!stopped) {
++ const part = await reader.read();
++ if (part.done) break;
++ buffer = (buffer + decoder.decode(part.value, { stream: true })).replace(/\r\n/g, '\n');
++ let cut;
++ while ((cut = buffer.indexOf('\n\n')) !== -1) {
++ const frame = buffer.slice(0, cut); buffer = buffer.slice(cut + 2);
++ const id = /^id: *(\d+)$/m.exec(frame), data = frame.split('\n').filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n');
++ if (id) last = Number(id[1]);
++ if (!data) continue;
++ try { const event = JSON.parse(data); view.push(event); if (event.type === 'RUN_FINISHED' || event.type === 'RUN_ERROR') terminal = true; } catch { /* skip one malformed event */ }
++ }
++ }
++ if (terminal || !/running|queued|placed/.test(run.status || '')) break;
++ await new Promise((resolve) => setTimeout(resolve, 1500));
++ }
++ } catch (err) {
++ if (!stopped) { body.replaceChildren(); const a = h('a', 'ovk-btn ovk-btn--quiet', 'Open on Codes'); a.href = href; body.appendChild(a); }
++ }
++ return win;
++ }
++
++ container.innerHTML = '';
++ container.classList.add('ovdc');
++ const box = h('form', 'ovdc-box');
++ box.setAttribute('aria-label', 'Start a coding task');
++ const text = h('textarea', 'ovdc-text');
++ text.rows = 2; text.maxLength = 16000; text.placeholder = PLACEHOLDER.create;
++ text.setAttribute('aria-label', 'What should we make?');
++ const repo = h('input', 'ovdc-repo');
++ repo.placeholder = 'Repository: owner/name or its address'; repo.setAttribute('aria-label', 'Repository'); repo.hidden = true; repo.maxLength = 300;
++ const row = h('div', 'ovdc-row');
++ const modes = h('div', 'ovdc-modes');
++ modes.setAttribute('role', 'radiogroup'); modes.setAttribute('aria-label', 'Where it works');
++ for (const [id, label, hint] of MODES) {
++ const b = h('button', 'ovdc-mode', label);
++ b.type = 'button'; b.dataset.mode = id; b.title = hint;
++ b.setAttribute('role', 'radio'); b.setAttribute('aria-checked', String(id === mode));
++ modes.appendChild(b);
++ }
++ const go = h('button', 'ovdc-go'); go.type = 'submit'; go.innerHTML = svg(G.send); go.setAttribute('aria-label', 'Start'); go.disabled = true;
++ row.append(modes, go);
++ const note = h('p', 'ovdc-note'); note.setAttribute('role', 'status');
++ box.append(text, repo, row, note);
++ const head = h('div', 'ovdc-head');
++ const all = h('a', 'ovdc-all'); all.href = `${codes}/`; all.innerHTML = `<span>Open Codes</span>${svg(G.open)}`;
++ head.append(h('b', null, 'Your tasks'), all);
++ const list = h('ul', 'ovdc-list');
++ container.append(box, head, list);
++
++ const ready = () => { go.disabled = !text.value.trim() || (mode === 'repo' && !repo.value.trim()); };
++ text.addEventListener('input', () => { text.style.height = 'auto'; text.style.height = Math.min(text.scrollHeight, d.defaultView.innerHeight * 0.3) + 'px'; ready(); });
++ repo.addEventListener('input', ready);
++ text.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !go.disabled) { e.preventDefault(); box.requestSubmit ? box.requestSubmit() : start(); } });
++ modes.addEventListener('click', (e) => {
++ const b = e.target.closest && e.target.closest('[data-mode]'); if (!b) return;
++ mode = b.dataset.mode;
++ modes.querySelectorAll('[data-mode]').forEach((x) => x.setAttribute('aria-checked', String(x === b)));
++ text.placeholder = PLACEHOLDER[mode];
++ repo.hidden = mode !== 'repo';
++ ready();
++ (mode === 'repo' && !repo.value ? repo : text).focus();
++ });
++
++ const fallback = () => `${codes}/?${new URLSearchParams({ prompt: text.value.trim(), mode, ...(mode === 'repo' ? { repo: repo.value.trim() } : {}) })}`;
++ async function call(what, id, body) {
++ const t = tok(); if (!t) throw new Error('signed out');
++ const r = await fetch(url(what, id), { method: body || what === 'stop' ? 'POST' : 'GET', headers: { authorization: `Bearer ${t}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined, credentials: 'omit' });
++ if (!r.ok) { const e = new Error(`HTTP ${r.status}`); e.status = r.status; try { e.body = await r.json(); } catch { /* */ } throw e; }
++ return r.json().catch(() => ({}));
++ }
++ async function start() {
++ if (go.disabled) return;
++ if (!reachable) { d.defaultView.location.assign(fallback()); return; }
++ go.disabled = true; note.textContent = 'Starting…';
++ try {
++ const r = await call('start', null, { text: text.value.trim(), mode, ...(mode === 'repo' ? { repo: repo.value.trim() } : {}) });
++ text.value = ''; text.dispatchEvent(new Event('input'));
++ note.textContent = '';
++ const there = onCodes(r && r.url);
++ if (there) { const a = h('a', null, 'Follow it on openvibe.codes'); a.href = there; note.textContent = 'Started. '; note.appendChild(a); }
++ read();
++ } catch (e) {
++ ready();
++ if (e.status === 404 || e.status === 502 || e.status === 503 || e.message === 'Failed to fetch') { d.defaultView.location.assign(fallback()); return; }
++ note.textContent = (e.body && (e.body.message || e.body.error)) || 'It did not start. Try again in a moment.';
++ }
++ }
++ box.addEventListener('submit', (e) => { e.preventDefault(); start(); });
++
++ function paint() {
++ list.replaceChildren();
++ if (!tok()) { list.appendChild(h('li', 'ovdc-none', 'Sign in to start coding tasks and follow them here.')); return; }
++ if (!reachable) {
++ const li = h('li', 'ovdc-none');
++ li.append('Your tasks are on ');
++ const a = h('a', null, 'openvibe.codes'); a.href = `${codes}/`; li.append(a, '. Starting one here opens it there.');
++ list.appendChild(li); return;
++ }
++ if (!tasks) return;
++ if (!tasks.length) { list.appendChild(h('li', 'ovdc-none', 'Nothing yet. Say what to make, and an agent builds it while you watch.')); return; }
++ for (const t of tasks) {
++ const li = h('li', 'ovdc-task');
++ const there = onCodes(t.url);
++ const a = h(there ? 'a' : 'div', 'ovdc-a');
++ if (there) { a.href = there; a.addEventListener('click', (event) => {
++ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
++ event.preventDefault(); openRun(t, a);
++ }); }
++ a.appendChild(h('span', 'ovdc-t', t.title || 'A coding task'));
++ const m = h('span', 'ovdc-m');
++ const s = h('span', 'ovdc-s', STATE[t.status] || t.status || ''); s.dataset.s = t.status || '';
++ m.appendChild(s);
++ const bits = [t.mode === 'repo' && t.repo ? repoName(t.repo) : t.mode === 'openvibe' ? 'OpenVibe' : null, t.steps ? `${t.steps} steps` : null, ago(t.updated_at || t.started_at)].filter(Boolean);
++ if (bits.length) m.appendChild(h('span', null, bits.join(' · ')));
++ a.appendChild(m);
++ li.appendChild(a);
++ if (t.status === 'running' || t.status === 'queued' || t.status === 'needs-you') {
++ const x = h('button', 'ovdc-stop'); x.type = 'button'; x.innerHTML = svg(G.stop); x.setAttribute('aria-label', `Stop: ${t.title || 'this task'}`); x.title = 'Stop';
++ x.addEventListener('click', async () => { x.disabled = true; try { await call('stop', t.id); } catch { /* the next read says */ } read(); });
++ li.appendChild(x);
++ }
++ list.appendChild(li);
++ }
++ }
++ async function read() {
++ clearTimeout(timer);
++ if (gone || !tok()) { paint(); return; }
++ try {
++ const j = await call('list');
++ reachable = true;
++ tasks = (Array.isArray(j) ? j : j.tasks || []).slice(0, 8);
++ tasks.sort((a, b) => (/running|queued|needs-you/.test(b.status) ? 1 : 0) - (/running|queued|needs-you/.test(a.status) ? 1 : 0));
++ } catch (e) {
++ if (e.status === 401 || e.status === 403) tasks = [];
++ else reachable = false;
++ }
++ if (gone) return;
++ paint();
++ if (reachable && tasks && tasks.some((t) => /running|queued/.test(t.status)) && (!o.active || o.active())) timer = setTimeout(read, POLL_MS);
++ }
++ // What another part handed over (Vibe's Build it in Code) goes in the box, ready to start.
++ const take = () => { const t = typeof o.take === 'function' ? o.take() : ''; if (!t) return; text.value = t; text.dispatchEvent(new Event('input')); text.focus(); note.textContent = 'From your chat with Vibe. Start it, or change it first.'; };
++ paint();
++ read();
++ take();
++ return {
++ refresh: () => { take(); read(); },
++ focus: () => text.focus(),
++ destroy() { gone = true; clearTimeout(timer); container.replaceChildren(); container.classList.remove('ovdc'); },
++ };
++ }
++
++ root.OpenVibeDockCode = { mount };
++ if (typeof module === 'object' && module.exports) module.exports = { mount };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/browser/shared/dock-mount.js b/browser/shared/dock-mount.js
nnew file mode 100644
iindex 0000000..f2a4dda
---- /dev/null
++++ b/browser/shared/dock-mount.js
@@@ -0,0 +1,27 @@
++// The Dock body inside a host sheet. Loaded only when mountInto is first used.
++(function (root) {
++ 'use strict';
++ function mount(host, first, bridge) {
++ if (!document.getElementById('ovdk-mount-styles')) {
++ const style = document.createElement('style');
++ style.id = 'ovdk-mount-styles';
++ style.textContent = `
++ .ovdk.ovdk-inline, .ovdk.ovdk-inline[data-corner] { position: relative !important; inset: auto !important; z-index: auto !important; width: 100% !important; height: 100% !important; max-width: 100% !important; max-height: none !important; min-height: 0; padding: 0 !important; border: 0; border-radius: 0; box-shadow: none !important; transform: none !important; animation: none !important; }
++ .ovdk-inline .ovdk-body { width: 100%; min-width: 0; border: 0; overflow: hidden; }
++ .ovdk-inline .ovdk-panel { min-width: 0; overflow-x: hidden; }
++ @media (max-width: 640px) { .ovdk-inline button, .ovdk-inline a, .ovdk-inline summary, .ovdk-inline input, .ovdk-inline textarea { min-height: 44px !important; max-height: none !important; box-sizing: border-box; } }
++ `;
++ document.head.append(style);
++ }
++ const wrap = document.createElement('section');
++ wrap.className = 'ovdk ovdk-inline';
++ wrap.setAttribute('aria-label', 'Dock panel');
++ host.replaceChildren(wrap);
++ bridge.start(wrap);
++ let gone = false;
++ const show = (tab) => { if (!gone) bridge.show(tab); };
++ show(first);
++ return { show, destroy() { if (gone) return; gone = true; bridge.end(); host.replaceChildren(); } };
++ }
++ root.OpenVibeDockMount = { mount };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/browser/shared/dock-runs.js b/browser/shared/dock-runs.js
nnew file mode 100644
iindex 0000000..8ec6ec2
---- /dev/null
++++ b/browser/shared/dock-runs.js
@@@ -0,0 +1,135 @@
++/** The Dock's content-aware run list. All content comes from Network's action feed and Actor's skill catalogue. */
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OpenVibeDockRuns) return;
++ const CSS = `
++.ovdk-overview{display:grid;gap:8px;padding:4px 10px 12px}.ovdk-tiles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}
++.ovdk-tile{min-width:0;display:grid;align-content:start;gap:2px;padding:10px 11px;border-radius:14px;background:color-mix(in srgb,var(--text-primary,#ececf1) 5%,transparent)}
++.ovdk-tile small{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted,#8b8b96)}
++.ovdk-tile b{font-size:22px;font-weight:800;line-height:1.1;font-variant-numeric:tabular-nums;letter-spacing:-.02em}.ovdk-tile b em{font-style:normal;font-size:12px;font-weight:650;color:var(--text-secondary,#a8a8b2);letter-spacing:0;margin-left:4px}
++.ovdk-tile span{white-space:pre-wrap;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--text-secondary,#a8a8b2);line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
++.ovdk-tile .ovdk-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin:0 3px 1px 0;background:var(--ok,#3fb27f)}.ovdk-tile .ovdk-dot.is-bad{background:var(--live-red,#e5484d)}
++.ovdk-tile-next b{font-size:15px;line-height:1.25;letter-spacing:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.ovdk-tile-next span{color:var(--accent-light,var(--accent,#60a5fa));font-weight:700}
++.ovdk-overview h3{font-size:11px;font-weight:750;letter-spacing:.05em;text-transform:uppercase;color:var(--text-muted,#8b8b96);margin:8px 2px 0}.ovdk-skills{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}
++.ovdk-skills button{min-height:44px;padding:9px 11px;border-radius:12px;border:0;background:color-mix(in srgb,var(--accent,#3b82f6) 10%,transparent);color:inherit;text-align:left;cursor:pointer;font:inherit;font-size:12.5px;font-weight:650}
++.ovdk-skills button:hover{background:color-mix(in srgb,var(--accent,#3b82f6) 18%,transparent)}.ovdk-overview .ovdk-runs{padding:0}.ovdk-run-filters{display:flex;gap:4px;overflow-x:auto;padding:8px 10px;scrollbar-width:none}.ovdk-run-filters::-webkit-scrollbar{display:none}
++.ovdk-run-filters button{min-height:36px;padding:0 11px;border:1px solid var(--border,rgba(127,127,127,.2));border-radius:99px;background:transparent;color:inherit;cursor:pointer;white-space:nowrap;font:inherit;font-size:12px}
++.ovdk-run-filters button[aria-pressed=true]{background:var(--accent,#3b82f6);color:var(--on-accent,#fff)}
++.ovdk-runs{list-style:none;margin:0;padding:0 6px 10px;display:grid;gap:2px}.ovdk-run{display:grid;grid-template-columns:36px minmax(0,1fr) auto;gap:11px;padding:10px 8px;border-radius:14px;min-width:0}
++.ovdk-run-more{position:relative;align-self:start}.ovdk-run-more summary{list-style:none;width:36px;height:36px;display:grid;place-items:center;border-radius:50%;cursor:pointer;color:var(--text-muted,#8b8b96)}
++.ovdk-run-more summary::-webkit-details-marker{display:none}.ovdk-run-more summary svg{width:18px;height:18px;fill:currentColor}
++@media (hover:hover){.ovdk-run-more summary:hover{background:color-mix(in srgb,var(--text-primary,#ececf1) 8%,transparent);color:var(--text-primary,#ececf1)}}
++.ovdk-run-more menu{position:absolute;right:0;top:38px;z-index:3;min-width:210px;padding:4px;margin:0;list-style:none;border:1px solid var(--border,rgba(127,127,127,.2));border-radius:12px;background:var(--bg-primary,#19191f);box-shadow:0 10px 28px rgba(0,0,0,.28)}
++.ovdk-run-more menu a,.ovdk-run-more menu button{display:flex;align-items:center;width:100%;min-height:40px;padding:0 12px;border:0;border-radius:9px;background:transparent;color:inherit;font:inherit;font-size:13px;text-decoration:none;cursor:pointer;text-align:left}
++.ovdk-run-more menu a:hover,.ovdk-run-more menu button:hover{background:color-mix(in srgb,var(--text-primary,#ececf1) 7%,transparent)}
++@media(max-width:640px){.ovdk-run-more summary{width:44px;height:44px}.ovdk-run-more menu{top:46px}}
++@media (hover:hover){.ovdk-run:hover{background:color-mix(in srgb,var(--text-primary,#ececf1) 4%,transparent)}}
++.ovdk-run-st{font-weight:750;margin-right:4px}.ovdk-run[data-state=running] .ovdk-run-st{color:var(--accent-light,var(--accent,#60a5fa))}.ovdk-run[data-state=waiting] .ovdk-run-st{color:#f0a73a}
++.ovdk-run[data-state=scheduled] .ovdk-run-st{color:var(--text-primary,#ececf1)}.ovdk-run[data-state=done] .ovdk-run-st{color:var(--ok,#3fb27f)}.ovdk-run[data-state=failed] .ovdk-run-st{color:var(--live-red,#e5484d)}
++.ovdk-run-ic{width:34px;height:34px;display:grid;place-items:center;border-radius:10px;background:color-mix(in srgb,var(--run-color,var(--accent,#3b82f6)) 15%,transparent);color:var(--run-color,var(--accent,#3b82f6))}
++.ovdk-run-sub{min-width:0;display:grid;gap:3px}.ovdk-run-sub>b,.ovdk-run-title b{display:block;font-size:13.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
++.ovdk-run-title{color:inherit;text-decoration:none;min-width:0}.ovdk-run-title:hover b{text-decoration:underline;text-underline-offset:2px}.ovdk-run-line{font-size:12.5px;color:var(--text-secondary,#a8a8b2);line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
++.ovdk-run-model{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--text-secondary,#a8a8b2)}.ovdk-run-maker{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:6px;background:color-mix(in srgb,var(--text-primary,#ececf1) 8%,transparent)}.ovdk-run-maker svg{width:14px;height:14px;fill:currentColor}
++.ovdk-run-bar{height:3px;margin:2px 0;background:color-mix(in srgb,var(--accent,#3b82f6) 18%,transparent);border-radius:9px;overflow:hidden}.ovdk-run-bar:after{content:'';display:block;width:40%;height:100%;border-radius:9px;background:var(--accent,#3b82f6);animation:ovdk-run-go 1.6s cubic-bezier(.45,0,.2,1) infinite}
++@keyframes ovdk-run-go{from{transform:translateX(-100%)}to{transform:translateX(250%)}}html[data-ov-motion=off] .ovdk-run-bar:after{width:45%}
++.ovdk-run-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:3px}.ovdk-run-actions a,.ovdk-run-actions button,.ovdk-run-actions summary{min-height:32px;display:inline-flex;align-items:center;padding:0 12px;border-radius:999px;color:inherit;background:color-mix(in srgb,var(--text-primary,#ececf1) 7%,transparent);border:0;cursor:pointer;font:inherit;font-size:12.5px;font-weight:650;text-decoration:none;list-style:none}
++.ovdk-run-actions summary::-webkit-details-marker{display:none}.ovdk-run-actions .primary{background:var(--accent,#3b82f6);color:var(--on-accent,#fff)}
++@media (hover:hover){.ovdk-run-actions a:hover,.ovdk-run-actions button:not(.primary):hover,.ovdk-run-actions summary:hover{background:color-mix(in srgb,var(--text-primary,#ececf1) 12%,transparent)}}
++.ovdk-run-actions details{position:relative}.ovdk-run-actions details menu{position:absolute;right:0;z-index:2;min-width:180px;padding:4px;margin:0;list-style:none;border:1px solid var(--border,rgba(127,127,127,.2));border-radius:10px;background:var(--bg-primary,#19191f);box-shadow:0 8px 24px rgba(0,0,0,.2)}
++.ovdk-run-repeat{display:block;width:100%;min-height:36px;padding:0 10px;text-align:left;border:0;border-radius:10px;background:transparent;color:var(--accent-light,var(--accent,#60a5fa));font:inherit;font-size:12.5px;font-weight:650;cursor:pointer}
++@media(max-width:640px){.ovdk-run-filters button,.ovdk-run-actions a,.ovdk-run-actions button,.ovdk-run-actions summary,.ovdk-run-repeat{min-height:44px}.ovdk-skills button{min-height:52px}}
++`;
++ const h = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; };
++ const ago = (at) => { const n = Date.parse(at || ''); if (!Number.isFinite(n)) return ''; const m = Math.max(0, Math.round((Date.now() - n) / 60000)); return m < 1 ? 'now' : m < 60 ? `${m} min` : m < 1440 ? `${Math.floor(m / 60)} h` : `${Math.floor(m / 1440)} d`; };
++ const until = (at) => { const m = Math.max(0, Math.ceil((Date.parse(at || '') - Date.now()) / 60000)); return m < 1 ? 'now' : m < 60 ? `in ${m} min` : m < 1440 ? `in ${Math.ceil(m / 60)} h` : `in ${Math.ceil(m / 1440)} d`; };
++ const cash = (n) => n == null ? '' : Number(n) < .01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`;
++ const iconOf = (it) => { const s = String(it.skill || it.subject && it.subject.kind || it.kind || ''); return /post|blog|write|changelog|draft|patch/.test(s) ? ['fa-pen','#d7974d'] : /wiki|article|research/.test(s) ? ['fa-book','#9875d8'] : /image|pic|meme/.test(s) ? ['fa-image','#d66bad'] : /chat|thread/.test(s) ? ['fa-comments','#61a9d7'] : /code|repo/.test(s) ? ['fa-code','#63b89c'] : /clip|stream|video/.test(s) ? ['fa-film','#dc866a'] : /quest/.test(s) ? ['fa-flag','#b9a74b'] : ['fa-sparkles','#8b92d9']; };
++ const dots = () => { const NS = 'http://www.w3.org/2000/svg'; const s = document.createElementNS(NS, 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true');
++ [5.5, 12, 18.5].forEach((x) => { const c = document.createElementNS(NS, 'circle'); c.setAttribute('cx', String(x)); c.setAttribute('cy', '12'); c.setAttribute('r', '1.7'); s.append(c); }); return s; };
++ const isUrl = (u) => /^https:\/\//.test(String(u || ''));
++ // Where a person picks the model a run uses, or adds their own key.
++ const MODEL_PAGE = 'https://openvibe.actor/chat/model';
++ function mount(panel, o) {
++ const st = o.styles && o.styles(); if (st && !st.textContent.includes('.ovdk-overview{')) st.textContent += CSS;
++ // A site's Dock passes no scopes (full behaviour); the extension passes its read-only set.
++ const canDecide = !Array.isArray(o.scopes) || o.scopes.includes('actions.decide');
++ let doc = null, busy = false, timer = 0, gone = false, filter = o.state().agent_filter || 'all';
++ const request = async (path, init) => o.call(path, init);
++ const all = () => doc ? ['waiting','running','scheduled','done','failed'].flatMap((b) => doc.buckets[b] || []) : [];
++ function row(it) {
++ const li = h('li', 'ovdk-run ovac-item'); li.dataset.state = it.bucket; li.dataset.bucket = it.bucket;
++ const [glyph, color] = iconOf(it); li.style.setProperty('--run-color', color);
++ const ic = h('span', 'ovdk-run-ic'); const i = h('i', `fa-solid ${glyph}`); i.setAttribute('aria-hidden', 'true'); ic.append(i);
++ const body = h('div', 'ovdk-run-sub');
++ const title = it.subject && it.subject.title || it.title || 'Agent run';
++ const url = it.subject && it.subject.url || it.href;
++ if (isUrl(url)) { const t = h('a', 'ovdk-run-title'); t.href = url; t.target = '_blank'; t.rel = 'noopener'; t.append(h('b', '', title)); body.append(t); } else body.append(h('b', '', title));
++ const error = it.run_error; let st = 'Done', line = '';
++ if (it.bucket === 'running') { st = 'Running'; line = it.step || it.summary || 'Working'; }
++ else if (it.bucket === 'waiting') { st = 'Waiting'; line = it.summary || 'Your answer is needed'; }
++ else if (it.bucket === 'scheduled') { st = 'Scheduled'; line = it.next_run_at ? until(it.next_run_at) : 'Time pending'; }
++ else if (it.bucket === 'failed') { st = 'Failed'; line = error ? `${error.words} ${error.next}` : 'This run could not finish. Open it for details.'; }
++ else line = [it.summary, cash(it.cost_usd), it.duration_ms != null ? `${Math.round(it.duration_ms / 1000)}s` : ''].filter(Boolean).join(' · ');
++ const ln = h('span', 'ovdk-run-line'); ln.append(h('span', 'ovdk-run-st', st), document.createTextNode(line)); body.append(ln);
++ if (it.model) { const model = h('span', 'ovdk-run-model'); const mark = root.OpenVibeAIMarks && root.OpenVibeAIMarks.mark(it.maker, it.model); if (mark) model.append(mark); model.append(String(it.model)); body.append(model); }
++ if (it.bucket === 'running') body.append(h('span', 'ovdk-run-bar'));
++ const acts = h('div', 'ovdk-run-actions'); let more = null;
++ if (canDecide && it.bucket === 'waiting' && (it.actions || []).includes('approve')) for (const action of ['approve','decline']) {
++ const b = h('button', action === 'approve' ? 'primary' : '', action === 'approve' ? 'Approve' : 'Decline'); b.type = 'button'; b.dataset.do = action;
++ b.addEventListener('click', async () => { b.disabled = true; try { await request(`/api/v1/me/actions/${encodeURIComponent(it.service)}/${encodeURIComponent(it.id)}/${action}`, { method: 'POST', body: '{}' }); b.textContent = action === 'approve' ? 'Approved' : 'Declined'; } catch { b.disabled = false; b.textContent = 'Try again'; } }); acts.append(b);
++ }
++
++ // The error's own action says what the person can do about it: pick a model, add a key, or wait.
++ const what = error && error.action;
++ if (it.bucket === 'failed' && (what === 'pick_model' || what === 'add_key')) { const a = h('a', '', what === 'pick_model' ? 'Pick a model' : 'Add a key'); a.href = MODEL_PAGE; a.target = '_blank'; a.rel = 'noopener'; acts.append(a); }
++ if (canDecide && it.bucket === 'running' && (it.actions || []).includes('cancel')) { const b = h('button', '', 'Stop'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; try { await request(`/api/v1/me/actions/${encodeURIComponent(it.service)}/${encodeURIComponent(it.id)}/cancel`, { method: 'POST', body: '{}' }); refresh(); } catch { b.disabled = false; } }); acts.append(b); }
++ if (canDecide && it.bucket === 'failed' && (it.actions || []).includes('retry')) { const b = h('button', '', 'Retry'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; try { await request(`/api/v1/me/actions/${encodeURIComponent(it.service)}/${encodeURIComponent(it.id)}/retry`, { method: 'POST', body: '{}' }); refresh(); } catch { b.disabled = false; } }); acts.append(b); }
++ if (canDecide && it.service === 'actor' && (it.kind === 'agent_task') && ['done', 'failed'].includes(it.bucket)) { const menu = h('details', 'ovdk-run-more'); const summary = h('summary'); summary.setAttribute('aria-label', 'More'); summary.title = 'More'; summary.append(dots()); menu.append(summary); const list = h('menu');
++ const choose = h('li'); const a = h('a', '', 'Run again with another model'); a.href = MODEL_PAGE; a.target = '_blank'; a.rel = 'noopener'; choose.append(a); list.append(choose);
++ const rerun = h('li'); const b = h('button', '', 'Run again'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; try { await request(`/api/v1/me/actions/actor/${encodeURIComponent(it.id)}/retry`, { method: 'POST', body: '{}' }); menu.open = false; refresh(); } catch { b.disabled = false; b.textContent = 'Try again'; } }); rerun.append(b); list.append(rerun); menu.append(list); more = menu; }
++ if (acts.childNodes.length) body.append(acts); li.append(ic, body); if (more) li.append(more); if (root.OVGlyphs) root.OVGlyphs.swap(li); return li;
++ }
++ function grouped(items) { const map = new Map(); for (const it of items) { const day = String(it.updated_at || it.created_at || '').slice(0,10); const key = [it.skill || it.kind, it.subject && it.subject.kind || '', it.bucket, day].join('|'); if (!map.has(key)) map.set(key, []); map.get(key).push(it); } return [...map.values()]; }
++ function rows(items, target) { const list = h('ul', 'ovdk-runs'); grouped(items).forEach((group) => { if (group.length === 1) list.append(row(group[0])); else { const li = row(group[0]); const b = h('button', 'ovdk-run-repeat', `×${group.length} similar runs · Show all`); const details = h('ul', 'ovdk-runs'); details.hidden = true; group.slice(1).forEach((it) => details.append(row(it))); b.addEventListener('click', () => { details.hidden = !details.hidden; b.textContent = details.hidden ? `×${group.length} similar runs · Show all` : 'Hide repeats'; }); li.querySelector('.ovdk-run-sub').append(b, details); list.append(li); } }); target.append(list); }
++ async function skills(box) {
++ try {
++ // Through Network (every site's connect-src reaches openvibe.network and refuses openvibe.actor).
++ const data = await request('/api/v1/me/actions/skills');
++ const available = (data.listings || []).filter((x) => x && x.skill && x.scan && x.scan.state === 'passed' && x.curator && x.curator.state === 'active').slice(0, 6);
++ if (gone || !available.length) return;
++ box.append(h('h3', '', 'Things agents can do for you'));
++ const grid = h('div', 'ovdk-skills');
++ available.forEach((s) => {
++ const b = h('button', '', s.skill.name); b.type = 'button'; b.setAttribute('aria-label', `Start ${s.skill.name}`);
++ b.addEventListener('click', async () => {
++ b.disabled = true;
++ try {
++ const out = await request(`/api/v1/me/actions/skills/${encodeURIComponent(s.id)}/run`, { method: 'POST', body: JSON.stringify({ input: {}, choice: { mode: 'balanced' } }) });
++ if (out.run && out.run.id) root.location.href = `${o.actorBase}/tasks/${encodeURIComponent(out.run.id)}`;
++ else refresh();
++ } catch { b.disabled = false; b.textContent = `${s.skill.name} · Try again`; }
++ });
++ grid.append(b);
++ });
++ box.append(grid);
++ } catch { /* catalogue unavailable */ }
++ }
++ function overview() { const box = h('section', 'ovdk-overview'); const tiles = h('div', 'ovdk-tiles'); const now = Date.now(); const items = all(); const next = (doc.buckets.scheduled || []).find((x) => x.next_run_at); const periods = [[1,'Today'],[7,'This week']];
++ const first = h('div', 'ovdk-tile ovdk-tile-next'); first.append(h('small', '', 'Next'), h('b', '', next ? next.title : 'Nothing scheduled')); if (next) first.append(h('span', '', until(next.next_run_at))); tiles.append(first);
++ periods.forEach(([days,label]) => { const recent = items.filter((x) => now - Date.parse(x.updated_at || x.created_at) < days * 86400000); const done = recent.filter((x) => x.bucket === 'done').length, failed = recent.filter((x) => x.bucket === 'failed').length; const cost = recent.reduce((n, x) => n + (Number(x.cost_usd) || 0), 0);
++ const tile = h('div', 'ovdk-tile'); const n = h('b', '', String(recent.length)); n.append(h('em', '', recent.length === 1 ? 'run' : 'runs')); const sub = h('span');
++ if (done) { sub.append(h('i', 'ovdk-dot'), document.createTextNode(`${done} `)); } if (failed) { sub.append(h('i', 'ovdk-dot is-bad'), document.createTextNode(`${failed} `)); } if (cost) sub.append(document.createTextNode(cash(cost))); if (!recent.length) sub.textContent = 'None yet';
++ tile.setAttribute('aria-label', `${label}: ${recent.length} runs, ${done} done, ${failed} failed${cost ? `, ${cash(cost)}` : ''}`); sub.title = `${done} done · ${failed} failed`;
++ tile.append(h('small', '', label), n, sub); tiles.append(tile); }); box.append(tiles);
++ const recent = [...(doc.buckets.done || []), ...(doc.buckets.failed || [])].sort((a,b) => String(b.updated_at).localeCompare(String(a.updated_at))).slice(0,3); if (recent.length) { box.append(h('h3', '', 'Last results')); rows(recent, box); }
++ skills(box); return box; }
++ function paint() { if (gone || !doc) return; panel.textContent = ''; const items = all(); const filters = h('div', 'ovdk-run-filters'); for (const [id,label] of [['all','All'],['running','Running'],['waiting','Waiting'],['scheduled','Scheduled'],['done','Done'],['failed','Failed']]) { const b = h('button', '', label); b.type='button'; b.setAttribute('aria-pressed', String(filter===id)); b.addEventListener('click', () => { filter=id; o.save({agent_filter:id}); paint(); }); filters.append(b); } panel.append(filters);
++ if (filter==='all' && !(doc.counts.running || doc.counts.waiting)) panel.append(overview());
++ else { const shown = filter==='all' ? items : (doc.buckets[filter] || []); if (shown.length) rows(shown,panel); else panel.append(h('p','ovdk-none','No runs in this view.')); }
++ const foot = o.foot || h('div','ovdk-foot'); foot.textContent=''; const allLink = h('a','','All runs'); allLink.href='https://openvibe.actor/tasks'; const give = h('a','','Give an agent a task'); give.href='https://openvibe.actor/skills'; foot.append(allLink,give); if (!o.foot) panel.append(foot); }
++ async function refresh() { if (gone || busy || !o.active()) return; busy=true; clearTimeout(timer); try { doc = await request('/api/v1/me/actions'); if (gone) return; if (doc && doc.buckets) { paint(); if (o.onCounts) o.onCounts(doc.counts || {}); } } catch { if (!gone) { panel.textContent=''; panel.append(h('div','ovdk-none','Your runs did not load. Open Agents again to retry.')); } } finally { busy=false; if (!gone && o.active() && doc && doc.counts && doc.counts.running) timer=setTimeout(refresh,15000); } }
++ refresh(); if (o.loadMarks) o.loadMarks().then((ok) => { if (!gone && ok && doc) paint(); });
++ return { refresh, destroy: () => { gone = true; clearTimeout(timer); } };
++ }
++ root.OpenVibeDockRuns = { mount };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/browser/shared/dock-share.js b/browser/shared/dock-share.js
nnew file mode 100644
iindex 0000000..247d2f4
---- /dev/null
++++ b/browser/shared/dock-share.js
@@@ -0,0 +1,91 @@
++/**
++ * openvibe-shared/dock-share.js: the Dock's "Send to…" (4.144.0). dock.js loads it the first time something is sent
++ * from a page (OpenVibeDock.share, the share sheet's OpenVibe Chat), so the Dock that every signed-in page carries
++ * stays small.
++ *
++ * OpenVibeDockShare.bar({ draft, h, chatCall, open, cancel, host }) → the bar, drawn above the person's conversations
++ * OpenVibeDockShare.ready({ draft, h, panel, form, box, fit }) the text in a conversation's box, unsent, said so
++ *
++ * The bar says what is being sent and lets the person write to someone new by their OpenVibe name: Chat's
++ * /api/dm/users/search finds them, POST /api/dm/conversations starts the conversation, both with the person's own
++ * token (dock.js's chatCall). Nothing is sent from here: the message waits in the conversation's box until the person
++ * presses Send there.
++ */
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OpenVibeDockShare) return;
++
++ const CSS = `
++.ovdk-share { flex: none; display: grid; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); }
++.ovdk-share-h { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
++.ovdk-share-h b { font-size: 14px; color: var(--text-primary, #ececf1); }
++.ovdk-share-x { min-height: 32px; padding: 0 10px; border: 0; border-radius: 8px; background: none; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; }
++.ovdk-share-x:hover { color: var(--text-primary, #ececf1); }
++.ovdk-share-what { margin: 0; font-size: 13px; color: var(--text-secondary, #b6b6c0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovdk-to { display: flex; gap: 8px; }
++.ovdk-to input { flex: 1; min-width: 0; box-sizing: border-box; min-height: 40px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border-light, var(--border, rgba(255,255,255,.14))); background: var(--bg-primary, #12141b); color: var(--text-primary, #ececf1); font: inherit; font-size: 14px; }
++.ovdk-to input:focus { outline: none; border-color: var(--accent, #3b82f6); }
++.ovdk-to-go { flex: none; min-height: 40px; padding: 0 14px; border: 0; border-radius: 12px; cursor: pointer; font: inherit; font-size: 14px; font-weight: 700; color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }
++.ovdk-to-go:disabled { opacity: .55; cursor: default; }
++.ovdk-share .ovdk-err { margin: 0; }
++.ovdk-ready { flex: none; margin: 6px 14px 0; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }
++`;
++ function style(host) {
++ const st = (host && host.ownerDocument && host.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles');
++ if (st && !st.textContent.includes('.ovdk-share {')) st.textContent += CSS;
++ }
++
++ function bar(c) {
++ const { draft, h, chatCall, open, cancel, host } = c;
++ style(host);
++ let busy = false;
++ const el = h('div', 'ovdk-share');
++ const top = h('div', 'ovdk-share-h');
++ const x = h('button', 'ovdk-share-x', 'Cancel'); x.type = 'button';
++ x.addEventListener('click', () => cancel());
++ top.append(h('b', '', 'Send to…'), x);
++ const what = h('p', 'ovdk-share-what', draft.title || draft.text); what.title = draft.text;
++ const to = h('form', 'ovdk-to');
++ const name = h('input'); name.type = 'text'; name.maxLength = 40; name.autocomplete = 'off'; name.spellcheck = false;
++ name.placeholder = 'Someone new: their OpenVibe name'; name.setAttribute('aria-label', 'Their OpenVibe name');
++ const next = h('button', 'ovdk-to-go', 'Next'); next.type = 'submit';
++ const err = h('p', 'ovdk-err'); err.hidden = true;
++ to.append(name, next);
++ to.addEventListener('submit', async (e) => {
++ e.preventDefault();
++ const want = name.value.trim().replace(/^@/, '');
++ if (want.length < 2 || busy) return;
++ busy = true; next.disabled = true; err.hidden = true;
++ try {
++ const found = (await chatCall(`/api/dm/users/search?q=${encodeURIComponent(want)}`)).users || [];
++ const exact = found.find((u) => String(u.username || '').toLowerCase() === want.toLowerCase());
++ if (!exact) {
++ err.textContent = found.length ? `Nobody is called @${want}. Did you mean @${found[0].username}?` : `Nobody on OpenVibe is called @${want}.`;
++ err.hidden = false;
++ return;
++ }
++ const made = await chatCall('/api/dm/conversations', { method: 'POST', body: JSON.stringify({ user_ids: [exact.id] }) });
++ const id = (made.conversation && made.conversation.id) || made.id;
++ if (!id) throw new Error('no conversation');
++ await open(id);
++ } catch (z) {
++ err.textContent = z.status === 429 ? 'Too many new conversations just now. Try again in a little while.' : z.status === 403 ? (z.message || 'You can’t start a conversation with them.') : 'That did not work. Try again.';
++ err.hidden = false;
++ } finally { busy = false; next.disabled = false; }
++ });
++ el.append(top, what, to, err);
++ return el;
++ }
++
++ /** The conversation the person picked: the text in its box, and a line saying nothing is sent until they send it. */
++ function ready({ draft, h, panel, form, box, fit }) {
++ box.value = draft;
++ const line = h('p', 'ovdk-ready', 'Ready to send: change it if you like, then press Send.');
++ panel.insertBefore(line, form);
++ box.addEventListener('input', () => line.remove(), { once: true });
++ form.addEventListener('submit', () => line.remove(), { once: true });
++ setTimeout(fit, 0);
++ }
++
++ root.OpenVibeDockShare = { bar, ready };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/browser/shared/dock-timeline.js b/browser/shared/dock-timeline.js
nnew file mode 100644
iindex 0000000..8a7e231
---- /dev/null
++++ b/browser/shared/dock-timeline.js
@@@ -0,0 +1,423 @@
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — the Dock's Agent panel (design 2026-11-07: one agent, one timeline behind the Dock), Shared 4.126.0
++//
++// OpenVibeDockTimeline.mount(container, { token, base, active, focus }) → { refresh(), destroy() }
++//
++// `token` is the person's own Network token: a string, or the function the navbar hands over (which
++// resolves it when it is needed). Nothing is asked without it: a signed-out page can never read the timeline.
++// OpenVibe.Actor's API is GET /api/v1/agent/timeline (the person's own steps, newest first, with the cursor and the
++// mode and needs-you filters), POST /api/v1/agent/approvals/:product/:id/decide (an approval is answered in the mode
++// that owns it) and POST /api/v1/agent/kill-switch (stop what is open and pause the agent). On a site every call goes
++// through Network's same-origin relay (/api/frame/agent…, /api/frame/tasks…) because every site's connect-src allows
++// openvibe.network and refuses openvibe.actor; `base` reaches Actor itself in a test, and `frameBase` names the relay.
++// `focus` is the id of the step to point at (the task id a site opened the Dock with): the entry that
++// carries it is marked and brought into view.
++//
++// The panel is what the person reads:
++// Now one card for what the agent is doing this moment: the mode's word, the step in plain words, how long,
++// and the mode's own controls (Stop, and Resume when it is paused). Nothing running: "Your agent is idle."
++// Needs you every approval or question from any mode, oldest first, with its buttons in place.
++// Timeline one entry per step, newest first: the mode, the title, where it ran and why, the outcome, the time
++// and the cost, and a link to the step's own page. A mode change is a line of its own.
++// Folding today's entries open; earlier days are counted folds ("Yesterday (14)"); a long run of one mode folds
++// into "12 more Code steps".
++// Filters a chip for each mode and for "Needs you", in the URL of the panel; a filter never hides without a
++// count.
++//
++// Everything is set as text, never as markup: a title, a reason or a link label never becomes HTML.
++// ═══════════════════════════════════════════════════════════════
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OpenVibeDockTimeline) return;
++
++ const POLL_MS = 15000;
++ const MODES = [
++ { id: 'talk', word: 'Talk' }, { id: 'act', word: 'Act' }, { id: 'run', word: 'Run' },
++ { id: 'code', word: 'Code' }, { id: 'browse', word: 'Browse' }, { id: 'watch', word: 'Watch' },
++ { id: 'stream', word: 'Stream' },
++ ];
++ const word = (mode) => (MODES.find((m) => m.id === mode) || { word: 'Agent' }).word;
++ const OUTCOME = { done: 'Done', failed: 'Failed', stopped: 'Stopped by you', running: 'Running', waiting: 'Waiting', needs_you: 'Needs you' };
++
++ function el(doc, tag, cls, text) {
++ const n = doc.createElement(tag);
++ if (cls) n.className = cls;
++ if (text !== undefined && text !== null) n.textContent = String(text);
++ return n;
++ }
++
++ function since(startedAt) {
++ const t = Date.parse(String(startedAt || ''));
++ if (!Number.isFinite(t)) return '';
++ const s = Math.max(0, Math.round((Date.now() - t) / 1000));
++ if (s < 60) return s + 's';
++ if (s < 3600) return Math.round(s / 60) + ' min';
++ return Math.round(s / 3600) + ' h';
++ }
++
++ function dayKey(iso) {
++ const d = new Date(String(iso || ''));
++ if (Number.isNaN(d.getTime())) return '';
++ return d.toISOString().slice(0, 10);
++ }
++
++ function styles(doc) {
++ if (!doc || doc.getElementById('ovdt-styles')) return;
++ const st = doc.createElement('style');
++ st.id = 'ovdt-styles';
++ st.textContent = [
++ // The site's own theme (Opus, 11-07): the Dock's tokens, so the panel is the navy of a dark site and the paper
++ // of a light one, never a grey card of its own. The site's font, a gutter that clears the panel's edge, one
++ // primary button per card, and a breathing dot only on what is really running.
++ '.ovdt{font:inherit;font-size:14px;line-height:1.45;color:var(--text-primary,#ececf1);padding:4px 12px 20px}',
++ '.ovdt h3{margin:14px 2px 8px;font-size:11px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;color:var(--text-muted,#93a0b4)}',
++ '.ovdt h3:first-child{margin-top:6px}',
++ '.ovdt-card{border:1px solid var(--border,rgba(255,255,255,.1));border-radius:14px;padding:12px 14px;margin:0 0 8px;background:color-mix(in srgb,var(--text-primary,#ececf1) 4%,transparent)}',
++ '.ovdt-needs .ovdt-card{border-color:color-mix(in srgb,var(--warning,#f5a524) 38%,var(--border,rgba(255,255,255,.1)));background:color-mix(in srgb,var(--warning,#f5a524) 7%,transparent)}',
++ '.ovdt-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
++ '.ovdt-needs .ovdt-card>.ovdt-mode{margin-bottom:2px;font-size:11.5px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;color:color-mix(in srgb,var(--warning,#f5a524) 70%,var(--text-primary,#ececf1))}',
++ '.ovdt-mode{font-weight:750}',
++ '.ovdt-title{flex:1 1 100%;min-width:0;overflow-wrap:anywhere;font-size:14.5px;font-weight:550}',
++ '.ovdt-meta{color:var(--text-muted,#93a0b4);font-size:12.5px;overflow-wrap:anywhere}',
++ '.ovdt-outcome{display:inline-flex;align-items:center;gap:6px;padding:2px 9px;border-radius:999px;font-size:11px;font-weight:750;letter-spacing:.04em;text-transform:uppercase;color:var(--text-secondary,#a8b3c4);background:color-mix(in srgb,var(--text-primary,#ececf1) 7%,transparent)}',
++ '.ovdt-outcome[data-s="running"]{color:color-mix(in srgb,var(--accent,#3b82f6) 72%,var(--text-primary,#ececf1));background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent)}',
++ '.ovdt-outcome[data-s="running"]::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;animation:ovdt-breathe 1.8s ease-in-out infinite}',
++ '.ovdt-outcome[data-s="failed"]{color:var(--danger-light,#f87171);background:color-mix(in srgb,var(--danger,#ef4444) 14%,transparent)}',
++ '@keyframes ovdt-breathe{50%{opacity:.35;transform:scale(.8)}}',
++ '.ovdt-buttons{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}',
++ '.ovdt-btn{min-height:44px;min-width:44px;padding:0 16px;border-radius:12px;border:1px solid var(--border,rgba(255,255,255,.14));background:transparent;color:inherit;font:inherit;font-weight:650;cursor:pointer}',
++ '.ovdt-btn.is-primary{border-color:transparent;background:var(--accent,#3b82f6);color:var(--on-accent,#fff)}',
++ '.ovdt-btn.is-stop{border-color:color-mix(in srgb,var(--danger,#ef4444) 45%,transparent);color:var(--danger-light,#f87171)}',
++ '.ovdt-btn:disabled{opacity:.55;cursor:default}',
++ '.ovdt-btn:focus-visible,.ovdt-chip:focus-visible,.ovdt-fold:focus-visible{outline:2px solid var(--accent-light,var(--accent,#3b82f6));outline-offset:1px}',
++ '@media (hover:hover){.ovdt-btn:not(.is-primary):hover,.ovdt-chip:hover{background:color-mix(in srgb,var(--text-primary,#ececf1) 7%,transparent)}.ovdt-btn.is-primary:hover{filter:brightness(1.08)}}',
++ '.ovdt-chip{min-height:44px;padding:0 14px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.12));background:transparent;color:var(--text-secondary,#a8b3c4);font:inherit;font-size:13.5px;font-weight:650;cursor:pointer;white-space:nowrap;flex:none}',
++ '.ovdt-chip[aria-pressed="true"]{color:color-mix(in srgb,var(--accent,#3b82f6) 72%,var(--text-primary,#ececf1));border-color:color-mix(in srgb,var(--accent,#3b82f6) 55%,transparent);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent)}',
++ '.ovdt-chips{display:flex;gap:8px;overflow-x:auto;margin:4px -12px 0;padding:6px 12px 10px;scrollbar-width:none;-webkit-overflow-scrolling:touch}',
++ '.ovdt-chips::-webkit-scrollbar{display:none}',
++ '.ovdt-fold{width:100%;text-align:left;min-height:44px;margin:0 0 8px;padding:0 14px;border-radius:12px;border:1px dashed var(--border,rgba(255,255,255,.16));background:transparent;color:var(--text-secondary,#a8b3c4);font:inherit;font-weight:650;cursor:pointer}',
++ '.ovdt-card.ovdt-focus{border-color:color-mix(in srgb,var(--accent,#3b82f6) 55%,transparent)}',
++ '.ovdt-empty{color:var(--text-muted,#93a0b4);padding:4px 2px 6px}',
++ '.ovdt-card.ovdt-empty{padding:12px 14px}',
++ '.ovdt-link{color:color-mix(in srgb,var(--accent,#3b82f6) 72%,var(--text-primary,#ececf1));font-weight:650;text-decoration:none}',
++ '@media (max-width:640px){.ovdt-link{display:inline-flex;align-items:center;min-height:44px;padding:0 4px}}',
++ '',
++ ].join('');
++ (doc.head || doc.documentElement).appendChild(st);
++ }
++
++ function mount(container, opts = {}) {
++ const doc = container.ownerDocument || document;
++ styles(doc);
++ // Network's frame relay on a site; an explicit base reaches Actor itself (a test).
++ const direct = opts.base ? String(opts.base).replace(/\/+$/, '') : '';
++ const frameBase = String(opts.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
++ const endpoint = (path) => (direct ? direct + path : frameBase + path.replace(/^\/api\/v1/, ''));
++ const rawToken = opts.token;
++ const active = typeof opts.active === 'function' ? opts.active : () => true;
++ const focus = typeof opts.focus === 'function' ? opts.focus : () => opts.focus || null;
++ const filters = new Set();
++ let timer = null;
++ let destroyed = false;
++
++ // The navbar hands the Dock a function (the token may change when the person signs in again),
++ // so it is resolved when a call is made, never read once at mount.
++ async function bearer() {
++ const t = typeof rawToken === 'function' ? await rawToken() : rawToken;
++ return t ? 'Bearer ' + t : null;
++ }
++
++ const wrap = el(doc, 'div', 'ovdt');
++ const nowHead = el(doc, 'h3', null, 'Now');
++ const nowBox = el(doc, 'div', 'ovdt-now');
++ const needsHead = el(doc, 'h3', null, 'Needs you');
++ const needsBox = el(doc, 'div', 'ovdt-needs');
++ const chips = el(doc, 'div', 'ovdt-chips');
++ const list = el(doc, 'div', 'ovdt-list');
++ wrap.append(nowHead, nowBox, needsHead, needsBox, chips, list);
++ container.appendChild(wrap);
++
++ async function api(path, options = {}) {
++ const auth = await bearer();
++ if (!auth) throw new Error('Sign in to see your agent');
++ const res = await fetch(endpoint(path), Object.assign({ headers: { Accept: 'application/json', Authorization: auth } }, options));
++ const body = await res.json().catch(() => ({}));
++ if (!res.ok) throw new Error((body && (body.detail || body.title)) || ('HTTP ' + res.status));
++ return body;
++ }
++
++ function chip(id, label, count) {
++ const b = el(doc, 'button', 'ovdt-chip', label + (count === undefined || count === null ? '' : ' (' + count + ')'));
++ b.type = 'button';
++ b.setAttribute('aria-pressed', filters.has(id) ? 'true' : 'false');
++ b.addEventListener('click', () => {
++ if (filters.has(id)) filters.delete(id); else filters.add(id);
++ refresh();
++ });
++ return b;
++ }
++
++ /** The step's state as a small pill; data-s lets a running step breathe and a failed one show red. */
++ function outcome(status) {
++ const o = el(doc, 'span', 'ovdt-outcome', OUTCOME[status] || status);
++ o.setAttribute('data-s', String(status || '').toLowerCase());
++ return o;
++ }
++
++ function renderNow(now) {
++ nowBox.textContent = '';
++ if (!now) {
++ nowBox.appendChild(el(doc, 'div', 'ovdt-card ovdt-empty', 'Your agent is idle. Ask it something in Talk.'));
++ return;
++ }
++ const card = el(doc, 'div', 'ovdt-card');
++ const row = el(doc, 'div', 'ovdt-row');
++ row.appendChild(el(doc, 'span', 'ovdt-mode', word(now.mode)));
++ row.appendChild(outcome(now.status));
++ row.appendChild(el(doc, 'span', 'ovdt-title', now.title));
++ row.appendChild(el(doc, 'span', 'ovdt-meta', since(now.started_at)));
++ card.appendChild(row);
++ if (now.placement && (now.placement.where || now.placement.why)) {
++ card.appendChild(el(doc, 'div', 'ovdt-meta', [now.placement.where, now.placement.why].filter(Boolean).join(': ')));
++ }
++ // The Computer this step works on (plan 2026-11-07): the same machine and cost the task record carries.
++ if (now.computer) card.appendChild(el(doc, 'div', 'ovdt-meta', [now.computer.machine, now.computer.cost_hint].filter(Boolean).join(' · ')));
++ const buttons = el(doc, 'div', 'ovdt-buttons');
++ // Take over / Give it back: one API on the task at Actor, the same hand-off the agent's own
++ // hand_to_person tool uses. Only a step that names its task (ref) and its Computer offers it.
++ if (now.computer && now.ref) {
++ const driving = now.computer.driving === true || /^You took over/.test(String(now.title || ''));
++ const take = el(doc, 'button', 'ovdt-btn is-primary', driving ? 'Give it back' : 'Take over');
++ take.type = 'button';
++ take.addEventListener('click', async () => {
++ take.disabled = true;
++ const auth = await bearer();
++ try {
++ await api('/api/v1/tasks/' + encodeURIComponent(now.ref) + (driving ? '/giveback' : '/takeover'), {
++ method: 'POST', headers: { 'Content-Type': 'application/json', ...(auth ? { Authorization: auth } : {}) }, body: '{}',
++ });
++ } catch (err) { /* the panel refreshes either way */ }
++ refresh();
++ });
++ buttons.appendChild(take);
++ }
++ const stop = el(doc, 'button', 'ovdt-btn is-stop', 'Stop');
++ stop.type = 'button';
++ stop.addEventListener('click', async () => {
++ stop.disabled = true;
++ const auth = await bearer();
++ try { await api('/api/v1/agent/kill-switch', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(auth ? { Authorization: auth } : {}) }, body: JSON.stringify({ action: 'stop' }) }); } catch (err) { /* the panel refreshes either way */ }
++ refresh();
++ });
++ buttons.appendChild(stop);
++ card.appendChild(buttons);
++ nowBox.appendChild(card);
++ }
++
++ function renderNeeds(needs) {
++ needsBox.textContent = '';
++ if (!needs.length) {
++ needsBox.appendChild(el(doc, 'div', 'ovdt-empty', 'Nothing needs you.'));
++ return;
++ }
++ for (const item of needs) {
++ const card = el(doc, 'div', 'ovdt-card');
++ card.appendChild(el(doc, 'div', 'ovdt-mode', word(item.mode) + ' needs you'));
++ card.appendChild(el(doc, 'div', 'ovdt-title', item.title));
++ if (item.reason) card.appendChild(el(doc, 'div', 'ovdt-meta', item.reason));
++ const buttons = el(doc, 'div', 'ovdt-buttons');
++ for (const [label, decision] of [['Approve', 'approve'], ['Decline', 'decline']]) {
++ const b = el(doc, 'button', decision === 'approve' ? 'ovdt-btn is-primary' : 'ovdt-btn', label);
++ b.type = 'button';
++ b.addEventListener('click', async () => {
++ b.disabled = true;
++ const approval = item.approval || {};
++ const auth = await bearer();
++ try {
++ await api('/api/v1/agent/approvals/' + encodeURIComponent(approval.product || item.product) + '/' + encodeURIComponent(approval.id) + '/decide', {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', ...(auth ? { Authorization: auth } : {}) },
++ body: JSON.stringify({ decision }),
++ });
++ } catch (err) { /* the panel refreshes either way */ }
++ refresh();
++ });
++ buttons.appendChild(b);
++ }
++ card.appendChild(buttons);
++ needsBox.appendChild(card);
++ }
++ }
++
++ function entryNode(entry) {
++ const card = el(doc, 'div', 'ovdt-card');
++ const row = el(doc, 'div', 'ovdt-row');
++ row.appendChild(el(doc, 'span', 'ovdt-mode', word(entry.mode)));
++ row.appendChild(outcome(entry.status));
++ row.appendChild(el(doc, 'span', 'ovdt-title', entry.title));
++ const when = new Date(entry.started_at || 0);
++ row.appendChild(el(doc, 'span', 'ovdt-meta', Number.isNaN(when.getTime()) ? '' : when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })));
++ card.appendChild(row);
++ if (entry.transition) card.appendChild(el(doc, 'div', 'ovdt-meta', entry.transition));
++ else if (entry.placement && (entry.placement.where || entry.placement.why)) {
++ card.appendChild(el(doc, 'div', 'ovdt-meta', [entry.placement.where, entry.placement.why].filter(Boolean).join(': ')));
++ }
++ if (entry.computer) card.appendChild(el(doc, 'div', 'ovdt-meta', [entry.computer.machine, entry.computer.cost_hint].filter(Boolean).join(' · ')));
++ if (entry.reason) card.appendChild(el(doc, 'div', 'ovdt-meta', entry.reason));
++ if (entry.cost_usd !== null && entry.cost_usd !== undefined) card.appendChild(el(doc, 'div', 'ovdt-meta', '$' + Number(entry.cost_usd).toFixed(2)));
++ if (entry.link) {
++ const a = el(doc, 'a', 'ovdt-link', 'Open');
++ a.href = entry.link;
++ a.rel = 'noopener noreferrer';
++ a.target = '_blank';
++ card.appendChild(a);
++ }
++ // The step a site opened the Dock at (its task id): it is marked, so the person sees where they landed.
++ const wanted = focusId();
++ if (wanted && (entry.id === wanted || entry.ref === wanted)) card.classList.add('ovdt-focus');
++ return card;
++ }
++
++ /** The step to point at: the id (a task id) a site opened the Dock with, if it is still there. */
++ function focusId() {
++ try { return String(focus() || '').trim() || null; } catch (err) { return null; }
++ }
++
++ /** Bring the focused step into view (the Dock's panel is short; the step may be below the fold). */
++ function scrollToFocus() {
++ const wanted = focusId();
++ if (!wanted) return;
++ const node = list.querySelector('.ovdt-focus');
++ if (!node || !node.scrollIntoView) return;
++ try { node.scrollIntoView({ block: 'nearest' }); } catch (err) { node.scrollIntoView(); }
++ }
++
++ function renderList(entries) {
++ list.textContent = '';
++ if (!entries.length) {
++ list.appendChild(el(doc, 'div', 'ovdt-empty', 'Nothing yet. The agent\u2019s steps show up here as it works.'));
++ return;
++ }
++ // Today's entries are open; earlier days are counted folds.
++ const today = new Date().toISOString().slice(0, 10);
++ const groups = new Map();
++ for (const entry of entries) {
++ const key = dayKey(entry.started_at);
++ if (!groups.has(key)) groups.set(key, []);
++ groups.get(key).push(entry);
++ }
++ for (const [key, group] of groups) {
++ if (key === today) {
++ for (const entry of group) list.appendChild(entryNode(entry));
++ continue;
++ }
++ const fold = el(doc, 'button', 'ovdt-fold', labelOf(key, group.length));
++ fold.type = 'button';
++ fold.setAttribute('aria-expanded', 'false');
++ fold.addEventListener('click', () => {
++ const open = fold.getAttribute('aria-expanded') === 'true';
++ fold.setAttribute('aria-expanded', open ? 'false' : 'true');
++ const shown = fold.nextElementSibling;
++ if (shown && shown.classList.contains('ovdt-fold-body')) shown.remove();
++ if (!open) {
++ const box = el(doc, 'div', 'ovdt-fold-body');
++ for (const entry of group) box.appendChild(entryNode(entry));
++ fold.after(box);
++ }
++ });
++ list.appendChild(fold);
++ }
++ // A long run of one mode folds into "12 more Code steps".
++ let run = 0;
++ let runMode = null;
++ const runs = [];
++ for (const entry of entries) {
++ if (entry.mode === runMode) { run += 1; continue; }
++ if (run >= 3) runs.push({ mode: runMode, count: run });
++ runMode = entry.mode;
++ run = 1;
++ }
++ if (run >= 3) runs.push({ mode: runMode, count: run });
++ for (const r of runs) {
++ const more = el(doc, 'div', 'ovdt-meta', r.count + ' more ' + word(r.mode) + ' steps');
++ list.appendChild(more);
++ }
++ }
++
++ function labelOf(day, count) {
++ const today = new Date();
++ const d = new Date(day + 'T00:00:00Z');
++ const diff = Math.round((Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()) - d.getTime()) / 86400000);
++ if (diff === 1) return 'Yesterday (' + count + ')';
++ if (diff < 7) return 'This week (' + count + ')';
++ return day + ' (' + count + ')';
++ }
++
++ function renderChips(counts) {
++ chips.textContent = '';
++ for (const m of MODES) {
++ const n = counts && counts[m.id];
++ if (!n) continue;
++ chips.appendChild(chip(m.id, m.word, n));
++ }
++ if (counts && counts.needs_you) chips.appendChild(chip('needs_you', 'Needs you', counts.needs_you));
++ }
++
++ async function refresh() {
++ if (destroyed || !active()) return;
++ const mode = [...filters].find((f) => f !== 'needs_you');
++ const query = [];
++ if (mode) query.push('mode=' + encodeURIComponent(mode));
++ if (filters.has('needs_you')) query.push('needs_you=1');
++ const path = '/api/v1/agent/timeline' + (query.length ? '?' + query.join('&') : '');
++ let data;
++ try { data = await api(path); } catch (err) {
++ if (destroyed) return;
++ container.hidden = true;
++ return;
++ }
++ if (destroyed) return;
++ container.hidden = !data.now && !(data.needs_you || []).length && !(data.entries || []).length;
++ renderNow(data.now);
++ renderNeeds(data.needs_you || []);
++ renderChips(data.counts || {});
++ renderList(data.entries || []);
++ scrollToFocus();
++ writeUrl();
++ }
++
++ function writeUrl() {
++ try {
++ const url = new URL(doc.defaultView.location.href);
++ url.searchParams.delete('mode');
++ url.searchParams.delete('needs_you');
++ for (const f of filters) {
++ if (f === 'needs_you') url.searchParams.set('needs_you', '1');
++ else url.searchParams.set('mode', f);
++ }
++ doc.defaultView.history.replaceState(null, '', url.toString());
++ } catch (err) { /* the panel keeps its filters in memory */ }
++ }
++
++ function start() {
++ if (timer) return;
++ timer = setInterval(refresh, POLL_MS);
++ if (timer.unref) timer.unref();
++ }
++
++ refresh();
++ start();
++
++ return {
++ refresh,
++ destroy() {
++ destroyed = true;
++ if (timer) clearInterval(timer);
++ timer = null;
++ wrap.remove();
++ },
++ };
++ }
++
++ root.OpenVibeDockTimeline = { mount };
++}(typeof window !== 'undefined' ? window : globalThis));
ddiff --git a/browser/shared/dock-timer.js b/browser/shared/dock-timer.js
iindex cfd3616..5375c17 100644
---- a/browser/shared/dock-timer.js
++++ b/browser/shared/dock-timer.js
@@@ -36,7 +36,6 @@
.ovdk-tmenu .go { background: var(--accent, #3b82f6); border-color: transparent; color: var(--on-accent, #fff); }
.ovdk-pill[data-tone="timer"] .ovdk-t { font-variant-numeric: tabular-nums; }
.ovdk-pill[data-tone="timer-done"] { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); }
--@media (prefers-reduced-motion: reduce) { .ovdk-timer-bar i { transition: none; } }
`;
const pad = (n) => String(n).padStart(2, '0');
const left = (ms) => {
ddiff --git a/browser/shared/dock-vibe.js b/browser/shared/dock-vibe.js
iindex 080eb23..c11b429 100644
---- a/browser/shared/dock-vibe.js
++++ b/browser/shared/dock-vibe.js
@@@ -9,11 +9,23 @@
if (typeof document === 'undefined' || root.OpenVibeDockVibe) return;
const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
++ const PLUS = '<path d="M12 5.5v13M5.5 12h13"/>';
++ const OUT = '<path d="M13.5 5.5h5v5M18.5 5.5l-7.5 7.5M17 13.5v4a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 5 17.5v-9A1.5 1.5 0 0 1 6.5 7h4"/>';
const MIC = '<rect x="9" y="3.5" width="6" height="10.5" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5v3"/>';
const CSS = `
++ .ovdk-ib.is-new { background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
++ .ovdk-ib.is-new:hover, .ovdk-ib.is-new:focus-visible { background: color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); }
.ovdk-vibe-h .ovdk-i { color: var(--accent, #3b82f6); margin-left: 6px; }
-- .ovdk-away { margin-left: auto; font-size: 12.5px; font-weight: 650; color: var(--accent, #3b82f6); text-decoration: none; white-space: nowrap; }
-- .ovdk-away:hover { text-decoration: underline; }
++ .ovdk-vibe-list { list-style: none; padding: 2px 6px 6px; margin: 0; display: grid; gap: 2px; }
++ .ovdk-vibe-list button { width: 100%; min-height: 56px; display: grid; grid-template-columns: 36px minmax(0,1fr) auto; grid-template-rows: auto auto; gap: 2px 12px; align-items: center; padding: 9px 10px; border: 0; border-radius: 14px; background: transparent; color: var(--text-primary,#ececf1); text-align: left; cursor: pointer; font: inherit; }
++ .ovdk-vibe-list .ovdk-vibe-site { grid-row: 1 / 3; width: 36px; height: 36px; border-radius: 11px; display: grid; place-items: center; background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
++ .ovdk-vibe-list .ovdk-vibe-site svg { width: 19px; height: 19px; }
++ .ovdk-vibe-list b { font-size: 14px; } .ovdk-vibe-list span { font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }
++ .ovdk-vibe-list small { justify-self: end; }
++ .ovdk-vibe-list button:hover, .ovdk-vibe-list button:focus-visible { background: var(--hover-bg,rgba(127,127,127,.12)); }
++ .ovdk-vibe-list b, .ovdk-vibe-list span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++ .ovdk-vibe-list small, .ovdk-vibe-list time { color: var(--text-muted,#8b8b96); font-size: 12px; }
++ .ovdk-vibe-back { min-width: 44px; min-height: 44px; display: grid; place-items: center; border: 0; background: transparent; color: inherit; cursor: pointer; }
.ovdk-msg.is-vibe .ovdk-text a { color: var(--accent, #3b82f6); text-decoration: underline; word-break: break-all; }
.ovdk-typing .ovdk-text { display: inline-flex; gap: 4px; align-items: center; padding: 10px 12px; }
.ovdk-typing i { width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted, #8b8b96); animation: ovdk-hop 1s ease-in-out infinite; }
@@@ -23,7 +35,8 @@
.ovdk-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin-top: 8px; }
.ovdk-chip { padding: 7px 12px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; cursor: pointer; }
.ovdk-chip:hover { border-color: var(--accent, #3b82f6); }
-- .ovdk-mic { width: 40px; height: 40px; flex: none; margin-left: -8px; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center; background: transparent; color: var(--text-muted, #8b8b96); touch-action: none; }
++ .ovdk-mic, .ovdk-attach { width: 44px; height: 44px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center; background: transparent; color: var(--text-muted, #8b8b96); touch-action: none; }
++ .ovdk-attach:hover { color: var(--text-primary,#ececf1); }
.ovdk-mic:hover { color: var(--text-primary, #ececf1); }
.ovdk-mic.is-on { color: var(--on-accent, #fff); background: var(--live-red, #e5484d); box-shadow: 0 0 0 4px color-mix(in srgb, var(--live-red, #e5484d) 28%, transparent); }
.ovdk-res { display: grid; gap: 6px; margin-top: 6px; max-width: 100%; }
@@@ -40,10 +53,28 @@
.ovdk-res-a button:disabled { opacity: .55; cursor: default; }
.ovdk-res-c.is-stopped { border-color: var(--border, rgba(255,255,255,.12)); background: transparent; }
.ovdk-res-c.is-stopped .ovdk-i, .ovdk-res-c.is-stopped .ovdk-res-t { color: var(--text-muted, #8b8b96); }
-- @media (prefers-reduced-motion: reduce) { .ovdk-typing i { animation: none; } }
`;
let ctx = null, G = {};
++ // 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': '9b3472802903' };
++ function joinComposer(form) {
++ if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
++ form.setAttribute('data-ovc-no-files', '');
++ const go = () => { try { if (root.OpenVibeComposer && form.isConnected) root.OpenVibeComposer.enhance(form); } catch (e) { console.error(e); } };
++ if (root.OpenVibeComposer) { go(); return; }
++ const m = /^(https?:\/\/[^?#]*\/)dock-vibe(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
++ if (!m) return;
++ let sc = document.getElementById('ov-composer-script');
++ if (!sc) { sc = document.createElement('script'); sc.id = 'ov-composer-script'; sc.async = true; sc.src = m[1] + 'ov-composer.js' + (VIBE_REV['ov-composer.js'] ? '?v=' + VIBE_REV['ov-composer.js'] : ''); document.head.appendChild(sc); }
++ sc.addEventListener('load', go, { once: true });
++ }
const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
++ /** A round icon button with its name as label and tooltip. */
++ const iconButton = (tag, cls, path, label) => { const b = h(tag, cls); if (tag === 'button') b.type = 'button'; b.setAttribute('aria-label', label); b.setAttribute('data-ov-tip', label); b.title = label; b.innerHTML = svg(path); return b; };
++ /** Open Vibe in OpenVibe.Chat, as an icon (the thread's own address when one is open). */
++ const awayLink = () => { const a = iconButton('a', 'ovdk-ib ovdk-away', OUT, 'Open in Chat'); a.href = 'https://openvibe.chat/vibe'; a.target = '_blank'; a.rel = 'noopener'; return a; };
// ── Vibe (plan L48 V2; the owner, 11-01: "access your agent or the AI… from across all websites… use a voice
// feature to tell it to set a reminder… have a conversation with your Vibe AI") ──
@@@ -54,7 +85,7 @@
// browser without it (Firefox) records instead, up to 30 s, and Chat's /api/vibe/listen turns that into text
// (OpenVibe.AI's speech-to-text on this network's own machines; the recording is never kept).
// Everything is set as text; a link is a link only when it is https.
-- const vibe = { panel: null, chat: null, msgs: [], last: 0, timer: 0, busy: false, waitUntil: 0, rec: null, talk: null };
++ const vibe = { panel: null, chat: null, view: 'list', fresh: false, msgs: [], last: 0, timer: 0, busy: false, waitUntil: 0, rec: null, talk: null, generation: 0 };
const SR = root.SpeechRecognition || root.webkitSpeechRecognition || null;
const MR = !SR && root.MediaRecorder && root.navigator && root.navigator.mediaDevices && root.navigator.mediaDevices.getUserMedia ? root.MediaRecorder : null;
const TALK = Boolean(SR || MR);
@@@ -115,15 +146,52 @@
if (m.at) li.title = new Date(m.at).toLocaleString();
return li;
}
++ const age = (iso) => { const n = Date.parse(iso || ''); if (!Number.isFinite(n)) return ''; const m = Math.max(0, Math.floor((Date.now() - n) / 60000)); return m < 1 ? 'now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.floor(m / 60)} h ago` : `${Math.floor(m / 1440)} d ago`; };
++ async function openList() {
++ const generation = ++vibe.generation;
++ clearTimeout(vibe.timer); vibe.view = 'list'; vibe.chat = null;
++ const panel = vibe.panel; if (!panel) return;
++ panel.textContent = '';
++ // No heading (ask 335, the owner: "remove the text … new conversation … an icon plus button … open in Chat a
++ // little button icon"): one slim row of two icon buttons over your chats.
++ const head = h('div', 'ovdk-bar');
++ const newer = iconButton('button', 'ovdk-ib is-new ovdk-vibe-new', PLUS, 'New chat'); newer.addEventListener('click', () => { vibe.fresh = true; vibe.chat = null; ctx.save({ vibe: null }); openVibe(); });
++ head.append(newer, awayLink());
++ const scroll = h('div', 'ovdk-scroll'); const list = h('ul', 'ovdk-vibe-list');
++ scroll.append(list); panel.append(head, scroll);
++ try {
++ const out = await ctx.chatCall('/api/vibe/conversations');
++ if (generation !== vibe.generation || vibe.view !== 'list' || vibe.panel !== panel) return;
++ const conversations = Array.isArray(out.conversations) ? out.conversations : [];
++ if (!conversations.length) list.append(h('li', 'ovdk-none', 'Your chats with Vibe will appear here. Press + to start one.'));
++ conversations.forEach((c) => {
++ const li = h('li'), b = h('button'); b.type = 'button';
++ const title = h('b', '', c.name || 'Conversation'); const time = h('time', '', age(c.last_message_at));
++ const line = h('span', '', c.last_line || 'No messages yet');
++ // The site it began on, by name ("Blog", not openvibe.blog), beside the time.
++ const host = String(c.site || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '');
++ const name = host ? (/^openvibe\.([a-z]+)$/.exec(host) || [])[1] : '';
++ const site = h('small', '', name ? name.charAt(0).toUpperCase() + name.slice(1) : host);
++ const mark = h('span', 'ovdk-vibe-site'); mark.append(icon(G.vibe));
++ b.append(mark, title, time, line, site);
++ b.setAttribute('aria-label', `${c.name || 'Conversation'}, ${age(c.last_message_at)}`);
++ b.addEventListener('click', () => { vibe.fresh = false; vibe.chat = { slug: c.slug }; ctx.save({ vibe: { slug: c.slug, at: new Date().toISOString() } }); openVibe(); });
++ li.append(b); list.append(li);
++ });
++ } catch (e) { if (generation === vibe.generation) list.append(h('li', 'ovdk-none', e && e.status === 401 ? 'Sign in to see your conversations.' : 'Conversations did not load. Open Vibe again to retry.')); }
++ }
async function openVibe() {
++ const generation = ++vibe.generation;
clearTimeout(vibe.timer);
++ vibe.view = 'conversation';
const panel = vibe.panel; if (!panel) return;
vibe.msgs = []; vibe.last = 0;
panel.textContent = '';
const head = h('div', 'ovdk-thread-h ovdk-vibe-h');
const title = h('b', '', 'Vibe');
-- const away = h('a', 'ovdk-away', 'Open in Chat'); away.href = 'https://openvibe.chat/vibe'; away.target = '_blank'; away.rel = 'noopener';
-- head.append(icon(G.vibe), title, away);
++ const back = h('button', 'ovdk-vibe-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your chats'); back.textContent = '←'; back.addEventListener('click', () => { ctx.save({ vibe: null }); openList(); });
++ const away = awayLink();
++ head.append(back, icon(G.vibe), title, away);
const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');
const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);
const typing = h('li', 'ovdk-msg is-vibe ovdk-typing'); typing.innerHTML = '<span class="ovdk-text"><i></i><i></i><i></i></span>'; typing.setAttribute('aria-label', 'Vibe is writing');
@@@ -131,6 +199,37 @@
const box = h('textarea'); box.rows = 1; box.maxLength = 8000; box.placeholder = TALK ? 'Ask Vibe, or hold the mic' : 'Ask Vibe'; box.setAttribute('aria-label', 'Message Vibe');
const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));
const err = h('p', 'ovdk-err'); err.hidden = true;
++ const reason = (e) => e && e.status === 401 ? 'Your sign in expired. Sign in again to continue.'
++ : e && e.status === 429 ? 'Too many requests were sent. Wait a minute, then retry.'
++ : e && e.code === 'vibe.off' ? 'Vibe is unavailable right now. Retry when it returns.'
++ : e && e.status === 503 ? 'The AI service is unavailable right now. Retry shortly.'
++ : 'The request could not reach Vibe. Check your connection and retry.';
++ const showFailure = (e, retry) => { err.textContent = `Vibe did not answer: ${reason(e)} `; err.hidden = false;
++ if (retry) { const b = h('button', '', 'Retry'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; try { await retry(); } catch (x) { showFailure(x, retry); } finally { b.disabled = false; } }); err.append(b); }
++ };
++ const attach = h('button', 'ovdk-attach', '+'); attach.type = 'button'; attach.setAttribute('aria-label', 'Attach a picture'); attach.title = 'Attach a picture';
++ const file = h('input'); file.type = 'file'; file.accept = 'image/*'; file.hidden = true;
++ attach.addEventListener('click', () => file.click());
++ file.addEventListener('change', async () => {
++ const picture = file.files && file.files[0]; if (!picture) return;
++ attach.disabled = true; err.hidden = true;
++ try {
++ const path = vibe.chat ? `/api/chat/rooms/${encodeURIComponent(vibe.chat.slug)}/pictures` : '/api/chat/vibe/picture';
++ const out = await ctx.chatCall(path, { method: 'POST', body: picture, type: picture.type || 'image/jpeg' });
++ if (out.room && out.room.slug) { vibe.chat = { slug: out.room.slug }; vibe.fresh = false; ctx.save({ vibe: { slug: out.room.slug, at: new Date().toISOString() } }); }
++ openVibe();
++ } catch { err.textContent = 'That picture could not be attached. Try again.'; err.hidden = false; }
++ finally { attach.disabled = false; file.value = ''; }
++ });
++ // 350: Build it in Code. What you wrote, else Vibe's last answer, goes to the Code part as a new task's words.
++ const build = iconButton('button', 'ovdk-attach ovdk-build', 'M8 7l-5 5 5 5M16 7l5 5-5 5', 'Build it in Code');
++ build.addEventListener('click', () => {
++ const last = [...vibe.msgs].reverse().find((m) => m.from !== 'you');
++ const text = box.value.trim() || (last && last.text) || '';
++ if (!text) { box.focus(); return; }
++ build.dispatchEvent(new CustomEvent('ov-dock:part', { bubbles: true, detail: { part: 'code', text: text.slice(0, 16000) } }));
++ });
++ form.append(attach, file, build);
let mic = null;
if (TALK) {
mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));
@@@ -140,6 +239,8 @@
form.append(box, go);
panel.append(head, scroll, err, form);
const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
++ vibe.fill = (text) => { box.value = String(text || '').slice(0, 8000); fit(); try { box.focus(); box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } };
++ if (vibe.pendingAsk) { vibe.fill(vibe.pendingAsk); vibe.pendingAsk = null; }
box.addEventListener('input', fit);
box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });
@@@ -148,8 +249,9 @@
vibe.busy = true; go.disabled = true; err.hidden = true;
try {
let timezone = null; try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { /* */ }
-- const out = await ctx.chatCall('/api/vibe/ask', { method: 'POST', body: JSON.stringify({ message: text, slug: vibe.chat ? vibe.chat.slug : undefined, timezone }) });
-- if (out.chat) { vibe.chat = out.chat; away.href = out.chat.url; }
++ const out = await ctx.chatCall('/api/vibe/ask', { method: 'POST', body: JSON.stringify({ message: text, slug: vibe.chat ? vibe.chat.slug : undefined, fresh: vibe.fresh, timezone }) });
++ if (generation !== vibe.generation) return;
++ if (out.chat) { vibe.chat = out.chat; vibe.fresh = false; away.href = out.chat.url; ctx.save({ vibe: { slug: out.chat.slug, at: new Date().toISOString() } }); }
box.value = ''; fit();
const empty = list.querySelector('.ovdk-vibe-start'); if (empty) empty.remove();
if (out.message && !vibe.msgs.some((x) => x.id === out.message.id)) { list.append(vbubble(out.message, vibe.msgs[vibe.msgs.length - 1])); vibe.msgs.push(out.message); vibe.last = Math.max(vibe.last, out.message.id || 0); }
@@@ -158,22 +260,22 @@
scroll.scrollTop = scroll.scrollHeight;
clearTimeout(vibe.timer); vibe.timer = setTimeout(() => pull(), 1200);
} catch (x) {
-- err.textContent = x.status === 429 ? 'That is a lot of questions at once: wait a minute, then ask again.' : x.status === 401 ? 'Sign in again to talk to Vibe.' : 'That did not reach Vibe. Try again.';
-- err.hidden = false;
++ showFailure(x, () => send(text));
} finally { vibe.busy = false; go.disabled = false; box.focus(); }
}
form.addEventListener('submit', (e) => { e.preventDefault(); send(box.value.trim()); });
++ joinComposer(form);
async function pull(first) {
let got;
try {
-- got = await ctx.chatCall(`/api/vibe/chat${vibe.chat ? `?slug=${encodeURIComponent(vibe.chat.slug)}${vibe.last ? `&after=${vibe.last}` : ''}` : ''}`);
++ got = vibe.fresh && !vibe.chat ? { chat: null, messages: [] } : await ctx.chatCall(`/api/vibe/chat${vibe.chat ? `?slug=${encodeURIComponent(vibe.chat.slug)}${vibe.last ? `&after=${vibe.last}` : ''}` : ''}`);
} catch (e) {
-- if (first) { list.textContent = ''; list.append(h('li', 'ovdk-none', e.status === 401 ? 'Sign in to OpenVibe.Chat to talk to Vibe.' : 'Vibe did not answer just now. Try again in a moment.')); }
++ if (first && generation === vibe.generation) { list.textContent = ''; showFailure(e, () => pull(true)); }
return;
}
-- if (!ctx.active() || vibe.panel !== panel) return;
-- if (got.chat) { vibe.chat = got.chat; away.href = got.chat.url; }
++ if (generation !== vibe.generation || !ctx.active() || vibe.panel !== panel) return;
++ if (got.chat) { vibe.chat = got.chat; away.href = got.chat.url; title.textContent = got.chat.name || 'Vibe'; }
if (first) list.textContent = '';
const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;
let answered = false;
@@@ -184,7 +286,13 @@
if (m.from === 'vibe') answered = true;
if (!first && Array.isArray(m.results) && m.results.some((r) => r && r.kind === 'timer') && ctx.refresh) ctx.refresh();
});
-- if (answered || Date.now() > vibe.waitUntil) { typing.remove(); vibe.waitUntil = 0; }
++ if (answered || Date.now() > vibe.waitUntil) {
++ typing.remove();
++ if (!answered && vibe.waitUntil && Date.now() > vibe.waitUntil) {
++ showFailure({ status: 503 }, vibe.chat ? async () => { await ctx.chatCall(`/api/chat/vibe/${encodeURIComponent(vibe.chat.slug)}/retry`, { method: 'POST', body: '{}' }); err.hidden = true; vibe.waitUntil = Date.now() + 90000; list.append(typing); pull(); } : null);
++ }
++ vibe.waitUntil = 0;
++ }
if (first && !vibe.msgs.length) {
const start = h('li', 'ovdk-vibe-start');
start.append(h('b', '', 'Ask Vibe anything'), h('span', '', TALK ? 'Type, or hold the mic and say it. It sets reminders, finds what is live, and answers from the whole network.' : 'It sets reminders, finds what is live, and answers from the whole network.'));
@@@ -267,7 +375,9 @@
list.append(h('li', 'ovdk-wait', null));
list.firstChild.innerHTML = '<span></span>';
await pull(true);
-- setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);
++ // A touch screen does not get the keyboard on opening (11-09, the owner: it "immediately opens the keyboard… you
++ // can't see the chat input or what it did"): the answer and the box show first; a tap on the box types.
++ if (!(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)) setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);
}
function mount(panel, c) {
@@@ -277,11 +387,18 @@
if (st && !st.textContent.includes('.ovdk-vibe-start')) st.textContent += CSS;
else if (!st && !document.getElementById('ovdk-vibe-styles')) { const s = document.createElement('style'); s.id = 'ovdk-vibe-styles'; s.textContent = CSS; document.head.appendChild(s); }
vibe.panel = panel;
-- openVibe();
++ // The Chats tab's AI chats row asks for the list, not for the last conversation.
++ const onList = ctx.wantList ? ctx.wantList() : false;
++ const selected = ctx.state && ctx.state().vibe;
++ if (!onList && selected && selected.slug && Date.now() - Date.parse(selected.at) < 20 * 60000) { vibe.chat = { slug: selected.slug }; openVibe(); }
++ else openList();
return {
-- refresh: () => { vibe.panel = panel; return openVibe(); },
-- pause: () => { clearTimeout(vibe.timer); if (vibe.rec) { try { vibe.rec.abort(); } catch { /* */ } } },
++ refresh: () => { vibe.panel = panel; const list = ctx.wantList ? ctx.wantList() : false; if (list) return openList(); return vibe.view === 'list' ? openList() : openVibe(); },
++ list: () => { vibe.panel = panel; return openList(); },
++ pause: () => { vibe.generation++; clearTimeout(vibe.timer); if (vibe.rec) { try { vibe.rec.abort(); } catch { /* */ } } },
talk: () => { if (vibe.talk) vibe.talk(); },
++ // A page's "Ask your agent" (4.135.0): the question waits in the box, with its context, for the person to send.
++ ask: (text) => { if (vibe.view === 'list') { vibe.pendingAsk = text; vibe.fresh = true; openVibe(); } else if (vibe.fill) vibe.fill(text); else vibe.pendingAsk = text; },
};
}
root.OpenVibeDockVibe = { mount };
ddiff --git a/browser/shared/dock.js b/browser/shared/dock.js
iindex 78afa90..18585bd 100644
---- a/browser/shared/dock.js
++++ b/browser/shared/dock.js
@@@ -1,70 +1,62 @@
--// ═══════════════════════════════════════════════════════════════
--// OpenVibe — the Dock (Dock plan 2026-10-30, K-D1): your agents and your activity at the edge of every OpenVibe page
--//
--// navbar.js loads this file from beside itself for a signed-in person, after the bell, and mounts it:
--// OpenVibeDock.mount({ token: () => jwt, apiBase, sibling }) the pill; mount(null) takes it away (signed out)
--// OpenVibeDock.open({ tab }) · close() · isOpen()
--//
--// The pill (the mark, a word and a dot) shows only while an agent works, one needs you, or the person pinned it, in the
--// corner the Dock sits in. It paints at once from this site's copy (localStorage ov-dock), then from the Network, and
--// re-reads every 30 s while something runs and when the page comes back. A 404 (Network without the route yet) keeps
--// { off } for a day, and navbar.js does not load this file until then.
--//
--// State: GET/PUT <apiBase>/api/v1/me/dock (network.dock-result@1, network.dock-state@1): open, tab, pinned and where it
--// sits for each device class, so it follows the person from site to site. A change shows at once (onChange paints the
--// pill and the site's copy) and reaches the Network a beat later; before Network has the route the copy stands.
--// Tabs: Agents (action-center.js mount(): what needs you, then what runs, approve or decline in place; K-D3: above it,
--// the person's own desk stream — dock-agent.js loads the first time the tab opens — with Curtain now, End and the still
--// when nothing is live), Chats (K-D2: the person's conversations and DMs from OpenVibe.Chat's own API,
--// GET /api/dm/conversations, opened in place, read and answered there) and Activity (notification-ui.js renderDock():
--// the bell's list).
--// Desktop: a floating card at the pill's corner. Drag its head to move it (it snaps to the nearest corner), drag the
--// free corner to resize (320-720 × 360-900 px). It is not modal: the page stays usable. Phones: a sheet from the
--// bottom over a scrim; drag the head down, ×, Escape or a tap outside closes it. Motion is transform and opacity only.
--// Pop out (K-D4): on desktop Chromium the panel moves into a window of its own (Document Picture-in-Picture) — the same
--// DOM, the same tabs, the page's own styles copied in; the page keeps the pill, which says where the Dock went.
--// ═══════════════════════════════════════════════════════════════
++// The Dock: navbar.js calls mount({ token, apiBase, sibling }) for the pill. open({ tab }) opens the floating card;
++// mountInto(host, { tab }) places its panel body inside another sheet. Both use the same state, counts and loaders.
(function (root) {
'use strict';
if (typeof document === 'undefined' || root.OpenVibeDock) return;
-- const W = [320, 720], H = [360, 900], DEF = { width: 400, height: 560 };
++ const W = [320, 720], H = [360, 900], DEF = { width: 420, height: 640 };
const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
++ // Close is the OpenVibe menu's orb in its × form (11-09, the owner: "replace this close button with our own… the
++ // corner button… its close form… nice animations"): nine dots that fold into an × as the Dock opens.
++ // The close button is the menu button, already crossed (navbar.js, root.OpenVibeOrb): it forms as the sheet opens.
++ const ORBX = () => (root.OpenVibeOrb ? root.OpenVibeOrb.svg(22, true) : '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>');
const G = {
++ code: '<path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/>',
spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',
bell: '<path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2h-14z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>',
-- pin: '<path d="M14.5 3.5l6 6-3 1.5-3.5 3.5.5 4-1.5 1.5-4-4-4.5 4.5M9 15l-4-4 1.5-1.5 4 .5L14 6.5z"/>',
++ // An upright pushpin whose lines never cross (owner, 11-09: the old one had "a line going through it").
++ pin: '<path d="M7.5 3.5h9M10 3.5v5.3L6.5 13h11L14 8.8V3.5M12 13v7.5"/>',
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
open: '<path d="M13.5 5.5h5v5M18.5 5.5l-8 8M16.5 13.5v4a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h4"/>',
chat: '<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5z"/>',
back: '<path d="M14.5 6.5L9 12l5.5 5.5"/>',
send: '<path d="M4.5 12h11M11 6.5l5.5 5.5-5.5 5.5"/>',
vibe: '<path d="M12 4.5a7.5 7.5 0 0 1 6.4 11.4L19.5 19.5l-3.6-1.1A7.5 7.5 0 1 1 12 4.5z"/><path d="M9 11.5h.01M12 11.5h.01M15 11.5h.01"/>',
++ // Three filled dots on the exact centre line (a stroked h.01 path drew them a hair off centre: the owner, 11-09).
++ more: '<g fill="currentColor" stroke="none"><circle cx="5.5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.5" cy="12" r="1.7"/></g>',
clock: '<circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/>',
};
// Vibe first (L48 V2): the person's AI, on every site.
-- const TABS = [['vibe', 'Vibe', G.vibe], ['agent', 'Agents', G.spark], ['chats', 'Chats', G.chat], ['activity', 'Activity', G.bell]];
++ const ROBOT = '<rect x="5" y="8" width="14" height="10" rx="3"/><path d="M12 8V4.5M9.5 13h.01M14.5 13h.01M3 12.5v2M21 12.5v2"/>'; // as in the menu's row
++ const TABS = [['vibe', 'Vibe', G.spark], ['code', 'Code', G.code], ['agent', 'Agents', ROBOT], ['chats', 'Chat', G.chat], ['activity', 'Activity', G.bell]];
// The tabs the Network keeps in the person's Dock state (network.dock-state@1; vibe since contracts 0.270.0); a
// newer one stays on this site's copy until the Network learns it.
-- const SAVED_TABS = ['vibe', 'agent', 'chats', 'activity'];
++ const SAVED_TABS = ['vibe', 'code', 'agent', 'chats', 'activity'];
const DEFAULT_STATE = { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } } };
-- let el = null, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null;
++ let el = null, bodyEl = null, inline = null, panelEpoch = 0, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null, dockSurface = null;
// Pop out (K-D4): the window the panel lives in while it is popped out, and whether it is in one.
let pipWin = null, popped = false, pipClosing = false, popOk = true;
const mounts = {};
const phone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };
-- const reduced = () => { try { return root.matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.getAttribute('data-ov-motion') === 'reduced'; } catch { return false; } };
++ // Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
++ const reduced = () => document.documentElement.getAttribute('data-ov-motion') === 'off';
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const state = () => (doc && doc.state) || DEFAULT_STATE;
const counts = () => (doc && doc.counts) || {};
const kind = () => (phone() ? 'phone' : 'desktop');
const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });
const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);
-- // K-D3: the agent stream comes from OpenVibe.Codes' API (it mints the view ticket with the person's own token).
-- const agentBase = () => String(o.agentBase || 'https://openvibe.codes').replace(/\/+$/, '');
++ // K-D3: the agent stream is OpenVibe.Codes' (it mints the view ticket with the person's own token) and the one
++ // timeline behind the Dock is OpenVibe.Actor's (design 2026-11-07). On a site both go through Network's frame
++ // relay: every site's connect-src allows openvibe.network and refuses openvibe.codes and openvibe.actor. A test
++ // or the extension passes agentBase/actorBase and reaches the product itself; actorBase also names the Actor's
++ // pages in the run list.
++ const frameBase = () => String(o.frameBase || (String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '') + '/api/frame')).replace(/\/+$/, '');
++ const directBase = (v) => (v ? String(v).replace(/\/+$/, '') : '');
++ const actorBase = () => String(o.actorBase || 'https://openvibe.actor').replace(/\/+$/, '');
async function call(path, init = {}) {
const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');
@@@ -96,12 +88,14 @@
} catch { /* offline, or no route yet: the copy stands and the next change tries again */ }
}
++ // 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': 'ee110f2a0c1d', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
const id = `ov-dock-${src.replace(/\W/g, '')}`;
let sc = document.getElementById(id);
-- if (!sc) { sc = document.createElement('script'); sc.id = id; sc.src = sibling(src); sc.async = true; document.head.appendChild(sc); }
++ if (!sc) { sc = document.createElement('script'); sc.id = id; sc.src = sibling(src) + (DOCK_REV[src] ? `?v=${DOCK_REV[src]}` : ''); sc.async = true; document.head.appendChild(sc); }
sc.addEventListener('load', () => ok(test()));
sc.addEventListener('error', () => ok(false));
});
@@@ -112,7 +106,7 @@
const st = document.createElement('style');
st.id = 'ovdk-styles';
st.textContent = `
-- .ovdk-pill { --t: var(--accent, #3b82f6); --bg: var(--bg-elevated, var(--bg-secondary, #252530)); position: fixed; z-index: 9995; right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 0px));
++ .ovdk-pill { --t: var(--accent, #3b82f6); --bg: var(--bg-elevated, var(--bg-secondary, #252530)); position: fixed; z-index: var(--ovb-z-pill, 9995); right: max(16px, env(safe-area-inset-right, 0px)); bottom: max(calc(16px + var(--ovb-dock, calc(max(var(--ovnav-appbar-h, 0px), env(safe-area-inset-bottom, 0px)) + var(--ovb-mini, 0px)))), var(--ovb-composer, 0px)); translate: 0 calc(-1 * var(--ovb-lift, 0px));
display: inline-flex; align-items: center; gap: 9px; height: 46px; padding: 0 18px 0 9px; border-radius: 99px; border: 1px solid var(--border, #333340); background: var(--bg); color: var(--text-primary, #e0e0e0);
font: inherit; font-size: 13.5px; font-weight: 700; letter-spacing: -.005em; box-shadow: 0 14px 34px -14px rgba(0,0,0,.6); cursor: pointer; -webkit-tap-highlight-color: transparent; animation: ovdk-in .3s cubic-bezier(.2,.9,.25,1.2); }
.ovdk-pill[hidden] { display: none; }
@@@ -130,9 +124,7 @@
.ovdk-pill:has(.ovdk-t:empty) { padding-right: 8px; }
@keyframes ovdk-in { from { opacity: 0; transform: translateY(10px) scale(.9); } }
@keyframes ovdk-ping { to { transform: scale(2.6); opacity: 0; } }
-- @media (prefers-reduced-motion: reduce) { .ovdk-pill, .ovdk-m::before { animation: none !important; } }
-- html[data-ov-motion="reduced"] .ovdk-pill, html[data-ov-motion="reduced"] .ovdk-m::before { animation: none; }
-- .ovdk { --ovdk-gap: 16px; position: fixed; z-index: 9996; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;
++ .ovdk { --ovdk-gap: 16px; position: fixed; z-index: var(--ovb-z-dock, 9996); display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;
right: max(var(--ovdk-gap), env(safe-area-inset-right, 0px)); bottom: calc(max(var(--ovdk-gap), env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 0px));
width: ${DEF.width}px; height: ${DEF.height}px; max-width: calc(100vw - 32px); max-height: calc(100vh - 96px); max-height: calc(100dvh - 96px);
background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));
@@@ -148,6 +140,8 @@
@keyframes ovdk-grow { from { opacity: 0; transform: scale(.82); } }
@keyframes ovdk-rise { from { transform: translateY(100%); } }
.ovdk svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
++ /* The menu button's drawing (navbar.js) has its own fills: the line style above is not its. */
++ .ovdk svg.ovmb { fill: currentColor; stroke: none; stroke-width: 0; }
.ovdk-head { position: relative; flex: none; display: flex; align-items: center; gap: 8px; padding: 10px 8px 8px 10px; cursor: grab; touch-action: none; user-select: none; }
.ovdk-tabs { display: flex; gap: 2px; padding: 3px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #ececf1) 6%, transparent); min-width: 0; flex: 0 1 auto; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
.ovdk-tabs::-webkit-scrollbar { display: none; }
@@@ -156,7 +150,7 @@
color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 13.5px; font-weight: 700; cursor: pointer; white-space: nowrap; transition: background .15s, color .15s; }
.ovdk-tabs button svg { width: 17px; height: 17px; flex: none; }
.ovdk-tabs button:hover { color: var(--text-primary, #ececf1); }
-- .ovdk-tabs button[aria-selected="true"] { background: var(--bg-primary, #12141b); color: var(--text-primary, #ececf1); box-shadow: 0 1px 3px rgba(0,0,0,.25); }
++ .ovdk-tabs button[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #8ab4ff)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
.ovdk-tabs button[aria-selected="true"] svg { color: var(--accent-light, var(--accent, #60a5fa)); }
.ovdk-tabs button:focus-visible, .ovdk-acts button:focus-visible, .ovdk-foot :focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 1px; }
.ovdk-tabs .n { min-width: 18px; padding: 1px 6px; box-sizing: border-box; border-radius: 999px; font-size: 11px; font-weight: 800; line-height: 1.35; text-align: center; font-variant-numeric: tabular-nums;
@@@ -166,9 +160,44 @@
.ovdk-acts { display: flex; gap: 2px; margin-left: auto; }
.ovdk-acts button { width: 40px; height: 40px; border: 0; border-radius: 12px; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; }
.ovdk-acts button:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); }
-- .ovdk-acts button svg { width: 19px; height: 19px; }
-- .ovdk-acts [data-act="pin"][aria-pressed="true"] { color: var(--accent-light, var(--accent, #60a5fa)); }
-- .ovdk-acts [data-act="pin"][aria-pressed="true"] svg { fill: color-mix(in srgb, currentColor 22%, transparent); }
++ .ovdk-acts button svg { width: 19px; height: 19px; display: block; }
++ .ovdk-acts .ovdk-tomenu span { display: none; }
++ .ovdk-orbx svg { width: 22px !important; height: 22px !important; overflow: visible; }
++ .ovdk-orbx { --ovo-a: var(--accent-light, var(--accent, #60a5fa)); }
++ .ovdk.is-in .ovdk-orbx .ovmb :is(.ovmb-bar, .ovmb-app) { animation: ovmb-form .5s cubic-bezier(.2,1.3,.3,1) both; }
++ .ovdk-orbx:hover .ovmb, .ovdk-orbx:focus-visible .ovmb { transform: rotate(90deg); }
++ .ovdk-orbx .ovmb { transition: transform .35s cubic-bezier(.2,1.3,.3,1); }
++ .ovdk-orbx:active .ovmb { transform: rotate(90deg) scale(.8); }
++ /* A phone's sheet opens out of the navbar's orb as the menu's does (4.186.0; see CHANGELOG): one move, one close button. */
++ .ovdk.from-orb.is-in { animation: ovdk-reveal .38s cubic-bezier(.2,.9,.25,1) both; }
++ .ovdk.from-orb.is-out { animation: ovdk-fold .28s cubic-bezier(.5,0,.75,.2) both; pointer-events: none; }
++ @keyframes ovdk-reveal { from { clip-path: circle(28px at var(--ovdk-ox) var(--ovdk-oy)); } to { clip-path: circle(150% at var(--ovdk-ox) var(--ovdk-oy)); } }
++ @keyframes ovdk-fold { from { clip-path: circle(150% at var(--ovdk-ox) var(--ovdk-oy)); } to { clip-path: circle(20px at var(--ovdk-ox) var(--ovdk-oy)); opacity: 0; } }
++ .ovdk.from-orb .ovdk-head { padding: 4px 14px 6px 8px; }
++ .from-orb .ovdk-acts .ovdk-orbx { position: relative; width: 40px; height: 40px; margin: 2px; border-radius: 50%; background: color-mix(in srgb, var(--accent, #3b82f6) 16%, var(--bg-primary, #0b0f19));
++ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-light, #60a5fa) 35%, transparent), 0 6px 18px -6px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); transition: background-color .3s, box-shadow .3s; }
++ .from-orb .ovdk-acts .ovdk-orbx.is-from { background: transparent; box-shadow: none; }
++ .from-orb.is-in .ovdk-orbx::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--accent-light, #60a5fa); pointer-events: none; animation: ovdk-ring .6s cubic-bezier(.2,.8,.2,1) .1s both; }
++ @keyframes ovdk-ring { from { opacity: .8; transform: scale(.9); } to { opacity: 0; transform: scale(calc(1 + 2.2 * var(--ov-move, 1))); } }
++ .from-orb .ovdk-orbx { color: var(--text-primary, #ececf1); }
++ .from-orb .ovdk-orbx svg { transition: transform .42s cubic-bezier(.2, 1.1, .3, 1); }
++ .ovdk-orbx.is-set, .ovdk-orbx.is-set * { transition: none !important; }
++ .from-orb .is-from svg { transform: translate(var(--orb-dx, 0), var(--orb-dy, 0)) scale(var(--orb-s, 1)); }
++ .ovnav-orb.is-dock { transform: translate(var(--dock-dx, 0), var(--dock-dy, 0)) scale(var(--dock-s, 1)) !important; color: var(--text-primary, #e6edf7) !important; }
++ .ovdk-more { position: absolute; z-index: 6; right: 8px; top: calc(100% - 4px); width: min(320px, calc(100% - 16px)); display: grid; gap: 2px; padding: 6px; border-radius: 16px;
++ border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); box-shadow: 0 18px 40px -12px rgba(0,0,0,.55); cursor: default; }
++ .ovdk-more[hidden] { display: none; }
++ .ovdk-more button { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 8px 10px; border: 0; border-radius: 12px; background: transparent; color: var(--text-primary, #ececf1); font: inherit; text-align: left; cursor: pointer; }
++ .ovdk-more button[hidden] { display: none; }
++ .ovdk-more button:hover, .ovdk-more button:focus-visible { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); outline: none; }
++ .ovdk-more button > svg { flex: none; width: 20px; height: 20px; color: var(--text-muted, #8b8b96); }
++ .ovdk-more span { flex: 1; min-width: 0; display: grid; gap: 2px; }
++ .ovdk-more b { font-size: 14px; font-weight: 650; }
++ .ovdk-more small { font-size: 12px; color: var(--text-muted, #8b8b96); line-height: 1.35; }
++ .ovdk-sw { flex: none; position: relative; width: 38px; height: 22px; border-radius: 11px; background: var(--bg-tertiary, rgba(255,255,255,.14)); transition: background .15s; }
++ .ovdk-sw::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .15s; }
++ .ovdk-more [aria-checked="true"] .ovdk-sw { background: var(--accent-strong, var(--accent, #2563eb)); }
++ .ovdk-more [aria-checked="true"] .ovdk-sw::after { transform: translateX(16px); }
.ovdk-body { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; border-top: 1px solid var(--border, rgba(255,255,255,.08)); }
.ovdk-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.ovdk-panel[hidden] { display: none; }
@@@ -186,7 +215,7 @@
.ovdk-none { padding: 40px 24px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }
.ovdk-size { position: absolute; z-index: 2; width: 22px; height: 22px; top: 0; left: 0; cursor: nwse-resize; touch-action: none; }
.ovdk-size::after { content: ''; position: absolute; top: 9px; left: 9px; width: 7px; height: 7px; border-top: 2px solid var(--text-muted, #8b8b96); border-left: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 3px; opacity: 0; transition: opacity .15s; }
-- .ovdk:hover .ovdk-size::after, .ovdk-size:active::after { opacity: .75; }
++ .ovdk .ovdk-size::after { opacity: .75; }
.ovdk[data-corner="bottom-left"] .ovdk-size { left: auto; right: 0; cursor: nesw-resize; }
.ovdk[data-corner="bottom-left"] .ovdk-size::after { left: auto; right: 9px; border-left: 0; border-right: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 0; border-top-right-radius: 3px; }
.ovdk[data-corner="top-right"] .ovdk-size { top: auto; bottom: 0; cursor: nesw-resize; }
@@@ -195,10 +224,21 @@
.ovdk[data-corner="top-left"] .ovdk-size::after { top: auto; bottom: 9px; left: auto; right: 9px; border: 0; border-bottom: 2px solid var(--text-muted, #8b8b96); border-right: 2px solid var(--text-muted, #8b8b96); border-radius: 0 0 3px 0; }
@media (pointer: coarse) { .ovdk-acts button { width: 44px; height: 44px; } .ovdk-tabs button { height: 40px; } }
.ovdk { container-type: inline-size; }
-- @container (max-width: 440px) { .ovdk-tabs button { padding: 0 8px; gap: 6px; font-size: 13px; } .ovdk-tabs button svg { display: none; } .ovdk-acts { gap: 0; } .ovdk-acts button { width: 34px; } }
++ @container (max-width: 440px) { .ovdk-tabs button { padding: 0 8px; gap: 6px; font-size: 13px; } .ovdk-acts { gap: 0; } .ovdk-acts button { width: 34px; } }
++ /* A narrow Dock (Opus, 11-07: "Activity" was cut off at the strip's edge): the open tab keeps its icon and name,
++ the others show their icon and count only, their names kept for screen readers. All four always fit. */
++ @container (max-width: 480px) {
++ .ovdk-tabs button:not([aria-selected="true"]) { padding: 0 10px; gap: 4px; }
++ .ovdk-tabs button:not([aria-selected="true"]) span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++ }
.ovdk-muted { color: var(--text-muted, #8b8b96); }
.ovdk-i { display: inline-grid; place-items: center; }
.ovdk-i svg { width: 18px; height: 18px; }
++ .ovdk-bar { flex: none; display: flex; justify-content: flex-end; gap: 4px; padding: 4px 8px 0; }
++ .ovdk-ib { flex: none; width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; text-decoration: none; }
++ .ovdk-ib svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; }
++ .ovdk-ib:hover, .ovdk-ib:focus-visible { background: var(--hover-bg, rgba(127,127,127,.12)); color: var(--text-primary, #ececf1); }
++ .ovdk-thread-h .ovdk-ib { margin-left: auto; }
.ovdk-chats { list-style: none; margin: 0; padding: 6px; display: grid; gap: 2px; }
.ovdk-chat { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; align-items: center; gap: 12px; width: 100%; min-height: 60px; padding: 8px 10px; border: 0; border-radius: 14px;
background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
@@@ -207,6 +247,11 @@
.ovdk-av { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; overflow: hidden; font-weight: 800; font-size: 15px;
color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
.ovdk-av img { width: 100%; height: 100%; object-fit: cover; }
++ .ovdk-av-vibe { color: #fff; background: radial-gradient(120% 120% at 30% 20%, color-mix(in srgb, var(--accent, #3b82f6) 70%, #a78bfa), var(--accent-strong, var(--accent, #2563eb))); }
++ .ovdk-av-vibe svg { width: 22px; height: 22px; }
++ .ovdk-av-group { display: flex; align-items: center; overflow: visible; background: transparent; }
++ .ovdk-av-group .ovdk-av { width: 27px; height: 27px; margin-left: -12px; border: 2px solid var(--bg-elevated, var(--bg-secondary, #1d1f27)); font-size: 11px; }
++ .ovdk-av-group .ovdk-av:first-child { margin-left: 0; }
.ovdk-chat-b { display: grid; gap: 1px; min-width: 0; }
.ovdk-chat-b b { font-size: 14px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovdk-chat-p { font-size: 12.5px; color: var(--text-secondary, #b6b6c0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@@ -230,24 +275,53 @@
border: 1px solid var(--border-light, var(--border, rgba(255,255,255,.14))); background: var(--bg-primary, #12141b); }
.ovdk-send:focus-within { border-color: var(--accent, #3b82f6); }
.ovdk-send textarea { flex: 1; min-width: 0; box-sizing: border-box; min-height: 36px; max-height: 140px; padding: 8px 0; resize: none; border: 0; outline: none; background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 14px; line-height: 1.4; }
-- .ovdk-go { width: 40px; height: 40px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center;
++ .ovdk-go { width: 44px; height: 44px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center;
color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }
.ovdk-go:disabled { opacity: .55; cursor: default; }
.ovdk-err { margin: 0 14px; font-size: 12.5px; color: color-mix(in srgb, var(--live-red, #e5484d) 60%, var(--text-primary, #ececf1)); }
.ovdk-err[hidden] { display: none; }
.ovdk-none b { display: block; margin-bottom: 4px; font-size: 15px; color: var(--text-primary, #ececf1); }
@media (max-width: 640px) {
-- .ovdk, .ovdk[data-corner] { left: 0; right: 0; top: auto; bottom: 0; width: auto !important; height: min(86vh, 720px) !important; height: min(86dvh, 720px) !important; max-width: none; max-height: none;
-- border-radius: 24px 24px 0 0; border-bottom: 0; padding-bottom: env(safe-area-inset-bottom, 0px); box-shadow: 0 0 0 100vmax rgba(0,0,0,.5), 0 -12px 40px rgba(0,0,0,.35); transform-origin: 50% 100%; }
++ .ovdk, .ovdk[data-corner] { z-index: 10010; left: 0; right: 0; top: 0; bottom: var(--ov-kb, 0px); width: auto !important; height: auto !important; max-width: none; max-height: none;
++ border: 0; border-radius: 0; padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px); box-shadow: none; transform-origin: 50% 100%; }
.ovdk.is-in { animation-name: ovdk-rise; animation-duration: .3s; }
-- .ovdk-head { padding-top: 22px; cursor: default; }
-- .ovdk-head::before { content: ''; position: absolute; top: 8px; left: 50%; width: 40px; height: 4px; margin-left: -20px; border-radius: 2px; background: var(--border-light, var(--border, rgba(255,255,255,.22))); }
++ .ovdk-head { cursor: default; }
.ovdk-size { display: none; }
.ovdk-tabs { flex: 1; } .ovdk-tabs button { flex: 1; justify-content: center; }
++ /* A thumb, not a cursor: every control on the sheet is 44 px (design 2026-11-07 §1, Phone). */
++ .ovdk-tabs button { height: 44px; }
++ .ovdk-acts button { width: 44px; height: 44px; }
++ /* Two rows (4.150.0, the owner: the strip was "cut off", "Activity" read "Ac"): the tools sit beside the grab
++ handle; the four tabs share the full width below, each icon over its name, every one whole. */
++ .ovdk-head { flex-wrap: nowrap; gap: 4px; padding: 6px 6px 6px 8px; }
++ .ovdk-acts { flex: none; gap: 0; }
++ .ovdk-acts button[data-act="pop"] { display: none !important; }
++ .ovdk-tabs { order: 0; flex: 1 1 auto; min-width: 0; padding: 3px; border-radius: 16px; }
++ .ovdk-tabs button, .ovdk-tabs button:not([aria-selected="true"]) { flex: 1 1 0; min-width: 0; height: 54px; padding: 4px 2px; flex-direction: column; justify-content: center; gap: 3px; border-radius: 12px; font-size: 11.5px; font-weight: 650; }
++ .ovdk-tabs button span, .ovdk-tabs button:not([aria-selected="true"]) span { position: static; width: auto; height: auto; overflow: hidden; clip: auto; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; }
++ .ovdk-tabs button svg { width: 19px; height: 19px; }
++ .ovdk-tabs .n { position: absolute; top: 4px; left: calc(50% + 6px); min-width: 17px; padding: 1px 5px; font-size: 10.5px; }
++ .ovdk-foot a, .ovdk-foot button { min-height: 44px; }
++ /* Two rows, as the menu's head (4.189.0): the way back to the menu, More and Close; under them the four tabs,
++ full width, in the look of the menu's row that leads here. */
++ .ovdk-head, .ovdk.from-orb .ovdk-head { flex-wrap: wrap; gap: 6px 4px; padding: 6px 12px 8px; }
++ .ovdk-acts { order: 0; flex: 1 0 100%; align-items: center; gap: 2px; }
++ .ovdk-acts .ovdk-tomenu { width: auto; margin-right: auto; padding: 0 14px 0 10px; gap: 8px; display: inline-flex; align-items: center; border-radius: 999px; font: inherit; font-size: 13.5px; font-weight: 700;
++ color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
++ .ovdk-acts .ovdk-tomenu span { display: inline; }
++ .ovdk-tabs { order: 1; flex: 1 0 100%; gap: 4px; padding: 0; border-radius: 0; background: transparent; overflow: visible; }
++ .ovdk-tabs button, .ovdk-tabs button:not([aria-selected="true"]) { height: 50px; gap: 2px; background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); }
++ .ovdk-tabs button[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); }
++ .ovdk-tabs button:not([aria-selected="true"]) svg { color: var(--accent-light, var(--accent, #60a5fa)); }
++ }
++ @media (min-width: 641px) and (max-width: 1023px) {
++ .ovdk, .ovdk[data-corner] { z-index: 10010; top: 0; bottom: 0; right: 0; left: auto; width: min(460px, calc(100vw - 48px)) !important; height: auto !important;
++ max-width: none; max-height: none; border-radius: 22px 0 0 22px; border-right: 0; padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px);
++ box-shadow: 0 0 0 100vmax rgba(0,0,0,.4), -16px 0 40px rgba(0,0,0,.3); transform-origin: 100% 50%; }
++ .ovdk-size { display: none; }
++ .ovdk-head { cursor: default; }
}
html.ovdk-sheet-open, html.ovdk-sheet-open body { overflow: hidden; }
-- @media (prefers-reduced-motion: reduce) { .ovdk.is-in, .ovdk-wait span { animation: none; } }
-- html[data-ov-motion="reduced"] .ovdk.is-in { animation: none; }
// In a window of its own the panel is the window: full bleed, no card, no phone sheet.
.ovdk-pip .ovdk, .ovdk-pip .ovdk[data-corner] { left: 0 !important; top: 0 !important; right: 0 !important; bottom: 0 !important;
width: 100% !important; height: 100% !important; max-width: none !important; max-height: none !important;
@@@ -270,15 +344,20 @@
<header class="ovdk-head">
<div class="ovdk-tabs" role="tablist" aria-label="Dock">${TABS.map(([id, label, g]) => `<button type="button" role="tab" id="ovdk-tab-${id}" aria-controls="ovdk-panel-${id}" data-tab="${id}" aria-selected="false" tabindex="-1">${svg(g)}<span>${label}</span><b class="n" data-n="${id}"></b></button>`).join('')}</div>
<div class="ovdk-acts">
-- <button type="button" data-act="timer" title="A timer" aria-label="Start a timer">${svg(G.clock)}</button>
-- <button type="button" data-act="pop" hidden title="Open the Dock in a window of its own" aria-label="Open the Dock in a window of its own">${svg(G.open)}</button>
-- <button type="button" data-act="pin" aria-pressed="false" title="Keep the Dock's button on every page" aria-label="Keep the Dock's button on every page">${svg(G.pin)}</button>
-- <button type="button" data-act="close" title="Close" aria-label="Close the Dock">${svg(G.close)}</button>
++ <button type="button" data-act="menu" class="ovdk-tomenu" title="The OpenVibe menu" aria-label="The OpenVibe menu: every site, the feed, you">${root.OpenVibeOrb ? root.OpenVibeOrb.svg(20) : ''}<span>Menu</span></button>
++ <button type="button" data-act="more" aria-haspopup="true" aria-expanded="false" title="More" aria-label="More: a timer, the Dock's button">${svg(G.more)}</button>
++ <button type="button" data-act="close" class="ovdk-orbx" title="Close" aria-label="Close the Dock">${ORBX()}</button>
++ </div>
++ <div class="ovdk-more" role="menu" aria-label="More" hidden>
++ <button type="button" role="menuitem" data-act="timer">${svg(G.clock)}<span><b>Start a timer</b><small>A countdown or a focus round, on the Dock's button</small></span></button>
++ <button type="button" role="menuitemcheckbox" aria-checked="false" data-act="pin">${svg(G.pin)}<span><b>Keep the Dock's button on every page</b><small>When off, it shows only while something works or needs you</small></span><i class="ovdk-sw" aria-hidden="true"></i></button>
++ <button type="button" role="menuitem" data-act="pop" hidden>${svg(G.open)}<span><b>Open the Dock in a window of its own</b><small>Keep it beside whatever you are doing</small></span></button>
</div>
</header>
<div class="ovdk-body">${TABS.map(([id]) => `<div class="ovdk-panel" role="tabpanel" id="ovdk-panel-${id}" aria-labelledby="ovdk-tab-${id}" data-panel="${id}" hidden></div>`).join('')}</div>
<span class="ovdk-size" aria-hidden="true"></span>`;
document.body.appendChild(el);
++ bodyEl = el.querySelector('.ovdk-body');
const tabs = [...el.querySelectorAll('[role="tab"]')];
tabs.forEach((b) => b.addEventListener('click', () => setTab(b.dataset.tab, true)));
@@@ -288,14 +367,39 @@
if (j >= 0) { e.preventDefault(); setTab(tabs[j].dataset.tab, true); tabs[j].focus(); }
});
el.querySelector('[data-act="close"]').addEventListener('click', () => close());
++ // The way back to the menu: this sheet folds, the menu opens out of the same button.
++ { const m = el.querySelector('[data-act="menu"]'); if (m) { if (!root.OpenVibeNavbar || !root.OpenVibeOrb) m.hidden = true; m.addEventListener('click', () => { close(); setTimeout(() => { try { root.OpenVibeNavbar.openSwitcher(); } catch { /* no bar */ } }, 300); }); } }
el.querySelector('[data-act="pop"]').addEventListener('click', () => { if (popped) backToPage(); else popOut(); });
el.querySelector('[data-act="pin"]').addEventListener('click', () => { save({ pinned: !state().pinned }); paintPin(); });
-- el.querySelector('[data-act="timer"]').addEventListener('click', (e) => { const a = e.currentTarget; withTimer((t) => t.menu(a)); });
++ el.querySelector('[data-act="timer"]').addEventListener('click', () => { const a = el.querySelector('[data-act="more"]'); closeMore(); withTimer((t) => t.menu(a)); });
++ const moreBtn = el.querySelector('[data-act="more"]'), moreMenu = el.querySelector('.ovdk-more');
++ function closeMore() { moreMenu.hidden = true; moreBtn.setAttribute('aria-expanded', 'false'); }
++ moreBtn.addEventListener('click', () => {
++ const open = moreMenu.hidden;
++ closeMore();
++ const tm = el.querySelector('.ovdk-tmenu'); if (tm) tm.remove();
++ if (open) { moreMenu.hidden = false; moreBtn.setAttribute('aria-expanded', 'true'); const first = moreMenu.querySelector('button:not([hidden])'); if (first) first.focus(); }
++ });
++ moreMenu.addEventListener('click', (e) => { const b = e.target.closest('button'); if (b && b.dataset.act !== 'pin') closeMore(); });
++ moreMenu.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); closeMore(); moreBtn.focus(); } });
++ // A pop-over (More, the timer) closes on a tap anywhere else, in the Dock or on the page (owner, 11-09: "clicked off
++ // something else, and it didn't actually close").
++ const awayFrom = (e) => {
++ const path = typeof e.composedPath === 'function' ? e.composedPath() : [e.target];
++ if (!moreMenu.hidden && !path.includes(moreMenu) && !path.includes(moreBtn)) closeMore();
++ const tm = el.querySelector('.ovdk-tmenu');
++ if (tm && !path.includes(tm) && !path.includes(moreMenu) && !path.includes(moreBtn)) tm.remove();
++ };
++ el.addEventListener('pointerdown', awayFrom, true);
++ document.addEventListener('pointerdown', (e) => { if (!el.contains(e.target)) awayFrom(e); }, true);
el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } });
// A tap outside closes the phone sheet. The path is the one at dispatch: a tap that redraws its own panel (opening a
// chat) leaves its target detached, and must not read as outside.
document.addEventListener('click', (e) => {
if (!isOpen() || !phone()) return;
++ // The tap that opened the sheet (a page's button, the share sheet's OpenVibe Chat, Ask your agent) is still on
++ // its way up when the sheet opens: an event made before the sheet opened is never "a tap outside" (4.145.1).
++ if (e.timeStamp && e.timeStamp <= openedAt) return;
const path = typeof e.composedPath === 'function' ? e.composedPath() : [e.target];
if (path.includes(el) || (pill && path.includes(pill)) || (e.target && e.target.isConnected === false)) return;
close();
@@@ -366,7 +470,7 @@
setTimeout(() => { el.style.transition = ''; }, 300);
};
head.addEventListener('pointerdown', (e) => {
-- if (e.button || e.target.closest('.ovdk-acts')) return;
++ if (e.button || e.target.closest('.ovdk-acts, .ovdk-more')) return;
s = { x: e.clientX, y: e.clientY, r: el.getBoundingClientRect(), dx: 0, dy: 0, id: e.pointerId, live: false };
root.addEventListener('pointermove', move); root.addEventListener('pointerup', end); root.addEventListener('pointercancel', end);
});
@@@ -410,62 +514,92 @@
n('agent', agent, c.waiting ? 'waiting' : '');
n('activity', c.unread || 0);
n('chats', chats.unread || 0);
-- el.querySelector('#ovdk-tab-chats').setAttribute('aria-label', chats.unread ? `Chats, ${chats.unread} unread` : 'Chats');
++ el.querySelector('#ovdk-tab-chats').setAttribute('aria-label', chats.unread ? `Chat, ${chats.unread} unread` : 'Chat');
el.querySelector('#ovdk-tab-agent').setAttribute('aria-label', [`Agents`, c.waiting ? `${c.waiting} need${c.waiting === 1 ? 's' : ''} you` : '', c.running ? `${c.running} working` : ''].filter(Boolean).join(', '));
el.querySelector('#ovdk-tab-activity').setAttribute('aria-label', c.unread ? `Activity, ${c.unread} unread` : 'Activity');
}
function paintPin() {
const b = el.querySelector('[data-act="pin"]'), on = !!state().pinned;
-- b.setAttribute('aria-pressed', String(on));
-- const t = on ? "The Dock's button stays on every page" : "Keep the Dock's button on every page";
-- b.title = t; b.setAttribute('aria-label', t);
++ b.setAttribute('aria-checked', String(on));
++ b.title = on ? "On: the Dock's button stays on every page" : "Off: the Dock's button shows only while something works or needs you";
}
function setTab(id, remember) {
++ if (id === 'agents') id = 'agent';
++ if (inline && tab !== id) disposePanel();
tab = TABS.some(([t]) => t === id) ? id : 'activity';
if (tab !== 'chats') clearTimeout(chats.timer);
if (tab !== 'vibe' && vibe.api) vibe.api.pause();
el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === tab; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
-- el.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });
++ bodyEl.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });
if (remember && state().tab !== tab && SAVED_TABS.includes(tab)) save({ tab });
fill(tab);
++ if (inline && tab === 'chats') readUnread();
++ }
++ const panelActive = (id) => tab === id && (isOpen() || !!inline);
++ function disposePanel() {
++ panelEpoch++;
++ clearTimeout(chats.timer); clearTimeout(chats.unreadT);
++ if (vibe.api) { vibe.api.pause(); vibe.api = null; }
++ for (const k of Object.keys(mounts)) { if (mounts[k] && mounts[k].destroy) mounts[k].destroy(); delete mounts[k]; }
++ if (root.OpenVibeNotifications && root.OpenVibeNotifications.unmountDock && bodyEl) root.OpenVibeNotifications.unmountDock(bodyEl.querySelector('[data-panel="activity"]'));
++ chats.panel = null; chats.open = null; vibe.panel = null;
++ if (bodyEl) bodyEl.querySelectorAll('[data-panel]').forEach((p) => { p.replaceChildren(); p.hidden = true; });
}
async function fill(id) {
-- const panel = el.querySelector(`[data-panel="${id}"]`);
++ const epoch = panelEpoch;
++ const panel = bodyEl.querySelector(`[data-panel="${id}"]`);
if (id === 'vibe') {
vibe.panel = panel;
if (vibe.api) return vibe.api.refresh();
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
const ok = await load('dock-vibe.js', () => !!(root.OpenVibeDockVibe && root.OpenVibeDockVibe.mount));
++ if (epoch !== panelEpoch || !panelActive(id)) return;
if (!ok) { panel.innerHTML = '<div class="ovdk-none">Vibe did not load. Check your connection, then open it again.</div>'; return; }
-- vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => isOpen() && tab === 'vibe', styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });
++ vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => panelActive('vibe'), state, save, wantList: () => { const v = vibeOnList; vibeOnList = false; return v; }, styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });
return;
}
-- if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }
-- if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); return; }
++ if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; if (chats.draft) return chatList(); return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }
++ // The Agent panel reads the step.
++ if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); if (mounts[id + 'Timeline']) mounts[id + 'Timeline'].refresh(); return; }
mounts[id] = { refresh: null };
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
++ if (id === 'code') {
++ const ok = await load('dock-code.js', () => !!(root.OpenVibeDockCode && root.OpenVibeDockCode.mount));
++ if (epoch !== panelEpoch || !panelActive(id)) { delete mounts[id]; return; }
++ if (!ok) return failed(panel, id);
++ mounts[id] = root.OpenVibeDockCode.mount(panel, { token: o.token, frameBase: frameBase(), active: () => panelActive('code') });
++ return;
++ }
if (id === 'agent') {
-- const ok = await load('action-center.js', () => !!(root.OpenVibeActionCenter && root.OpenVibeActionCenter.mount));
++ const ok = await load('dock-runs.js', () => !!(root.OpenVibeDockRuns && root.OpenVibeDockRuns.mount));
++ if (epoch !== panelEpoch || !panelActive(id)) { delete mounts[id]; return; }
if (!ok) return failed(panel, id);
// K-D3: the person's own agent stream above the list; it hides itself when there is none.
-- panel.innerHTML = `<div class="ovdk-agent"></div><div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;
++ panel.innerHTML = '<div class="ovdk-actor"></div><div class="ovdk-timeline"></div><div class="ovdk-agent"></div><div class="ovdk-scroll"></div><div class="ovdk-runs-foot ovdk-foot"></div>';
++ // The Agent panel: Now, Needs you and the one timeline (design 2026-11-07). It is first in the tab, so on a
++ // phone Now and Needs you are on screen before anything else.
++ 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 });
++ });
++ // Actor v3 (A3): your Actor first, on every site; it shows nothing for a person without one.
++ 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') });
++ });
load('dock-agent.js', () => !!(root.OpenVibeDockAgent && root.OpenVibeDockAgent.mount)).then((on) => {
-- if (on && !mounts[id + 'View'] && panel.isConnected) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.firstChild, { token: o.token, base: agentBase(), active: () => isOpen() && tab === 'agent' });
++ if (on && epoch === panelEpoch && !mounts[id + 'View'] && panel.isConnected && panelActive(id)) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.querySelector('.ovdk-agent'), { token: o.token, base: directBase(o.agentBase), frameBase: frameBase(), active: () => panelActive('agent') });
});
-- const m = root.OpenVibeActionCenter.mount(panel.querySelector('.ovdk-scroll'), {
-- token: o.token, apiBase: o.apiBase, scopes: o.scopes, buckets: ['waiting', 'running'],
++ const m = root.OpenVibeDockRuns.mount(panel.querySelector('.ovdk-scroll'), {
++ call, state, save, scopes: o.scopes, active: () => panelActive('agent'), actorBase: actorBase(), loadMarks: () => load('ai-marks-client.js', () => !!root.OpenVibeAIMarks),
++ styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'),
++ foot: panel.querySelector('.ovdk-runs-foot'),
onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },
});
mounts[id] = m;
-- panel.querySelector('[data-act="all"]').addEventListener('click', (e) => {
-- const anchor = e.currentTarget;
-- close();
-- try { root.OpenVibeActionCenter.open({ token: o.token, apiBase: o.apiBase, scopes: o.scopes, anchor: (pill && !pill.hidden) ? pill : anchor }); } catch { /* */ }
-- });
return;
}
const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));
++ if (epoch !== panelEpoch || !panelActive(id)) { delete mounts[id]; return; }
if (!ok) return failed(panel, id);
const N = root.OpenVibeNotifications;
if (!N.__ovNavInit) { try { N.init({ token: o.token, apiBase: o.apiBase || 'https://openvibe.network', scopes: o.scopes }); N.__ovNavInit = true; } catch { /* */ } }
@@@ -482,33 +616,36 @@
// The person's conversations and DMs, from OpenVibe.Chat's own API with their Network token (Chat checks they are
// in each one). Everything people wrote is set as text, never markup; only https pictures are shown. A thread polls
// for new messages every 5 s while it is open in the Dock, and is marked read as it is shown.
-- const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, unread: 0, unreadT: 0, busy: false };
++ const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, edits: null, unread: 0, unreadT: 0, busy: false, draft: null, vibeSummary: null, filter: '' };
const chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');
const me = () => String(o.me || '');
const scopes = () => Array.isArray(o.scopes) ? o.scopes : null;
const canWriteChat = () => !scopes() || scopes().includes('chat.write');
const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
++ const toChat = (href) => { const a = h('a', 'ovdk-ib ovdk-away'); a.href = href; a.target = '_blank'; a.rel = 'noopener'; a.title = 'Open in Chat'; a.setAttribute('aria-label', 'Open in Chat'); a.setAttribute('data-ov-tip', 'Open in Chat'); a.innerHTML = svg('<path d="M13.5 5.5h5v5M18.5 5.5l-7.5 7.5M17 13.5v4a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 5 17.5v-9A1.5 1.5 0 0 1 6.5 7h4"/>'); return a; };
async function chatCall(path, init = {}) {
const t = await tokenOf();
const headers = { accept: 'application/json', ...(init.body ? { 'content-type': init.type || 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
const { type: _type, ...rest } = init;
return fetch(chatBase() + path, { ...rest, headers, credentials: 'omit' }).then(async (r) => {
const b = await r.json().catch(() => ({}));
-- if (!r.ok) { const e = new Error(b.error || `HTTP ${r.status}`); e.status = r.status; throw e; }
++ if (!r.ok) { const e = new Error(b.error || `HTTP ${r.status}`); e.status = r.status; e.code = b.code || null; throw e; }
return b;
});
}
function readUnread() {
clearTimeout(chats.unreadT);
chatCall('/api/dm/unread').then((b) => { chats.unread = Number(b && b.unread) || 0; badges(); }).catch(() => { /* Chat away: no count */ });
-- if (isOpen()) chats.unreadT = setTimeout(readUnread, 30000);
++ if (panelActive('chats')) chats.unreadT = setTimeout(readUnread, 30000);
}
const when = (iso) => {
const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return '';
const m = Math.max(0, Math.round((Date.now() - t) / 60000));
return m < 1 ? 'now' : m < 60 ? `${m} min` : m < 1440 ? `${Math.floor(m / 60)} h` : `${Math.floor(m / 1440)} d`;
};
++ // "answered 4 min ago", and "answered just now" when it was this minute.
++ const answered = (iso) => { const w = when(iso); return !w ? '' : w === 'now' ? 'answered just now' : `answered ${w} ago`; };
function avatar(p, group) {
const a = h('span', 'ovdk-av');
const url = p && /^https:\/\//.test(String(p.avatar_url || '')) ? p.avatar_url : '';
@@@ -516,30 +653,54 @@
else a.textContent = group ? '#' : String((p && (p.display_name || p.username)) || '?').trim().charAt(0).toUpperCase() || '?';
return a;
}
++ function groupAvatar(c) {
++ const people = others(c).slice(0, 3);
++ if (!people.length) return avatar({}, false);
++ const stack = h('span', 'ovdk-av ovdk-av-group');
++ people.forEach((p) => stack.append(avatar(p, false)));
++ return stack;
++ }
const others = (c) => (c.participants || []).filter((p) => p.username !== me());
const titleOf = (c) => c.name || others(c).map((p) => p.display_name || p.username).join(', ') || 'Just you';
-- function chatFoot(panel) {
-- const foot = h('div', 'ovdk-foot');
-- const a = h('a', '', 'Open Chat →'); a.href = `${chatBase()}/messages`; a.target = '_blank'; a.rel = 'noopener';
-- foot.append(h('span', 'ovdk-muted', 'Your conversations and DMs'), a);
-- panel.append(foot);
-- }
async function chatList() {
clearTimeout(chats.timer);
++ if (chats.edits) { chats.edits.stop(); chats.edits = null; }
chats.open = null;
const panel = chats.panel; if (!panel) return;
if (!chats.list) { panel.innerHTML = '<div class="ovdk-wait"><span></span></div>'; }
-- try { chats.list = (await chatCall('/api/dm/conversations')).conversations || []; } catch (e) { panel.textContent = ''; panel.append(h('div', 'ovdk-none', e.status === 401 ? 'Sign in to Chat to see your conversations.' : 'Your chats did not load. Check your connection, then open them again.')); chatFoot(panel); return; }
-- if (chats.open || tab !== 'chats') return;
++ const sharing = chats.draft && canWriteChat() ? load('dock-share.js', () => !!(root.OpenVibeDockShare && root.OpenVibeDockShare.bar)) : null;
++ try {
++ const [dm, ai] = await Promise.all([chatCall('/api/dm/conversations'), chatCall('/api/vibe/conversations').catch(() => null)]);
++ chats.list = dm.conversations || [];
++ chats.vibeSummary = ai && ai.count ? { kind: 'vibe', count: ai.count, latest: ai.conversations && ai.conversations[0], last_message_at: ai.conversations && ai.conversations[0] && ai.conversations[0].last_message_at } : null;
++ } catch (e) { if (panel !== chats.panel || !panelActive('chats')) return; panel.textContent = ''; panel.append(h('div', 'ovdk-none', e.status === 401 ? 'Sign in to Chat to see your conversations.' : 'Your chats did not load. Check your connection, then open them again.')); chatFoot(panel); return; }
++ if (sharing) await sharing;
++ if (panel !== chats.panel || !panelActive('chats') || chats.open) return;
panel.textContent = '';
++ if (chats.draft && canWriteChat() && root.OpenVibeDockShare) panel.append(root.OpenVibeDockShare.bar({ draft: chats.draft, h, chatCall, host: el, cancel: () => { chats.draft = null; chatList(); }, open: (id) => { chats.list = null; return openChat(String(id), true); } }));
++ // Open in Chat, as an icon over your conversations (ask 336).
++ const bar = h('div', 'ovdk-bar'); bar.append(toChat('https://openvibe.chat/messages')); panel.append(bar);
const scroll = h('div', 'ovdk-scroll');
-- if (!chats.list.length) {
++ if (!chats.list.length && !chats.vibeSummary) {
const none = h('div', 'ovdk-none');
none.append(h('b', '', 'No conversations yet'), document.createTextNode('A message to someone on OpenVibe starts one, and it shows here on every site.'));
scroll.append(none);
} else {
const ul = h('ul', 'ovdk-chats');
-- chats.list.forEach((c) => {
++ [...chats.list.filter((c) => !chats.filter || titleOf(c).toLowerCase().includes(chats.filter)), ...(chats.vibeSummary ? [chats.vibeSummary] : [])].sort((a, b) => String(b.last_message_at || b.updated_at || '').localeCompare(String(a.last_message_at || a.updated_at || ''))).forEach((c) => {
++ if (c.kind === 'vibe') {
++ const li = h('li'), b = h('button', 'ovdk-chat'); b.type = 'button';
++ const body = h('span', 'ovdk-chat-b'); body.append(h('b', '', 'Vibe'), h('span', 'ovdk-chat-p', `${c.count} chat${c.count === 1 ? '' : 's'}${c.latest && c.latest.name ? ` · ${c.latest.name}` : ''}`));
++ const meta = h('span', 'ovdk-chat-m'); const at = c.latest && c.latest.last_answer_at;
++ const t = h('time', '', when(at || c.last_message_at)); if (at) t.title = answered(at); meta.append(t);
++ const av = h('span', 'ovdk-av ovdk-av-vibe'); av.append(icon(G.vibe));
++ b.append(av, body, meta); b.setAttribute('aria-label', 'Open Vibe');
++ b.addEventListener('click', () => {
++ if (!b.dispatchEvent(new CustomEvent('openvibe:open-vibe', { bubbles: true, cancelable: true }))) return;
++ vibeOnList = true; save({ vibe: null }); setTab('vibe', true);
++ });
++ li.append(b); ul.append(li); return;
++ }
const li = h('li'), b = h('button', 'ovdk-chat');
b.type = 'button';
const unread = Number(c.unread_count) || 0;
@@@ -549,7 +710,7 @@
const meta = h('span', 'ovdk-chat-m');
meta.append(h('time', '', when(c.last_message_at || c.updated_at)));
if (unread) meta.append(h('b', 'ovdk-unread', unread > 99 ? '99+' : String(unread)));
-- b.append(avatar(others(c)[0] || {}, !!c.is_group), body, meta);
++ b.append(c.is_group ? groupAvatar(c) : avatar(others(c)[0] || {}, false), body, meta);
b.setAttribute('aria-label', `${titleOf(c)}${unread ? `, ${unread} unread` : ''}`);
if (unread) li.className = 'is-unread';
b.addEventListener('click', () => openChat(String(c.id), true));
@@@ -558,20 +719,27 @@
scroll.append(ul);
}
panel.append(scroll);
-- chatFoot(panel);
}
function bubble(m, prev) {
const mine = m.username === me();
const li = h('li', `ovdk-msg${mine ? ' is-mine' : ''}${prev && prev.username === m.username ? ' is-cont' : ''}`);
if (!mine && !(prev && prev.username === m.username)) li.append(h('span', 'ovdk-who', m.display_name || m.username || ''));
++ li.dataset.id = String(m.id);
li.append(h('span', 'ovdk-text', String(m.message || '')));
++ if (m.edited_at) li.append(h('small', 'ovdk-edited', `edited · ${String(m.edited_at).slice(11, 16)}`));
++ if (mine && canWriteChat()) { const edit = h('button', 'ovk-btn ovk-btn--quiet ovdk-edit', 'Edit'); edit.type = 'button'; li.append(edit); }
++ // A known OpenVibe link's card (Chat's link cards): dock-card.js draws it, loaded the first time one shows.
++ if (m.card) load('dock-card.js', () => !!root.OpenVibeDockCard).then((ok) => { const c = ok && root.OpenVibeDockCard(m.card, h); if (c) li.append(c); });
li.title = new Date(m.created_at).toLocaleString();
return li;
}
async function openChat(id, remember) {
clearTimeout(chats.timer);
++ if (chats.edits) { chats.edits.stop(); chats.edits = null; }
const panel = chats.panel; if (!panel) return;
chats.open = String(id); chats.msgs = []; chats.last = 0;
++ const draft = chats.draft && canWriteChat() ? chats.draft.text : '';
++ chats.draft = null;
if (remember) save({ chat: chats.open });
const conv = (chats.list || []).find((c) => String(c.id) === chats.open);
panel.textContent = '';
@@@ -579,7 +747,7 @@
const back = h('button', 'ovdk-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your chats'); back.append(icon(G.back));
back.addEventListener('click', () => { save({ chat: null }); chatList(); });
const title = h('b', '', conv ? titleOf(conv) : 'Conversation');
-- head.append(back, title);
++ head.append(back, title, toChat('https://openvibe.chat/messages'));
// Opened from the Dock's state on another page: the list is not here yet, so the title comes with it.
if (!conv) chatCall('/api/dm/conversations').then((b) => { chats.list = b.conversations || []; const c = chats.list.find((x) => String(x.id) === String(id)); if (c && chats.open === String(id)) title.textContent = titleOf(c); }).catch(() => { /* the title stays */ });
const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');
@@@ -594,6 +762,7 @@
panel.append(form);
const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
box.addEventListener('input', fit);
++ if (draft && root.OpenVibeDockShare) root.OpenVibeDockShare.ready({ draft, h, panel, form, box, fit });
box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });
form.addEventListener('submit', async (e) => {
e.preventDefault();
@@@ -615,17 +784,18 @@
list.append(h('li', 'ovdk-wait', null));
list.firstChild.innerHTML = '<span></span>';
await pull(list, scroll, true);
-- if (canWriteChat()) setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);
++ load('dock-chat-edits.js', () => !!root.OpenVibeDockChatEdits).then((ok) => { if (ok && chats.open === String(id)) chats.edits = root.OpenVibeDockChatEdits({ id: String(id), list, messages: chats.msgs, error: err, tokenOf, chatCall, chatBase, active: () => chats.open === String(id) && panelActive('chats'), load, h }); });
++ if (canWriteChat() && !(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)) setTimeout(() => { try { box.focus({ preventScroll: true }); if (draft) box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } }, 0);
}
/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */
async function pull(list, scroll, first) {
const id = chats.open;
let got = [];
try { got = (await chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/messages?limit=50${chats.last ? `&after=${chats.last}` : ''}`)).messages || []; } catch (e) {
-- if (first) { list.textContent = ''; list.append(h('li', 'ovdk-none', e.status === 403 ? 'This conversation is not yours.' : 'The messages did not load. Try again.')); }
++ if (first && panelActive('chats')) { list.textContent = ''; list.append(h('li', 'ovdk-none', e.status === 403 ? 'This conversation is not yours.' : 'The messages did not load. Try again.')); }
return;
}
-- if (id !== chats.open) return;
++ if (id !== chats.open || !panelActive('chats')) return;
if (first) list.textContent = '';
const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;
got.forEach((m) => { if (chats.msgs.some((x) => x.id === m.id)) return; list.append(bubble(m, chats.msgs[chats.msgs.length - 1])); chats.msgs.push(m); chats.last = Math.max(chats.last, Number(m.id) || 0); });
@@@ -633,13 +803,15 @@
if (first || atEnd) scroll.scrollTop = scroll.scrollHeight;
if (got.length || first) chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/read`, { method: 'POST', body: '{}' }).then(readUnread).catch(() => { /* */ });
clearTimeout(chats.timer);
-- if (isOpen() && tab === 'chats' && chats.open === id) chats.timer = setTimeout(() => pull(list, scroll), 5000);
++ if (panelActive('chats') && chats.open === id) chats.timer = setTimeout(() => pull(list, scroll), 5000);
}
// ── Vibe (plan L48 V2): the person's Vibe, in dock-vibe.js, loaded the first time the tab opens (the Dock is on
// every page for signed-in people; Vibe's conversation and mic are not needed until then). Alt+V talks to Vibe from
// any page: it opens the Dock on Vibe and starts listening (only where the browser can listen).
const vibe = { panel: null, api: null };
++ // The Chats tab's AI chats row opens the Vibe tab on its list, not on the last conversation.
++ let vibeOnList = false;
const canListen = () => !!(root.SpeechRecognition || root.webkitSpeechRecognition);
document.addEventListener('keydown', (e) => {
if (!e.altKey || e.ctrlKey || e.metaKey || String(e.key).toLowerCase() !== 'v' || !canListen() || !o || typeof o.token !== 'function') return;
@@@ -653,14 +825,14 @@
function withTimer(fn) {
if (timerMod) return fn(timerMod);
timerLoading = timerLoading || load('dock-timer.js', () => !!(root.OpenVibeDockTimer && root.OpenVibeDockTimer.attach)).then((ok) => {
-- if (ok && !timerMod) timerMod = root.OpenVibeDockTimer.attach({ state, save, host: () => el, repaint: () => paintPill() });
++ if (ok && !timerMod) timerMod = root.OpenVibeDockTimer.attach({ state, save, host: () => inline || el, repaint: () => paintPill() });
return timerMod;
});
return timerLoading.then((m) => (m ? fn(m) : null));
}
// ── The pill ─────────────────────────────────────────────
-- let pill = null, readT = 0, bound = false;
++ let pill = null, readT = 0, bound = false, openedAt = 0;
function store(v) {
try { if (v === undefined) return JSON.parse(localStorage.getItem('ov-dock') || 'null'); localStorage.setItem('ov-dock', JSON.stringify(v)); } catch { /* */ }
return null;
@@@ -702,7 +874,7 @@
const next = r.ok ? await r.json() : null;
if (!next || !next.state || !pill) return;
update(next);
-- if (!isOpen() && next.state.open && !phone()) open();
++ if (!isOpen() && !inline && next.state.open && !phone()) open();
const c = next.counts || {};
if (c.running || c.waiting || next.state.timer) readT = setTimeout(read, 30000);
} catch { /* offline: the copy stands */ }
@@@ -710,6 +882,7 @@
/** The pill for a signed-in person (navbar.js), or none (null: signed out, or the site turned the Dock off). */
function mount(opts) {
if (!opts) {
++ if (mountHandle) mountHandle.destroy();
clearTimeout(readT);
if (pill) { pill.remove(); pill = null; }
if (isOpen()) close();
@@@ -747,7 +920,7 @@
if (!b) return;
b.hidden = !pip();
const t = popped ? 'Bring the Dock back to the page' : 'Open the Dock in a window of its own';
-- b.title = t; b.setAttribute('aria-label', t);
++ b.title = t; const name = b.querySelector('b'); if (name) name.textContent = t;
}
/** The window gets the Dock's styles, the theme's colours and the page's own type: the Dock looks the same there. */
@@@ -809,8 +982,54 @@
}
// ── Open and close ───────────────────────────────────────
++ // Ask your agent (4.135.0): Vibe opens with the question in its box, unsent.
++ function ask({ text = '' } = {}, n = 0) { if (!n) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); }
function isOpen() { return !!(el && !el.hidden); }
++ // A phone's sheet and the navbar's orb are one button (.from-orb).
++ let orbLink = null, closing = 0;
++ function navOrb() {
++ if (!phone()) return null;
++ const list = document.querySelectorAll('.openvibe-navbar .ovnav-orb');
++ for (let i = 0; i < list.length; i++) { const r = list[i].getBoundingClientRect(); if (r.width && r.left > root.innerWidth / 2 && r.top < 120 && r.bottom > 0) return list[i]; }
++ return null;
++ }
++ function fromOrb() {
++ unlinkOrb();
++ const nav = navOrb(), mine = el.querySelector('.ovdk-orbx'), mineSvg = mine && mine.querySelector('svg');
++ if (!nav || !mineSvg || reduced() || popped) return;
++ el.classList.add('from-orb'); // first: it sets the header's layout, and the close orb is measured where it will rest
++ const a = (nav.querySelector('svg') || nav).getBoundingClientRect(), b = mineSvg.getBoundingClientRect(), box = el.getBoundingClientRect();
++ if (!a.width || !b.width) { el.classList.remove('from-orb'); return; }
++ const ax = a.left + a.width / 2, ay = a.top + a.height / 2, bx = b.left + b.width / 2, by = b.top + b.height / 2, s = a.width / b.width;
++ el.style.setProperty('--ovdk-ox', `${Math.round(ax - box.left)}px`); el.style.setProperty('--ovdk-oy', `${Math.round(ay - box.top)}px`);
++ mine.style.setProperty('--orb-dx', `${(ax - bx).toFixed(1)}px`); mine.style.setProperty('--orb-dy', `${(ay - by).toFixed(1)}px`); mine.style.setProperty('--orb-s', s.toFixed(3));
++ nav.style.setProperty('--dock-dx', `${(bx - ax).toFixed(1)}px`); nav.style.setProperty('--dock-dy', `${(by - ay).toFixed(1)}px`); nav.style.setProperty('--dock-s', (1 / s).toFixed(3));
++ mine.classList.add('is-set', 'is-from');
++ orbLink = { nav, mine };
++ void mine.offsetWidth; // the starting pose is put on, not animated into
++ mine.classList.remove('is-set');
++ void mine.offsetWidth;
++ nav.style.transition = 'transform .42s cubic-bezier(.2, 1.1, .3, 1), color .3s';
++ root.requestAnimationFrame(() => { if (!orbLink || orbLink.mine !== mine) return; mine.classList.remove('is-from'); nav.classList.add('is-dock'); });
++ }
++ function unlinkOrb() {
++ if (closing) { clearTimeout(closing); closing = 0; }
++ if (el) el.classList.remove('from-orb', 'is-out');
++ if (!orbLink) return;
++ orbLink.nav.classList.remove('is-dock'); orbLink.nav.style.transition = ''; orbLink.mine.classList.remove('is-from', 'is-set');
++ orbLink = null;
++ }
++ function share({ text = '', title = '' } = {}) {
++ const t = String(text || '').trim().slice(0, 4000);
++ if (!t) return;
++ chats.draft = { text: t, title: String(title || '').trim().slice(0, 140) }; chats.open = null;
++ // A bar left from an earlier share would show the old words until the list reloads: it goes now.
++ const stale = chats.panel && chats.panel.querySelector('.ovdk-share'); if (stale) stale.remove();
++ open({ tab: 'chats' });
++ }
function open(opts = {}) {
++ if (mountHandle) mountHandle.destroy();
++ openedAt = typeof performance !== 'undefined' && performance.now ? performance.now() : 0;
o = { ...o, ...opts };
if (opts.doc) doc = opts.doc;
if (!el) build();
@@@ -822,8 +1041,18 @@
paintPin();
paintPop();
el.hidden = false;
-- el.classList.remove('is-in'); void el.offsetWidth; el.classList.add('is-in');
-- if (phone()) { el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open'); } else el.removeAttribute('aria-modal');
++ el.classList.remove('is-in');
++ fromOrb();
++ void el.offsetWidth; el.classList.add('is-in');
++ // On a computer it grows out of its pill; a phone's sheet opens out of the navbar's orb (fromOrb), or rises.
++ if (!phone() && root.OpenVibeNavbar && root.OpenVibeNavbar.pop) {
++ const pill = document.getElementById('ovdk-pill');
++ root.OpenVibeNavbar.pop(el, pill?.offsetWidth ? pill : null);
++ }
++ if (phone()) {
++ el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open');
++ if (root.OVSurfaces && !dockSurface) { dockSurface = root.OVSurfaces.sheet({ el, body: el.querySelector('.ovdk-body'), close }); dockSurface.open(); }
++ } else { el.removeAttribute('aria-modal'); if (dockSurface) { dockSurface.destroy(); dockSurface = null; } }
setTab(want, false);
readUnread();
if (!state().open || (state().tab !== tab && SAVED_TABS.includes(tab))) save({ open: true, ...(SAVED_TABS.includes(tab) ? { tab } : {}) }); else notify();
@@@ -831,16 +1060,29 @@
}
function close() {
if (!isOpen()) return;
++ // Fold back into the navbar's orb first, then go.
++ if (orbLink && !closing && !popped && !reduced() && el.classList.contains('from-orb')) {
++ el.classList.remove('is-in'); el.classList.add('is-out');
++ orbLink.mine.classList.add('is-from'); orbLink.nav.classList.remove('is-dock');
++ closing = setTimeout(() => { closing = 0; shut(); }, 280);
++ return;
++ }
++ shut();
++ }
++ function shut(keepState = false) {
++ if (!isOpen()) return;
++ unlinkOrb();
const wasPopped = popped;
if (wasPopped) { popped = false; const w = pipWin; pipWin = null; pipClosing = true; try { if (w) w.close(); } catch { /* already gone */ } pipClosing = false; if (el.parentNode !== document.body) document.body.appendChild(el); }
const inside = el.contains(document.activeElement);
el.hidden = true;
++ if (dockSurface) { dockSurface.destroy(); dockSurface = null; }
el.style.transform = '';
el.removeAttribute('aria-modal');
document.documentElement.classList.remove('ovdk-sheet-open');
clear(0);
clearTimeout(chats.timer); clearTimeout(chats.unreadT);
-- save({ open: false });
++ if (!keepState) save({ open: false });
if (inside) {
const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);
try { if (back) back.focus({ preventScroll: true }); } catch { /* */ }
@@@ -859,5 +1101,29 @@
if (isOpen()) layout();
}
-- root.OpenVibeDock = { mount, open, close, isOpen, update, _state: () => ({ doc, tab, pending }) };
++ let mountHandle = null;
++ /** Place the same panel body under another sheet's head. The helper and its layout load on first use. */
++ function mountInto(host, { tab: first = 'activity' } = {}) {
++ if (!(host instanceof Element)) throw new TypeError('mountInto needs a host element');
++ if (mountHandle) mountHandle.destroy();
++ if (isOpen()) { clearTimeout(closing); closing = 0; shut(true); }
++ let api = null, gone = false, wanted = first;
++ const handle = {
++ show(id) { if (gone) return; wanted = id; if (api) api.show(id); },
++ destroy() { if (gone) return; gone = true; if (api) api.destroy(); if (mountHandle === handle) mountHandle = null; },
++ };
++ mountHandle = handle;
++ load('dock-mount.js', () => !!root.OpenVibeDockMount).then((ok) => {
++ if (ok && !gone) api = root.OpenVibeDockMount.mount(host, wanted, {
++ start(wrap) { if (!el) build(); inline = wrap; wrap.append(bodyEl); },
++ show(id) { setTab(id, false); badges(); },
++ end() { disposePanel(); inline = null; if (el) el.insertBefore(bodyEl, el.querySelector('.ovdk-size')); paintPill(); },
++ });
++ });
++ return handle;
++ }
++
++ root.OpenVibeDock = { mount, mountInto, open, close, isOpen, update, ask, share,
++ filterChats: (v) => { chats.filter = v.toLowerCase(); chatList(); },
++ more: a => el.querySelector(`[data-act="${a}"]`).click(), canSend: () => canWriteChat(), _state: () => ({ doc, tab, pending }) };
})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/browser/shared/kit.css b/browser/shared/kit.css
iindex bcb4ba6..8cde492 100644
---- a/browser/shared/kit.css
++++ b/browser/shared/kit.css
@@@ -51,7 +51,7 @@
--ovk-radius-sm: var(--ov-radius-1);
--ovk-tap: 44px;
--ovk-top: var(--ovnav-real-h, 52px); /* the navbar's height: sticky things stop under it */
-- --ovk-bottom: var(--ovnav-appbar-h, 0px); /* the app bar's height on phones (navbar.js sets it) */
++ --ovk-bottom: var(--ovb-dock, var(--ovnav-appbar-h, 0px)); /* the app bar (and a small player above it) on phones, measured by bottom.js */
--ovk-font: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
}
@@@ -63,9 +63,16 @@
:is([data-theme=neon-tokyo], [data-theme=vapor], [data-theme=matrix], [data-theme=hacker], [data-theme=high-contrast], [data-theme=dracula], [data-theme=monokai], [data-theme=abyss]) {
--ov-dur-1: .09s; --ov-dur-2: .15s; --ov-dur-3: .24s; --ov-dur-4: .42s; --ov-ease-spring: cubic-bezier(.3, 1.8, .6, 1);
}
--html[data-ov-motion=reduced] { --ov-dur-1: 0s; --ov-dur-2: 0s; --ov-dur-3: 0s; --ov-dur-4: 0s; }
++/* Motion (4.183.0; the owner, 2026-11-10: "it should still have full motion but actually just reduce it instead of
++ getting rid of it"). Calm keeps every effect and makes it smaller and slower: the calm feel's lengths, no
++ overshoot, and --ov-move, the factor a moving thing multiplies its distance by (calc(12px * var(--ov-move, 1))).
++ Only Off stops motion. theme-loader.js writes data-ov-motion (reduced for Calm, the device's own setting counted
++ in; off; full); the media rule serves a page the loader has not reached. */
++:where(html) { --ov-move: 1; }
++html[data-ov-motion=reduced] { --ov-move: .35; --ov-dur-1: .15s; --ov-dur-2: .25s; --ov-dur-3: .4s; --ov-dur-4: .62s; --ov-ease-spring: var(--ov-ease-out); }
++@media (prefers-reduced-motion: reduce) { html:not([data-ov-motion]) { --ov-move: .35; --ov-ease-spring: var(--ov-ease-out); } }
/* :root:root outranks ov-fx.js's own :root and theme feels, which load later. */
--@media (prefers-reduced-motion: reduce) { :root:root { --ov-dur-1: 0s; --ov-dur-2: 0s; --ov-dur-3: 0s; --ov-dur-4: 0s; } }
++:root:root[data-ov-motion=off] { --ov-move: 0; --ov-dur-1: 0s; --ov-dur-2: 0s; --ov-dur-3: 0s; --ov-dur-4: 0s; }
/* Shape personalities (plan L45: "Paper is quiet and square, Neon glows and bounces"). A quiet look has square corners
* and lies flat, with a hairline where a shadow would be; a loud one is round and its raised things glow in its accent.
* The ids are Network's theme catalog (as ov-fx.js's motion feels); any other look keeps the defaults above. */
@@@ -111,6 +118,83 @@ html[data-ov-motion=reduced] { --ov-dur-1: 0s; --ov-dur-2: 0s; --ov-dur-3: 0s; -
.ovk-icon-btn[hidden] { display: none; }
.ovk-icon-btn.is-done { color: var(--ovk-ok-text); }
.ovk-icon-btn--danger:hover { color: var(--ovk-bad-text); }
++
++/* ── Buttons that show what they do and what is happening (4.204.0) ───────────────────────────────────────────────
++ The owner, 2026-11-11: "improve our buttons across the site and add better animations and effects for them, and
++ different things we can use them for, and little indicators or statuses … idle animation … more important buttons …
++ more intuitive and more modular … shared across everything through openvibe.design". docs/kit.md#buttons.
++ Every motion is scaled by --ov-move (Calm makes it smaller and slower, Off removes it; the durations follow). */
++.ovk-btn, .ovk-icon-btn { position: relative; -webkit-tap-highlight-color: transparent;
++ transition: transform var(--ov-dur-1, .12s) var(--ov-ease-out, ease), box-shadow var(--ov-dur-2, .2s) var(--ov-ease-out, ease),
++ background-color var(--ov-dur-1, .12s), border-color var(--ov-dur-1, .12s), color var(--ov-dur-1, .12s), filter var(--ov-dur-1, .12s); }
++.ovk-btn:hover:not([disabled]):not([aria-disabled="true"]) { transform: translateY(calc(-1px * var(--ov-move, 1))); box-shadow: 0 8px 18px -12px rgba(0, 0, 0, .6); }
++.ovk-btn:active:not([disabled]), .ovk-icon-btn:active:not([disabled]) { transform: scale(calc(1 - .03 * var(--ov-move, 1))); box-shadow: none; transition-duration: .06s; }
++.ovk-btn .ovk-i, .ovk-icon-btn .ovk-i { transition: transform var(--ov-dur-2, .2s) var(--ov-ease-spring, var(--ov-ease-out, ease)); }
++/* The icon moves the way the button does (data-nudge): forward slides on, add turns, open lifts, down drops, refresh turns. */
++.ovk-btn[data-nudge="forward"]:hover .ovk-i:last-child, .ovk-icon-btn[data-nudge="forward"]:hover .ovk-i { transform: translateX(calc(3px * var(--ov-move, 1))); }
++.ovk-btn[data-nudge="back"]:hover .ovk-i:first-child, .ovk-icon-btn[data-nudge="back"]:hover .ovk-i { transform: translateX(calc(-3px * var(--ov-move, 1))); }
++.ovk-btn[data-nudge="add"]:hover .ovk-i:first-child, .ovk-icon-btn[data-nudge="add"]:hover .ovk-i { transform: rotate(calc(90deg * var(--ov-move, 1))); }
++.ovk-btn[data-nudge="open"]:hover .ovk-i:last-child, .ovk-icon-btn[data-nudge="open"]:hover .ovk-i { transform: translate(calc(2px * var(--ov-move, 1)), calc(-2px * var(--ov-move, 1))); }
++.ovk-btn[data-nudge="down"]:hover .ovk-i:last-child, .ovk-icon-btn[data-nudge="down"]:hover .ovk-i { transform: translateY(calc(2px * var(--ov-move, 1))); }
++.ovk-btn[data-nudge="refresh"]:hover .ovk-i:first-child, .ovk-icon-btn[data-nudge="refresh"]:hover .ovk-i { transform: rotate(calc(180deg * var(--ov-move, 1))); }
++
++/* Importance: hero (the one thing a page is for), soft (a tinted second choice), live (on air now). */
++.ovk-btn--hero { min-height: 52px; padding: 12px 24px; font-size: 1.02rem; color: var(--ovk-on-fill); border: 0;
++ background: linear-gradient(135deg, var(--ovk-accent), var(--ovk-fill)); box-shadow: 0 10px 26px -14px var(--ovk-fill); }
++.ovk-btn--hero:hover:not([disabled]) { box-shadow: 0 14px 30px -12px var(--ovk-fill); filter: brightness(1.06); }
++.ovk-btn--soft { color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); border-color: color-mix(in srgb, var(--ovk-accent) 22%, transparent); }
++.ovk-btn--soft:hover { background: color-mix(in srgb, var(--ovk-accent) 20%, transparent); }
++.ovk-btn--live { color: #fff; background: var(--ovk-live); }
++.ovk-btn--live::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #fff; flex: none; animation: ovkBreathe 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }
++@keyframes ovkBreathe { 50% { opacity: calc(1 - .65 * var(--ov-move, 1)); transform: scale(calc(1 - .2 * var(--ov-move, 1))); } }
++
++/* A toggle says it is on (aria-pressed): filled tint and a check-weight border. */
++.ovk-btn[aria-pressed="true"], .ovk-icon-btn[aria-pressed="true"] { color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 16%, transparent); border-color: color-mix(in srgb, var(--ovk-accent) 45%, transparent); }
++
++/* The one next step (data-attention): a soft ring goes out from it now and then, until it is pressed. */
++.ovk-btn[data-attention]:not([disabled])::after, .ovk-icon-btn[data-attention]:not([disabled])::after {
++ content: ""; position: absolute; inset: -1px; border-radius: inherit; pointer-events: none;
++ box-shadow: 0 0 0 0 color-mix(in srgb, var(--ovk-accent) 55%, transparent); animation: ovkAttention 3.2s var(--ov-ease-out, ease-out) infinite; }
++@keyframes ovkAttention { 0%, 55% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ovk-accent) 55%, transparent); } 85%, 100% { box-shadow: 0 0 0 calc(10px * var(--ov-move, 1)) transparent; } }
++
++/* A count (data-badge="3") and a status dot (data-dot="ok|warn|bad|live") at the top right. */
++.ovk-btn[data-badge]:not([data-badge=""])::before, .ovk-icon-btn[data-badge]:not([data-badge=""])::before {
++ content: attr(data-badge); position: absolute; top: -6px; right: -6px; min-width: 18px; height: 18px; padding: 0 5px; box-sizing: border-box;
++ border-radius: 999px; background: var(--ovk-bad); color: #fff; font-size: 11px; font-weight: 800; line-height: 18px; text-align: center;
++ box-shadow: 0 0 0 2px var(--ovk-bg, var(--bg-primary, #0b0f19)); animation: ovkPop var(--ov-dur-2, .2s) var(--ov-ease-spring, ease-out); }
++.ovk-btn--live[data-badge]::before { animation: ovkBreathe 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }
++.ovk-btn[data-dot], .ovk-icon-btn[data-dot] { --ovk-dot: var(--ovk-muted); }
++.ovk-btn[data-dot="ok"], .ovk-icon-btn[data-dot="ok"] { --ovk-dot: var(--ovk-ok); }
++.ovk-btn[data-dot="warn"], .ovk-icon-btn[data-dot="warn"] { --ovk-dot: var(--warning, #f59e0b); }
++.ovk-btn[data-dot="bad"], .ovk-icon-btn[data-dot="bad"], .ovk-btn[data-dot="live"], .ovk-icon-btn[data-dot="live"] { --ovk-dot: var(--ovk-bad); }
++.ovk-btn[data-dot]:not([data-badge])::before, .ovk-icon-btn[data-dot]:not([data-badge])::before {
++ content: ""; position: absolute; top: 4px; right: 4px; width: 8px; height: 8px; border-radius: 50%; background: var(--ovk-dot);
++ box-shadow: 0 0 0 2px var(--ovk-bg, var(--bg-primary, #0b0f19)); }
++.ovk-btn[data-dot="live"]:not([data-badge])::before, .ovk-icon-btn[data-dot="live"]:not([data-badge])::before { animation: ovkBreathe 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }
++@keyframes ovkPop { from { transform: scale(calc(1 - .6 * var(--ov-move, 1))); opacity: 0; } }
++
++/* Progress inside the button (style="--ovk-progress: .4"): an upload, a countdown, a hold. */
++.ovk-btn[style*="--ovk-progress"] { background-image: linear-gradient(90deg, color-mix(in srgb, currentColor 18%, transparent) calc(var(--ovk-progress, 0) * 100%), transparent 0); }
++
++/* What is happening (data-state, set by OpenVibeKitWeb.busy): busy keeps the words and adds the mark's wait; done shows
++ a check for a moment; error shakes once and turns red. */
++.ovk-btn[data-state="busy"] { cursor: progress; pointer-events: none; }
++.ovk-btn[data-state="busy"] > :not(.ovk-btn-wait) { opacity: .62; }
++.ovk-btn-wait { display: inline-flex; gap: 3px; align-items: center; }
++.ovk-btn-wait i { width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: ovkWait 1.1s var(--ov-ease-in-out, ease-in-out) infinite; }
++.ovk-btn-wait i:nth-child(2) { animation-delay: .15s; } .ovk-btn-wait i:nth-child(3) { animation-delay: .3s; }
++@keyframes ovkWait { 0%, 80%, 100% { opacity: .25; transform: translateY(0); } 40% { opacity: 1; transform: translateY(calc(-3px * var(--ov-move, 1))); } }
++.ovk-btn[data-state="done"] { color: #fff; background: var(--ovk-ok); border-color: transparent; }
++.ovk-btn[data-state="done"]::after { content: ""; flex: none; width: 16px; height: 16px; background: currentColor; animation: ovkPop var(--ov-dur-2, .2s) var(--ov-ease-spring, ease-out);
++ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12.5 4.5 4.5L19 7.5'/%3E%3C/svg%3E") center / contain no-repeat;
++ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12.5 4.5 4.5L19 7.5'/%3E%3C/svg%3E") center / contain no-repeat; }
++.ovk-btn[data-state="error"] { color: var(--ovk-bad-text); border-color: var(--ovk-bad); background: color-mix(in srgb, var(--ovk-bad) 10%, transparent); animation: ovkShake .36s var(--ov-ease-out, ease-out); }
++@keyframes ovkShake { 25% { transform: translateX(calc(-4px * var(--ov-move, 1))); } 50% { transform: translateX(calc(4px * var(--ov-move, 1))); } 75% { transform: translateX(calc(-2px * var(--ov-move, 1))); } }
++
++/* Hold to confirm (data-hold="1200" on a destructive button): the fill runs while it is held; letting go early cancels. */
++.ovk-btn[data-hold] { background-image: linear-gradient(90deg, color-mix(in srgb, var(--ovk-bad) 26%, transparent) var(--ovk-hold, 0%), transparent 0); }
++.ovk-btn[data-hold].is-holding { transition: none; }
++@media (hover: none) { .ovk-btn:hover:not([disabled]):not([aria-disabled="true"]) { transform: none; box-shadow: none; } }
.ovk-icon-btn .ovk-i { width: 20px; height: 20px; }
@media (pointer: coarse) { .ovk-icon-btn { width: var(--ovk-tap); height: var(--ovk-tap); } }
.ovk-badge { display: inline-flex; align-items: center; flex: none; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--ovk-line); font-size: .75rem; font-weight: 800; letter-spacing: .03em; text-transform: uppercase; color: var(--ovk-muted); white-space: nowrap; }
@@@ -128,10 +212,16 @@ html[data-ov-motion=reduced] { --ov-dur-1: 0s; --ov-dur-2: 0s; --ov-dur-3: 0s; -
.ovk-head-row { display: flex; align-items: center; gap: 6px; min-height: 48px; padding-bottom: 6px; }
.ovk-head-row > .ovk-back { margin-left: -8px; }
.ovk-head-id { flex: 1; min-width: 0; }
--.ovk-head-id.ovk-head-id h1, .ovk-head-id.ovk-head-id h2 { margin: 0; color: var(--ovk-text); font-family: var(--ovk-font); font-size: clamp(1.15rem, 4.2vw, 1.5rem); font-weight: 800; letter-spacing: -.01em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
--.ovk-sub { margin: 2px 0 0; color: var(--ovk-muted); font-size: .86rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++/* The title and its line wrap to two lines before anything is cut (openvibe.design names-are-never-cut, 2026-11-11: the
++ Arena's "One prompt, two models, side b…" lost its point on a phone). */
++.ovk-head-id.ovk-head-id h1, .ovk-head-id.ovk-head-id h2 { margin: 0; color: var(--ovk-text); font-family: var(--ovk-font); font-size: clamp(1.15rem, 4.2vw, 1.5rem); font-weight: 800; letter-spacing: -.01em; line-height: 1.2; overflow: hidden; overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
++.ovk-sub { margin: 2px 0 0; color: var(--ovk-muted); font-size: .86rem; line-height: 1.35; overflow: hidden; overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
.ovk-head-tools { display: flex; align-items: center; gap: 2px; margin-right: -8px; }
.ovk-head-tools form { margin: 0; }
++.ovk-heading-icon { display: grid; place-items: center; flex: 0 0 40px; width: 40px; height: 40px; border-radius: var(--ovk-radius-sm); color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 12%, transparent); }
++.ovk-heading-icon > .ovk-i { width: 22px; height: 22px; }
++.ovk-heading-icon.ovk-i { box-sizing: border-box; width: 40px; height: 40px; padding: 9px; vertical-align: middle; }
++.ovk-titled.ovk-titled { display: flex; align-items: center; gap: 10px; white-space: normal; overflow-wrap: anywhere; }
.ovk-tabs { position: relative; display: flex; gap: 4px; margin: 0 -16px; padding: 0 16px 8px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
.ovk-tabs::-webkit-scrollbar { display: none; }
.ovk-tabs a { display: inline-flex; align-items: center; gap: 6px; flex: none; min-height: 36px; padding: 6px 14px; border-radius: 999px; color: var(--ovk-muted); font-size: .9rem; font-weight: 650; text-decoration: none; white-space: nowrap; }
@@@ -173,6 +263,12 @@ html[data-ov-motion=reduced] { --ov-dur-1: 0s; --ov-dur-2: 0s; --ov-dur-3: 0s; -
/* ── Sections (settings groups, a page's parts) and folds ── */
.ovk-section { margin: 0 0 12px; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-panel); overflow: hidden; color: var(--ovk-text); }
.ovk-section.ovk-section > h2 { display: flex; align-items: center; gap: 6px; margin: 0; padding: 12px 14px 6px; color: var(--ovk-muted); font-family: inherit; font-size: .74rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
++.ovk-section-head { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 14px; }
++.ovk-section-head.ovk-section-head > h2 { flex: 1 1 160px; min-width: 0; margin: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; color: var(--ovk-text); font-family: var(--ovk-font); font-size: 1rem; font-weight: 800; line-height: 1.35; overflow-wrap: anywhere; }
++.ovk-section-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; max-width: 100%; }
++.ovk-section-actions .ovk-btn { min-height: 44px; white-space: normal; text-align: center; }
++.ovk-section-actions form { margin: 0; }
++.ovk-section > .ovk-empty { margin: 0 14px 14px; }
.ovk-count { color: var(--ovk-text); font-weight: 800; letter-spacing: 0; }
.ovk-note { margin: 0; padding: 0 14px 10px; color: var(--ovk-muted); font-size: .86rem; }
.ovk-section > .ovk-form, .ovk-section > form:not(.ovk-find):not(.ovk-inline) { padding: 4px 14px 14px; }
@@@ -205,6 +301,12 @@ div.ovk-set-row { cursor: default; }
.ovk-set-row select { flex: none; max-width: 52%; margin: 0; padding: 7px 8px; border-radius: var(--ov-radius-1); border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: .9rem; }
.ovk-set-range input[type=range] { flex: 0 1 45%; min-width: 120px; margin: 0; accent-color: var(--ovk-fill); }
.ovk-set-chips { flex-wrap: wrap; }
++/* A switch row (4.164.0): the icon tile, the title and its one line, then the switch; the whole 56 px row is the
++ target, and the switch is the native checkbox (role="switch") drawn as one, so it works without JavaScript. */
++.ovk-set-switch { min-height: 56px; }
++.ovk-set-ic { flex: none; display: grid; place-items: center; width: 36px; height: 36px; border-radius: var(--ov-radius-1); background: color-mix(in srgb, var(--ovk-accent) 12%, transparent); color: var(--ovk-accent); }
++.ovk-set-ic .ovk-i { width: 19px; height: 19px; }
++.ovk-set-row .ovk-switch { margin-left: auto; }
.ovk-switch { appearance: none; -webkit-appearance: none; flex: none; position: relative; width: 44px; height: 26px; margin: 0; padding: 0; border-radius: 999px; border: 0; background: var(--ovk-line); cursor: pointer; transition: background .15s; }
.ovk-switch::after { content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: var(--ov-elev-1); transition: transform .15s; }
.ovk-switch:checked { background: var(--ovk-fill); }
@@@ -215,10 +317,17 @@ div.ovk-set-row { cursor: default; }
.ovk-seg span { display: block; padding: 6px 12px; border-radius: max(0px, calc(var(--ov-radius-1) - 2px)); color: var(--ovk-muted); font-size: .86rem; font-weight: 650; cursor: pointer; }
.ovk-seg input:checked + span { background: var(--ovk-fill); color: var(--ovk-on-fill); }
.ovk-seg input:focus-visible + span, .ovk-chip input:focus-visible + span { outline: 2px solid var(--ovk-accent); outline-offset: 1px; }
++/* A segmented control (4.164.0): three or four equal choices in one 44 px control; the icons sit before the words. */
++.ovk-seg--fill { display: flex; flex: 1 1 auto; flex-wrap: nowrap; width: 100%; min-width: 0; }
++.ovk-seg--fill .ovk-seg-item { flex: 1 1 0; min-width: 0; }
++.ovk-seg--fill span { display: flex; align-items: center; justify-content: center; gap: 6px; min-height: 38px; box-sizing: border-box; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.ovk-seg--fill .ovk-i { flex: none; width: 16px; height: 16px; }
++/* Straight in a section (no label row around it), it keeps the rows' inset instead of running to the card's edges. */
++.ovk-section > .ovk-seg--fill { width: auto; margin: 10px 12px 6px; }
.ovk-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.ovk-chip span { display: inline-flex; align-items: center; min-height: 32px; box-sizing: border-box; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--ovk-line); color: var(--ovk-muted); font-size: .86rem; font-weight: 650; cursor: pointer; }
.ovk-chip input:checked + span { border-color: color-mix(in srgb, var(--ovk-accent) 55%, transparent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); color: var(--ovk-text); }
--.ovk-save { position: sticky; bottom: var(--ovk-bottom); z-index: 30; margin: 0; padding: 10px 0 calc(10px + env(safe-area-inset-bottom, 0px)); background: linear-gradient(transparent, var(--ovk-bg) 35%); }
++.ovk-save { position: sticky; bottom: var(--ovk-bottom); translate: 0 calc(-1 * var(--ovb-lift, 0px)); z-index: 30; margin: 0; padding: 10px 0 calc(10px + env(safe-area-inset-bottom, 0px)); background: linear-gradient(transparent, var(--ovk-bg) 35%); }
.ovk-save .ovk-btn { width: 100%; }
.ovk-form { display: grid; gap: 12px; margin: 0; }
.ovk-form label { display: grid; gap: 5px; color: var(--ovk-muted); font-size: .9rem; }
@@@ -242,7 +351,7 @@ div.ovk-set-row { cursor: default; }
.ovk-go { display: inline-grid; place-items: center; flex: none; width: 38px; height: 38px; padding: 0; border: 0; border-radius: 50%; background: var(--ovk-fill); color: var(--ovk-on-fill); cursor: pointer; }
.ovk-go .ovk-i { width: 18px; height: 18px; stroke-width: 2.4; }
.ovk-go--send { width: var(--ovk-tap); height: var(--ovk-tap); }
--.ovk-dock { position: sticky; bottom: var(--ovk-bottom); z-index: 30; display: flex; align-items: flex-end; gap: 6px; margin: 8px -16px 0; padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px)); background: color-mix(in srgb, var(--ovk-bg) 92%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }
++.ovk-dock { position: sticky; bottom: var(--ovk-bottom); translate: 0 calc(-1 * var(--ovb-lift, 0px)); z-index: 30; display: flex; align-items: flex-end; gap: 6px; margin: 8px -16px 0; padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px)); background: color-mix(in srgb, var(--ovk-bg) 92%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }
.ovk-compose { flex: 1; min-width: 0; display: flex; align-items: flex-end; gap: 8px; margin: 0; }
.ovk-compose textarea { flex: 1; min-width: 0; min-height: var(--ovk-tap); max-height: 40vh; box-sizing: border-box; margin: 0; padding: 10px 16px; border-radius: 22px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 1rem; line-height: 1.4; resize: none; field-sizing: content; }
.ovk-compose textarea:focus-visible { outline: none; border-color: var(--ovk-accent); }
@@@ -275,14 +384,18 @@ div.ovk-set-row { cursor: default; }
.ovk-empty > .ovk-i { width: 28px; height: 28px; }
.ovk-empty.ovk-empty p { margin: 0; max-width: 46ch; }
.ovk-empty .ovk-empty-title { color: var(--ovk-text); font-weight: 750; }
--.ovk-notice { display: flex; align-items: flex-start; gap: 10px; margin: 0 0 12px; padding: 10px 14px; border-radius: calc(var(--ov-radius-1) + 2px); border: 1px solid var(--ovk-line); background: var(--ovk-panel); color: var(--ovk-text); font-size: .92rem; }
--.ovk-notice > .ovk-i { margin-top: 2px; }
--.ovk-notice--ok { border-color: color-mix(in srgb, var(--ovk-ok) 45%, transparent); }
--.ovk-notice--ok > .ovk-i { color: var(--ovk-ok-text); }
--.ovk-notice--error { border-color: color-mix(in srgb, var(--ovk-bad) 50%, transparent); }
--.ovk-notice--error > .ovk-i { color: var(--ovk-bad-text); }
--.ovk-notice--warn > .ovk-i { color: var(--ovk-warn-text); }
--.ovk-notice--info > .ovk-i { color: var(--ovk-accent); }
++/* A tone is a lean and a glow, never a coloured bar (4.196.0; the owner, 2026-11-10: "instead of just like this red and
++ crappy border … shadow, glow … something nice. Also add some icons"). A notice leans toward its tone from the side its
++ icon is on, carries a hairline tinted with it and a soft glow in it; its icon sits in a round chip of the tone. The same
++ three properties are a class of their own (.ovk-tone, with data-tone) for a card, a toast or a row that must say
++ "good", "careful" or "wrong" without a stripe. */
++.ovk-notice, .ovk-tone { --ovk-tone: var(--ovk-accent); --ovk-tone-text: var(--ovk-accent); border: 1px solid color-mix(in srgb, var(--ovk-tone) 32%, var(--ovk-line)); background: linear-gradient(100deg, color-mix(in srgb, var(--ovk-tone) 13%, var(--ovk-panel)) 0, var(--ovk-panel) 58%); box-shadow: 0 12px 28px -20px var(--ovk-tone); }
++.ovk-notice--ok, .ovk-tone[data-tone="good"] { --ovk-tone: var(--ovk-ok); --ovk-tone-text: var(--ovk-ok-text); }
++.ovk-notice--error, .ovk-tone[data-tone="alert"] { --ovk-tone: var(--ovk-bad); --ovk-tone-text: var(--ovk-bad-text); }
++.ovk-notice--warn, .ovk-tone[data-tone="warning"] { --ovk-tone: var(--ovk-warn); --ovk-tone-text: var(--ovk-warn-text); }
++.ovk-notice { display: flex; align-items: flex-start; gap: 11px; margin: 0 0 12px; padding: 10px 14px 10px 10px; border-radius: calc(var(--ov-radius-1) + 4px); color: var(--ovk-text); font-size: .92rem; }
++.ovk-notice > .ovk-i { flex: none; box-sizing: border-box; width: 26px; height: 26px; margin: -2px 0; padding: 5px; border-radius: 50%; background: color-mix(in srgb, var(--ovk-tone) 20%, transparent); color: var(--ovk-tone-text); }
++.ovk-notice > span { align-self: center; min-width: 0; }
.ovk-snip { position: relative; }
.ovk-snip > .ovk-icon-btn { position: absolute; top: 6px; right: 6px; z-index: 1; width: 34px; height: 34px; background: var(--ovk-panel); border-color: var(--ovk-line); }
.ovk-snip > pre { max-height: 50vh; margin: 0 0 10px; padding: 12px 48px 12px 12px; overflow: auto; border-radius: var(--ov-radius-1); border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font-size: .84rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
@@@ -312,7 +425,6 @@ div.ovk-set-row { cursor: default; }
.ovk-split { grid-template-columns: minmax(0, 1fr) 320px; align-items: start; }
.ovk-split > .ovk-side { position: sticky; top: calc(var(--ovk-top) + 12px); }
}
--@media (prefers-reduced-motion: reduce) { .ovk-switch, .ovk-switch::after, .ovk-fold > summary > .ovk-i, .ovk-head, .ovk-btn { transition: none; } }
@media print { .ovk-head, .ovk-dock, .ovk-save { position: static; background: none; -webkit-backdrop-filter: none; backdrop-filter: none; } }
/* Touch (4.46.0, the fleet tap audit): small buttons are 44px tall on a touch screen, the find bar's go button reaches
@@@ -370,12 +482,11 @@ div.ovk-set-row { cursor: default; }
.ovk-sidebar-foot { padding-top: 8px; border-top: 1px solid var(--ovk-line); }
@media (max-width: 760px) {
.ovk-with-sidebar { grid-template-columns: minmax(0, 1fr); }
-- .ovk-sidebar { position: fixed; z-index: 10060; top: 0; left: 0; bottom: 0; height: auto; width: min(320px, 86vw); padding-top: max(12px, env(safe-area-inset-top, 0px)); padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)); border-right: 0; translate: -100% 0; visibility: hidden; transition: translate .25s ease, visibility 0s linear .25s; box-shadow: 12px 0 32px rgba(0, 0, 0, .25); }
-- .ovk-sidebar.is-open { translate: 0 0; visibility: visible; transition: translate .25s ease; }
++ .ovk-sidebar { position: fixed; z-index: 10060; top: 0; left: 0; bottom: 0; height: auto; width: min(320px, 86vw); padding-top: max(12px, env(safe-area-inset-top, 0px)); padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)); border-right: 0; translate: calc(-100% * var(--ov-move, 1)) 0; opacity: 0; visibility: hidden; transition: translate .25s ease, opacity .2s ease, visibility 0s linear .25s; box-shadow: 12px 0 32px rgba(0, 0, 0, .25); }
++ .ovk-sidebar.is-open { translate: 0 0; opacity: 1; visibility: visible; transition: translate .25s ease, opacity .2s ease; }
.ovk-sidebar-close, .ovk-sidebar-open { display: inline-grid; }
.ovk-sidebar-scrim { position: fixed; inset: 0; z-index: 10050; background: rgba(0, 0, 0, .4); }
}
--@media (prefers-reduced-motion: reduce) { .ovk-sidebar, .ovk-sidebar.is-open { transition: none; } .ovk-sidebar-dot.is-busy { animation: none; } }
/* ── Scroll areas (4.69.0; the owner, 2026-11-01: "you can't scroll down the page even when you're at the bottom of
the chat… this should be consistent across websites"). The network's rule: a scroll area inside a page that scrolls
@@@ -412,16 +523,14 @@ div.ovk-set-row { cursor: default; }
.ovk-skel-dot { flex: none; width: 22px; height: 22px; border-radius: 50%; }
.ovk-skel-stat { display: grid; gap: 6px; padding: 12px; border-radius: var(--ov-radius-2); border: 1px solid var(--border, rgba(255,255,255,.08)); }
.ovk-skel-num { height: 22px; width: 48%; }
--@media (prefers-reduced-motion: reduce) { .ovk-skel { animation: none; } }
--html[data-ov-motion=reduced] .ovk-skel { animation: none; }
/* ── Moving between pages (4.70.0, plan L52) ── A server-rendered site's pages cross-fade while the navbar and the app bar
-- stay put (cross-document view transitions; a browser without them just loads the page). Off under reduced motion. */
++ stay put (cross-document view transitions; a browser without them just loads the page). A cross-fade moves nothing,
++ so Calm keeps it; with motion off the loader gives it no length. */
@view-transition { navigation: auto; }
::view-transition-old(root), ::view-transition-new(root) { animation-duration: .18s; }
#navbar-mount, .openvibe-navbar { view-transition-name: ov-navbar; }
.ovnav-appbar { view-transition-name: ov-appbar; }
--@media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }
/* Content labels (L1): quiet chips beside a title, the adult one in the warning colour. They say what a thing carries;
each viewer's own choice (ov-labels.js) decides whether it shows, blurs or hides. */
@@@ -429,3 +538,43 @@ html[data-ov-motion=reduced] .ovk-skel { animation: none; }
.ovk-label { display: inline-flex; align-items: center; min-height: 20px; padding: 0 8px; border-radius: 999px; font-size: .7rem; font-weight: 700; letter-spacing: .02em;
color: var(--text-secondary, #a8b3c7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); border: 1px solid var(--border, rgba(255,255,255,.1)); }
.ovk-label--adult { color: var(--warning-text, #fbbf24); border-color: color-mix(in srgb, var(--warning-text, #fbbf24) 45%, transparent); background: color-mix(in srgb, var(--warning-text, #fbbf24) 9%, transparent); }
++
++/* "Ask your agent" (4.134.0): the chip that hands this page's question to your Vibe in the Dock. */
++.ovk-ask { border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, #2a3345)); }
++.ovk-ask .ovk-i { color: var(--accent-light, var(--accent, #60a5fa)); }
++.ovk-ask:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
++
++/* A phone's drawer holds the page still: the page cannot scroll behind it, the drawer scrolls on its own and never hands
++ the scroll on to the page, and the scrim takes no scroll gestures (11-09). */
++@media (max-width: 760px) {
++ html.ovk-drawer-open, html.ovk-drawer-open body { overflow: hidden; overscroll-behavior: none; }
++ .ovk-sidebar { overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
++ .ovk-sidebar-scrim { touch-action: none; overscroll-behavior: contain; }
++}
++/* A page's head (kit.pageHead; designed 11-09, the owner: "less websitey… modern… an icon system"): a small named way
++ back, the page's icon tile and name with its actions, and one line about it, whole (one copy: readers and search see
++ the same words; nothing behind an "About this"). */
++.ovk-page-head { margin: 0 0 20px; min-width: 0; }
++.ovk-page-back { position: relative; display: inline-flex; align-items: center; gap: 2px; height: 32px; margin: 0 0 12px; padding: 0 12px 0 8px;
++ border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent); color: var(--text-secondary, #a8b3c4);
++ font-size: 13.5px; font-weight: 650; text-decoration: none; transition: background .15s, color .15s; }
++.ovk-page-back::after { content: ""; position: absolute; inset: -6px -4px; } /* 44 px under the finger */
++.ovk-page-back .ovk-i { width: 18px; height: 18px; }
++@media (hover: hover) { .ovk-page-back:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 11%, transparent); color: var(--text-primary, #e8ecf3); } }
++/* The icon and the actions sit on the title's FIRST line (centred on it), so a title that wraps to three lines (a home
++ page's hero, Space 11-09) keeps them beside its start instead of floating in the middle. --ovk-page-line is the
++ title's line (1.15 × its size, in rem and vw so it means the same on every child). */
++.ovk-page-head-row { --ovk-page-line: calc(1.15 * clamp(1.45rem, 2.4vw + .7rem, 2.2rem)); display: flex; align-items: flex-start; gap: 12px; min-width: 0; }
++.ovk-page-head-row > .ovk-heading-icon, .ovk-page-head-row > .ovk-page-actions { margin-top: calc(var(--ovk-page-line) / 2 - 22px); }
++.ovk-page-head-row > .ovk-heading-icon { flex-basis: 44px; width: 44px; height: 44px; border-radius: 13px;
++ background: linear-gradient(150deg, color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent), color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent));
++ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); }
++.ovk-page-head-row > .ovk-heading-icon > .ovk-i { width: 24px; height: 24px; }
++.ovk-page-head-row h1 { flex: 1 1 auto; min-width: 0; margin: 0; font-size: clamp(1.45rem, 2.4vw + .7rem, 2.2rem); line-height: 1.15; letter-spacing: -.015em; overflow-wrap: anywhere; }
++.ovk-page-actions { display: flex; align-items: center; gap: 6px; flex: none; margin-left: auto; }
++.ovk-page-actions .ovk-icon-btn { width: 44px; height: 44px; border-radius: 50%; }
++.ovk-page-lede { max-width: 68ch; margin: 10px 0 0; color: var(--text-secondary, #a8b3c4); font-size: 15.5px; line-height: 1.5; }
++.ovk-page-back:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 3px; }
++@media (max-width: 760px) { .ovk-page-head { margin-bottom: 16px; } .ovk-page-lede { margin-top: 8px; font-size: 14.5px; } }
++/* A phone hero's 1fr track shrinks around the head. */
++.sc-hero-main:has(>.ovk-page-head),.sc-room-main:has(>.ovk-page-head){min-width:0}
ddiff --git a/browser/shared/notification-ui.js b/browser/shared/notification-ui.js
iindex 99fdf4f..70d8e27 100644
---- a/browser/shared/notification-ui.js
++++ b/browser/shared/notification-ui.js
@@@ -49,7 +49,10 @@
stream: 'Live', social: 'Social', chat: 'Chat', game: 'Games', economy: 'Money & deals',
achievement: 'Achievements', service: 'Services', moderation: 'Moderation', system: 'System', admin: 'Announcements',
};
-- const SERVICE_LABELS = { live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network', host: 'Host', deals: 'Deals', coupons: 'Coupons', trade: 'Trade', chat: 'Chat' };
++ const SERVICE_LABELS = {
++ live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network', host: 'Host', deals: 'Deals', coupons: 'Coupons', trade: 'Trade', chat: 'Chat',
++ actor: 'Agents', watch: 'Watch', connect: 'Connect', link: 'Link', community: 'Community', billing: 'Wallet', blog: 'Blog', design: 'Design', ai: 'AI', events: 'Events',
++ };
// Line glyphs (24×24, stroked like the open-loop mark): one per kind of notice.
const GLYPH = {
@@@ -70,7 +73,12 @@
bellSolid: '<path d="M5.8 16.2V11a6.2 6.2 0 0 1 12.4 0v5.2l1.7 2.3H4.1ZM9.9 19.6h4.2a2.1 2.1 0 0 1-4.2 0Z"/>',
key: '<circle cx="8.2" cy="14.8" r="4"/><path d="M11.1 12l8.2-8.2M16.4 6.7l2.3 2.3M14.2 8.9l1.8 1.8"/>',
spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',
++ agent: '<rect x="5" y="8.2" width="14" height="10.8" rx="3.6"/><path d="M12 8.2V5.4M9.4 13.4h.01M14.6 13.4h.01M3 12.6v2.6M21 12.6v2.6"/><circle cx="12" cy="4.4" r="1"/>',
++ eye: '<path d="M2.8 12s3.4-6.2 9.2-6.2 9.2 6.2 9.2 6.2-3.4 6.2-9.2 6.2S2.8 12 2.8 12z"/><circle cx="12" cy="12" r="2.8"/>',
++ screen: '<rect x="3.5" y="4.5" width="17" height="11.5" rx="2.2"/><path d="M9 20h6M12 16v4"/>',
};
++ const SERVICE_GLYPH = { actor: 'agent', watch: 'eye', connect: 'screen', link: 'screen', billing: 'coin' };
++ const TYPE_GLYPH = { WELCOME: 'spark', AGENT_TASK_DONE: 'agent', AGENT_TASK_FAILED: 'agent', AGENT_NEEDS_YOU: 'agent' };
const CATEGORY_GLYPH = { stream: 'live', social: 'people', chat: 'chat', game: 'game', economy: 'coin', achievement: 'trophy', moderation: 'shield', system: 'gear', service: 'grid', admin: 'bell' };
const TONE = {
live: 'var(--live-red, #e5484d)', danger: 'var(--live-red, #e5484d)', warn: 'var(--warning, #f5a524)', ok: 'var(--success, #30a46c)',
@@@ -92,6 +100,7 @@
let _pollTimer = null;
let _lastSeenAt = null; // newest created_at we have toasted / seen
let _unreadCount = 0;
++ let _msgs = 0; // new messages (navbar.js setMessages): on the bell's badge with the notifications, never in the list's own count
let _toastContainer = null;
let _panelEl = null;
let _bellEl = null;
@@@ -196,6 +205,7 @@
.openvibe-notif-dock { display: flex; flex-direction: column; min-height: 0; flex: 1; color: var(--text-primary, #ececf1); }
.openvibe-notif-dock .openvibe-nv-head { display: none; }
.openvibe-notif-dock .openvibe-nv-tools { padding-top: 10px; }
++.openvibe-notif-dock.is-preview .openvibe-nv-tools, .openvibe-notif-dock.is-preview .openvibe-nv-foot, .openvibe-notif-dock.is-preview .openvibe-nv-head, .openvibe-notif-dock.is-preview .openvibe-nv-more { display: none; }
.openvibe-notif-dock .openvibe-nv-foot { gap: 12px; }
.openvibe-notif-dock .openvibe-nv-foot [data-role="status"] { margin-right: auto; }
.ovn-foot-read { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0; border: 0; background: none; font: inherit; font-weight: 700; color: var(--text-secondary, #b6b6c0); cursor: pointer; }
@@@ -231,6 +241,7 @@
.openvibe-nv-unread svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.openvibe-nv-tabs { display: flex; overflow-x: auto; scrollbar-width: none; margin: 0 -12px; padding: 0 6px; }
.openvibe-nv-tabs::-webkit-scrollbar { display: none; }
++ .openvibe-nv-tabs.is-more { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 40px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 40px), transparent); }
.openvibe-nv-tabs button { flex: none; position: relative; display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 10px; border: 0; background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; }
.openvibe-nv-tabs button:hover, .openvibe-nv-tabs button[aria-selected="true"] { color: var(--text-primary, #ececf1); }
.openvibe-nv-tabs button[aria-selected="true"]::after { content: ''; position: absolute; left: 10px; right: 10px; bottom: 0; height: 2.5px; border-radius: 2px 2px 0 0; background: var(--accent, #3b82f6); }
@@@ -304,12 +315,7 @@
}
html.ovn-sheet-open, html.ovn-sheet-open body { overflow: hidden; }
@media (max-height: 520px) { .openvibe-notif-item .msg { -webkit-line-clamp: 1; } }
-- @media (prefers-reduced-motion: reduce) {
-- .openvibe-toast, .ovn-phone .openvibe-toast, .openvibe-notif-panel { animation: none; }
-- .openvibe-toast.removing { animation: none; opacity: 0; }
-- .openvibe-bell.ringing svg, .openvibe-bell .badge { animation: none; }
-- .openvibe-toast-progress { display: none; }
-- }
++ html[data-ov-motion=off] .openvibe-toast.removing{opacity: 0}html[data-ov-motion=off] .openvibe-toast-progress{display: none}
`;
document.head.appendChild(style);
}
@@@ -407,17 +413,54 @@
label: 'System', url: rich.runbook_url || urlOf(n), actionLabel: 'Open status',
};
}
++ // JSON in a message (a watch sends a feed entry, cut short): its title and summary, never code.
++ function readableMessage(raw) {
++ const s = String(raw || '').trim();
++ if (!/^[{[]\s*"/.test(s)) return s;
++ const field = (name) => {
++ const m = new RegExp(`"${name}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`).exec(s);
++ if (!m) return '';
++ try { return JSON.parse(`"${m[1].replace(/\\$/, '')}"`); } catch { return m[1]; }
++ };
++ const title = field('title') || field('name');
++ const summary = field('summary') || field('description');
++ return [title, summary].filter(Boolean).join(' — ').replace(/\s+/g, ' ').trim();
++ }
++ const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
++ // What kind of step it is, as a label (a noun, so it never reads as a button).
++ const ASK_CHIP = { approve: 'Approval', confirm: 'Approval', answer: 'Question', choose: 'Choice', pick: 'Choice', review: 'Review', 'sign in': 'Sign-in', 'log in': 'Sign-in' };
++ // An agent's notice: the task, not its wording ("Done: x" → x; "approve: step (task)" → step + chip).
++ function agentText(n, rich) {
++ const t = String(n.type || '');
++ let msg = String(n.message || '').trim();
++ const chips = [];
++ if (t === 'AGENT_TASK_DONE') msg = rich.task_title || msg.replace(/^Done:\s*/i, '');
++ else if (t === 'AGENT_TASK_FAILED') msg = rich.task_title ? `${rich.task_title}${rich.detail ? ` — ${rich.detail}` : ''}` : msg.replace(/^Not finished:\s*/i, '');
++ else if (t === 'AGENT_NEEDS_YOU') {
++ if (rich.step) msg = String(rich.step);
++ else if (/\)$/.test(msg) && msg.lastIndexOf(' (') > 0) msg = msg.slice(0, msg.lastIndexOf(' ('));
++ const m = /^(approve|answer|confirm|choose|pick|review|sign in|log in)\s*:\s*/i.exec(msg);
++ const k = (m ? m[1] : String(rich.kind || '')).toLowerCase();
++ if (m) msg = msg.slice(m[0].length);
++ if (ASK_CHIP[k]) chips.push(ASK_CHIP[k]);
++ }
++ return { message: cap(msg.trim()), chips };
++ }
function present(n) {
const rich = richOf(n);
if (isOpsAlert(n, rich)) return presentOps(n, rich);
const cat = n.category || 'system';
const t = String(n.type || '');
-- const glyph = /DEAL|COUPON|PRICE|TRADE/.test(t) ? 'tag' : /CONFIRM|APPROV|SECUR|SIGN/.test(t) ? 'key' : (CATEGORY_GLYPH[cat] || 'bell');
-- const tone = n.priority === 'critical' ? 'danger' : (CATEGORY_TONE[cat] || 'accent');
-- const chips = Array.isArray(rich.chips) ? rich.chips.filter(c => typeof c === 'string').slice(0, 4) : [];
++ const agent = /^AGENT_/.test(t);
++ const glyph = /DEAL|COUPON|PRICE|TRADE/.test(t) ? 'tag' : /CONFIRM|APPROV|SECUR|SIGN/.test(t) ? 'key'
++ : (TYPE_GLYPH[t] || SERVICE_GLYPH[n.service] || CATEGORY_GLYPH[cat] || 'bell');
++ const tone = n.priority === 'critical' ? 'danger'
++ : t === 'AGENT_TASK_DONE' ? 'ok' : t === 'AGENT_TASK_FAILED' ? 'warn' : (CATEGORY_TONE[cat] || 'accent');
++ const said = agent ? agentText(n, rich) : { message: readableMessage(n.message), chips: [] };
++ const chips = said.chips.concat(Array.isArray(rich.chips) ? rich.chips.filter(c => typeof c === 'string') : []).slice(0, 4);
return {
-- kind: 'plain', key: [t, n.title || '', n.service || '', cat].join('\u0001'), tone, glyph,
-- title: String(n.title || ''), message: String(n.message || ''), chips, severity: null, command: null,
++ kind: 'plain', key: [t, n.title || '', n.service || '', cat, rich.task_id || ''].join('\u0001'), tone, glyph,
++ title: String(n.title || ''), message: said.message, chips, severity: null, command: null,
label: SERVICE_LABELS[n.service] || (n.service && n.service !== 'network' ? n.service : '') || CATEGORY_LABELS[cat] || '',
url: urlOf(n), actionLabel: null,
};
@@@ -509,6 +552,9 @@
if (msg.type === 'unread') setBadge(msg.count, { quiet: true });
else if (msg.type === 'read') { for (const v of _views.values()) applyReadLocally(v, msg.ids || [], msg.all); }
}
++ function announceCount() {
++ try { document.dispatchEvent(new CustomEvent('openvibe-notifications-count', { detail: { count: _unreadCount } })); } catch { /* */ }
++ }
// ── Sound ───────────────────────────────────────────────
function playSound(priority) {
@@@ -784,33 +830,43 @@
}
// ── Bell / badge ────────────────────────────────────────
++ let _bellAction = null;
function createBellEl() {
const bell = document.createElement('div');
bell.className = 'openvibe-bell';
bell.setAttribute('role', 'button'); bell.setAttribute('aria-label', 'Notifications'); bell.tabIndex = 0;
bell.innerHTML = `<svg class="ovnav-ovg has-s" viewBox="0 0 24 24" aria-hidden="true">${GLYPH.bell}<g class="ovg-s">${GLYPH.bellSolid}</g></svg><span class="badge hidden">0</span>`;
-- bell.addEventListener('click', () => togglePanel());
-- bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } });
++ // With the Dock on the page, its Activity tab is this list: the bell opens it there (4.150.0, the owner: the bell
++ // "doesn't actually open the notifications … you have to open the chat button and click this manually"); the
++ // panel opened under the Dock's sheet, so the tap seemed to do nothing.
++ const press = () => { if (_bellAction) { try { if (_bellAction() === true) return; } catch { /* the panel then */ } } togglePanel(); };
++ bell.addEventListener('click', press);
++ bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); press(); } });
_bellEl = bell;
return bell;
}
++ // The bell's own number: notifications and new messages together, each named in its label.
++ function paintBell(ring) {
++ if (!_bellEl) return;
++ const badge = _bellEl.querySelector('.badge'), n = _unreadCount + _msgs;
++ if (n > 0) {
++ badge.textContent = n > 99 ? '99+' : String(n);
++ badge.classList.remove('hidden');
++ if (ring) { _bellEl.classList.add('ringing'); setTimeout(() => _bellEl.classList.remove('ringing'), 700); }
++ } else badge.classList.add('hidden');
++ const parts = [_unreadCount ? `${_unreadCount} unread` : '', _msgs ? `${_msgs} new ${_msgs === 1 ? 'message' : 'messages'}` : ''].filter(Boolean);
++ _bellEl.setAttribute('aria-label', parts.length ? `Notifications, ${parts.join(', ')}` : 'Notifications');
++ }
function setBadge(count, { quiet = false } = {}) {
const prev = _unreadCount;
_unreadCount = Math.max(0, Number(count) || 0);
-- if (_bellEl) {
-- const badge = _bellEl.querySelector('.badge');
-- if (_unreadCount > 0) {
-- badge.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount);
-- badge.classList.remove('hidden');
-- if (!quiet && _unreadCount > prev) { _bellEl.classList.add('ringing'); setTimeout(() => _bellEl.classList.remove('ringing'), 700); }
-- } else badge.classList.add('hidden');
-- _bellEl.setAttribute('aria-label', _unreadCount ? `Notifications, ${_unreadCount} unread` : 'Notifications');
-- }
++ paintBell(!quiet && _unreadCount > prev);
for (const v of _views.values()) {
const c = v.root.querySelector('.openvibe-nv-head .count');
if (c) { c.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount); c.classList.toggle('zero', _unreadCount === 0); }
}
if (!quiet) broadcast({ type: 'unread', count: _unreadCount });
++ announceCount();
if (_unreadCount !== prev && listShowing()) loadTabCounts();
}
@@@ -872,6 +928,14 @@
};
tabs.forEach((btn) => btn.addEventListener('click', () => selectTab(btn)));
const tabRow = root.querySelector('.openvibe-nv-tabs');
++ // More filters past the edge: a fade says the row scrolls (4.150.0; "System" was cut off with no sign of more).
++ if (tabRow && !tabRow.__more) {
++ tabRow.__more = true;
++ const more = () => tabRow.classList.toggle('is-more', tabRow.scrollWidth - tabRow.clientWidth - tabRow.scrollLeft > 4);
++ tabRow.addEventListener('scroll', more, { passive: true });
++ if (typeof ResizeObserver === 'function') new ResizeObserver(more).observe(tabRow);
++ setTimeout(more, 0);
++ }
const fade = () => { tabRow.classList.toggle('is-more', tabRow.scrollLeft + tabRow.clientWidth < tabRow.scrollWidth - 4); };
tabRow.addEventListener('scroll', fade, { passive: true });
v.fade = fade;
@@@ -924,6 +988,7 @@
}, true);
attachRowSwipe(v, list);
refreshPushButton(v);
++ v.destroy = () => { clearTimeout(qTimer); v.seq++; };
_views.set(root, v);
paintTabCounts();
return v;
@@@ -1038,7 +1103,7 @@
}
async function fetchPage(v, offset) {
-- const params = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String(offset) });
++ const params = new URLSearchParams({ limit: String(v.pageSize || PAGE_SIZE), offset: String(offset) });
if (v.unread) params.set('unread_only', '1');
if (v.tab && v.tab !== 'all') params.set('tab', v.tab);
if (v.q) params.set('q', v.q);
@@@ -1056,6 +1121,7 @@
if (seq !== v.seq) return;
v.items = data.notifications || []; v.items.forEach(n => v.ids.add(String(n.id)));
v.hasMore = !!data.has_more; v.total = data.total ?? null; v.offset = v.items.length;
++ if (typeof v.onCount === 'function') { try { v.onCount(v.total); } catch { /* */ } }
renderItems(v);
} catch (e) {
if (seq !== v.seq) return;
@@@ -1190,6 +1256,8 @@
}
function stopPolling() { if (_pollTimer) clearInterval(_pollTimer); _pollTimer = null; }
++ // notification-live.js's content hash (scripts/sibling-revs.js writes it), so a release gives it a new URL.
++ const NOTIFY_REV = { 'notification-live.js': 'cd2d64cc77d0' };
// ── Realtime (notification-live.js) ─────────────────────
function liveOpen() { return !!(_live && _live.state().state === 'open'); }
/** One re-read for a burst of events (a go-live fan-out, a replay after a reconnect); a gap re-reads open lists too. */
@@@ -1207,7 +1275,7 @@
function liveSrc() {
if (_config.liveSrc) return _config.liveSrc;
const m = /^(https?:\/\/[^?#]*\/)notification-ui\.js(?:[?#]|$)/.exec(SELF_SRC);
-- return `${m ? m[1] : 'https://openvibe.network/shared/'}notification-live.js`;
++ return `${m ? m[1] : 'https://openvibe.network/shared/'}notification-live.js${NOTIFY_REV['notification-live.js'] ? `?v=${NOTIFY_REV['notification-live.js']}` : ''}`;
}
let _liveLoading = null;
function loadLive() {
@@@ -1386,6 +1454,12 @@
_toastContainer?.remove(); _panelEl?.remove();
_toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null; _queue.length = 0;
},
++ /** What a tap on the bell does instead of its own panel: fn() returns true when it handled it (navbar.js: the Dock). */
++ setBellAction(fn) { _bellAction = typeof fn === 'function' ? fn : null; },
++ /** New messages, counted on the bell with the notifications (navbar.js): 0 takes them off. */
++ setMessages(count) { const prev = _msgs; _msgs = Math.max(0, Number(count) || 0); paintBell(_msgs > prev); },
++ /** How many notifications are unread (not counting messages). */
++ unread() { return _unreadCount; },
/** Create and inject the bell icon. Returns the DOM element. */
createBell(container) {
injectStyles();
@@@ -1416,6 +1490,23 @@
reload(v);
return v;
},
++ /** The You tab's preview (4.211.0): the newest three, no tools; Show all opens the whole list in place. */
++ renderMenuNotifications(container, onCount) {
++ injectStyles();
++ if (!container) return null;
++ container.classList.add('openvibe-notif-dock', 'is-preview');
++ const v = buildView(container, { mode: 'dock' });
++ v.pageSize = 3; v.onCount = onCount;
++ reload(v);
++ return { showAll() { container.classList.remove('is-preview'); v.pageSize = PAGE_SIZE; reload(v); } };
++ },
++ unmountDock(container) {
++ const v = _views.get(container);
++ if (!v || v.mode !== 'dock') return;
++ if (v.destroy) v.destroy();
++ _views.delete(container);
++ container.classList.remove('openvibe-notif-dock');
++ },
/** Programmatically show a toast (real-time events). Marks nothing on the server. */
push(notification) {
showToast(notification);
ddiff --git a/browser/shared/ov-composer.js b/browser/shared/ov-composer.js
nnew file mode 100644
iindex 0000000..96e3abb
---- /dev/null
++++ b/browser/shared/ov-composer.js
@@@ -0,0 +1,605 @@
++/**
++ * ov-composer.js (openvibe-shared 4.201.0): one composer on every site. Ask 349, the owner: "how come on this composer
++ * there's no file attaching system and all the other features … a better composer system unified across all sites …
++ * moddable and fully customizable". ov-thread.js loads it for each thread.composer() form, and a site's own box joins
++ * with OpenVibeComposer.enhance(form). The form's markup and its submit stay the site's; this adds:
++ * - files: the + menu, a paste, a drop; each one a chip above the box (a picture's thumbnail, the name and size, ×).
++ * A long paste becomes a text file, which you can put back in the box. Files go with the form (multipart); where a
++ * mod gives upload(file, onProgress), they go ahead of it, the chip shows the progress, and the form carries the ids;
++ * - "/" for commands (the site's, its mods', your own snippets) and "@" for mentions (the mods' sources);
++ * - voice, where the browser can listen; a draft kept per box until it is sent; Ctrl/⌘+B, I, E for bold, italic and
++ * code; the count near the box's limit;
++ * - your settings (what sends, voice, compact, your snippets), from "/settings" or the slash list's last row.
++ *
++ * Mods (a site, or a script on it): OpenVibeComposer.mod({
++ * id, match(form) → bool, which boxes it joins (all when absent)
++ * tools: [{ id, label, path (24-unit path data), run(ctx) }],
++ * commands: [{ name, hint, insert } | { name, hint, run(ctx) }],
++ * mentions: { search(q) → Promise<[{ label, handle, sub, avatar, insert }]> },
++ * upload(file, onProgress) → Promise<{ id }>, files go to the site's store before the send
++ * beforeSend(ctx) → false to hold the send })
++ * ctx: { form, text, value, set(v), insert(s), files(), add(files), note(s), send() }.
++ * A box its page sends by script (a chat, an agent's run): form.__ovc.files() → the files (File objects), uploads(): the
++ * ids a mod's upload gave, clear() after the send (the text, the chips, the draft); a form 'reset' clears the same.
++ * A site without script says the same in data: data-ovt-commands='[{"name","hint","insert"}]', data-ovc-max-files,
++ * data-ovc-max-mb, data-ovc-no-draft.
++ */
++(function (root) {
++ 'use strict';
++ if (root.OpenVibeComposer || typeof document === 'undefined') return;
++
++ // 4.203.0: the composer carries its own look (it was in thread.css), so any box on any page gets it: the Dock's
++ // Vibe and Code boxes included. Injected once, before the first box is joined.
++ const CSS = `.ovt-composer { position: relative; display: grid; gap: 6px; max-width: 780px; margin: 0 auto; padding: 10px 12px; border: 1px solid var(--border, rgba(148,163,184,.18)); border-radius: 18px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); }
++.ovt-composer textarea { width: 100%; min-height: 48px; box-sizing: border-box; padding: 6px 2px; border: 0; outline: 0; resize: vertical; background: transparent; color: inherit; font: inherit; }
++.ovt-c-row { display: flex; align-items: center; gap: 8px; }
++.ovt-c-tools { flex: 1; display: flex; align-items: center; gap: 6px; }
++.ovt-send { min-height: 40px; padding: 0 14px; border: 0; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: inherit; font-weight: 650; cursor: pointer; }
++.ovt-send:disabled { opacity: .5; }
++.ovt-composer .ovc-file.ovc-image { height: auto; min-height: 100px; flex-wrap: wrap; overflow: visible; }
++.ovt-composer .ovc-image input { flex: 1 0 100%; min-width: 0; min-height: 36px; box-sizing: border-box; border: 1px solid var(--border, rgba(148,163,184,.18)); border-radius: 8px; background: var(--bg-primary, #0b1220); color: inherit; font: inherit; }
++.ovc-files { display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 8px; margin: 0 0 2px -4px; scrollbar-width: thin; }
++.ovc-files[hidden] { display: none; }
++.ovc-file { position: relative; flex: none; display: flex; align-items: center; gap: 9px; max-width: 230px; height: 52px; padding: 5px 30px 5px 5px; box-sizing: border-box; border-radius: 14px;
++ border: 1px solid var(--ovt-line, var(--border, rgba(148, 163, 184, .18))); background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); overflow: hidden; animation: ovc-in .2s cubic-bezier(.2, .9, .3, 1) both; }
++.ovc-file.is-bad { border-color: color-mix(in srgb, var(--live-red, #ef4444) 70%, transparent); }
++.ovc-file.is-up::after { content: ""; position: absolute; left: 0; bottom: 0; height: 3px; width: calc(var(--p, 0) * 100%); background: var(--ovt-accent, var(--accent, #3b82f6)); transition: width .2s; }
++.ovc-file-th { flex: none; width: 40px; height: 40px; border-radius: 10px; object-fit: cover; background: #0b1220; }
++.ovc-file-ic { display: grid; place-items: center; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 16%, transparent); color: var(--ovt-accent, var(--accent, #3b82f6)); font-size: 11px; font-weight: 750; letter-spacing: .02em; }
++.ovc-file-t { min-width: 0; display: grid; line-height: 1.25; }
++.ovc-file-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; font-weight: 600; color: var(--text-primary, #e6edf7); }
++.ovc-file-s { font-size: 12px; color: var(--text-muted, #7386a3); white-space: nowrap; }
++.ovc-file.is-bad .ovc-file-s { color: var(--live-red, #ef4444); }
++.ovc-file-x, .ovc-file-b { position: absolute; top: 4px; right: 4px; display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; }
++.ovc-file-b { top: auto; bottom: 4px; }
++.ovc-file-x:hover, .ovc-file-b:hover, .ovc-file-x:focus-visible, .ovc-file-b:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 10%, transparent)); color: var(--text-primary, #e6edf7); }
++.ovc-file-x svg, .ovc-file-b svg { width: 15px; height: 15px; }
++.ovc-dropping { border-color: var(--ovt-accent, var(--accent, #3b82f6)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 30%, transparent), 0 8px 30px rgba(0, 0, 0, .25); }
++.ovc-dropping::before { content: "Drop to attach"; position: absolute; inset: 0; z-index: 5; display: grid; place-items: center; border-radius: inherit; background: color-mix(in srgb, var(--bg-card, #131c2e) 86%, var(--ovt-accent, var(--accent, #3b82f6))); color: var(--text-primary, #e6edf7); font-weight: 650; pointer-events: none; }
++.ovc-pop { position: absolute; z-index: 40; left: 6px; right: 6px; bottom: calc(100% + 8px); max-height: min(320px, 50vh); overflow: auto; padding: 6px; border-radius: 16px; border: 1px solid var(--ovt-line, var(--border, rgba(148, 163, 184, .18)));
++ background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); animation: ovc-in .16s cubic-bezier(.2, .9, .3, 1) both; }
++.ovt.is-start .ovc-pop { bottom: auto; top: calc(100% + 8px); }
++.ovc-pop[hidden] { display: none; }
++.ovc-pop-i { display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 6px 10px; box-sizing: border-box; border: 0; border-radius: 11px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
++.ovc-pop-i[aria-selected="true"], .ovc-pop-i:hover { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); }
++.ovc-pop-ic, .ovc-pop-av { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; color: var(--ovt-accent, var(--accent, #3b82f6)); background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 12%, transparent); object-fit: cover; }
++.ovc-pop-av { border-radius: 50%; }
++.ovc-pop-ic svg { width: 17px; height: 17px; }
++.ovc-pop-t { min-width: 0; display: grid; line-height: 1.3; }
++.ovc-pop-t b { font-weight: 650; font-size: 14px; color: var(--text-primary, #e6edf7); }
++.ovc-pop-t span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text-muted, #7386a3); }
++.ovc-pop-tag { font-size: 11px; font-weight: 650; padding: 2px 7px; border-radius: 999px; color: var(--ovt-accent, var(--accent, #3b82f6)); background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 12%, transparent); }
++.ovc-mic[aria-pressed="true"] { color: var(--live-red, #ef4444); background: color-mix(in srgb, var(--live-red, #ef4444) 14%, transparent); animation: ovc-listen 1.4s ease-in-out infinite; }
++.ovc-count { flex: none; font-size: 12px; font-variant-numeric: tabular-nums; color: var(--text-muted, #7386a3); }
++.ovc-count.is-low { color: var(--live-red, #ef4444); }
++.ovc-count[hidden] { display: none; }
++.ovt-composer.ovc-compact { padding: 6px 8px 6px 12px; border-radius: 18px; }
++.ovt-composer.ovc-compact .ovt-c-row { margin-top: 0; }
++.ovt-composer.ovc-compact .ovt-btn, .ovt-composer.ovc-compact .ovt-send { width: 36px; min-width: 36px; height: 36px; }
++@keyframes ovc-in { from { opacity: 0; transform: translateY(4px) scale(.98); } }
++@keyframes ovc-listen { 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--live-red, #ef4444) 18%, transparent); } }
++html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data-ov-motion=off] .ovc-mic { animation: none; }
++.ovc-set { width: min(460px, calc(100vw - 24px)); max-height: min(640px, calc(100vh - 32px)); padding: 0; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .2)); border-radius: 20px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); box-shadow: 0 30px 80px rgba(0, 0, 0, .5); }
++.ovc-set::backdrop { background: rgba(2, 6, 14, .55); }
++.ovc-set.ovc-set-in { width: auto; max-height: none; border: 0; border-radius: 0; background: none; box-shadow: none; }
++.ovc-set-in .ovc-set-f { padding: 0; }
++.ovc-set-f { display: grid; gap: 14px; padding: 6px 18px 18px; }
++.ovc-set-h { display: flex; align-items: center; justify-content: space-between; margin: 0 -8px 0 0; font-size: 17px; }
++.ovc-set-g { margin: 0; padding: 0; border: 0; display: grid; gap: 6px; }
++.ovc-set-g legend { padding: 0 0 6px; font-weight: 650; font-size: 14px; }
++.ovc-set label { display: flex; align-items: center; gap: 10px; min-height: 40px; font-size: 14.5px; cursor: pointer; }
++.ovc-set small, .ovc-set-p { color: var(--text-muted, #7386a3); font-size: 12.5px; margin: 0; }
++.ovc-set input[type=radio], .ovc-set input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--accent, #3b82f6); }
++.ovc-set-l { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
++.ovc-set-l li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; padding: 4px 4px 4px 10px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); }
++.ovc-set-l span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary, #b4c0d4); font-size: 13px; }
++.ovc-set-add { display: grid; grid-template-columns: 110px 1fr auto; gap: 8px; align-items: start; }
++.ovc-set-add input, .ovc-set-add textarea { box-sizing: border-box; width: 100%; padding: 9px 10px; border-radius: 10px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .25)); background: var(--bg-input, rgba(2, 6, 14, .35)); color: inherit; font: inherit; font-size: 14px; resize: vertical; }
++.ovc-set-btn { height: 40px; padding: 0 14px; border: 0; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: inherit; font-weight: 650; cursor: pointer; }
++@media (max-width: 480px) { .ovc-set-add { grid-template-columns: 1fr; } .ovc-file { max-width: 200px; } }
++/* 4.205.0: the composer's own tool buttons and row on a page without thread.css (an agent's run, a card, any box), so it
++ is the same composer everywhere. Where thread.css is loaded its rules are the same. */
++.ovc .ovt-btn { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; border: 0; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; text-decoration: none; }
++.ovc .ovt-btn:hover, .ovc .ovt-btn:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 6%, transparent)); color: var(--text-primary, #e6edf7); }
++.ovc .ovt-btn .ovt-ic, .ovc .ovt-btn svg { width: 22px; height: 22px; }
++.ovc .ovt-c-row { display: flex; align-items: center; gap: 6px; }
++.ovc .ovt-c-tools { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
++.ovc .ovt-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
++`;
++ function styles() { if (document.getElementById('ovc-styles')) return; const st = document.createElement('style'); st.id = 'ovc-styles'; st.textContent = CSS; document.head.appendChild(st); }
++ const mods = [];
++ // 4.202.0: your settings open in the one window (ov-window.js: a sheet on a phone, swiped down to close), loaded
++ // from beside this file; without it, a plain <dialog>.
++ const COMPOSER_REV = { 'ov-window.js': 'd3d8f9e3e6d2' };
++ const selfSrc = (document.currentScript && document.currentScript.src) || '';
++ function needWindow() {
++ return new Promise((ok) => {
++ if (root.OVWindow) return ok(root.OVWindow);
++ const m = /^(https?:\/\/[^?#]*\/)ov-composer(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
++ if (!m) return ok(null);
++ const sc = document.createElement('script');
++ sc.src = m[1] + 'ov-window.js' + (COMPOSER_REV['ov-window.js'] ? '?v=' + COMPOSER_REV['ov-window.js'] : '');
++ sc.onload = () => ok(root.OVWindow || null); sc.onerror = () => ok(null);
++ document.head.appendChild(sc);
++ });
++ }
++ const live = new Set();
++ const PREFS_KEY = 'ov:composer';
++ const DEFAULTS = { send_key: 'enter', voice: true, density: 'comfy', snippets: [] };
++ const PATH_OK = /^[MmLlHhVvCcSsQqTtAaZz0-9 ,.\-]{1,2000}$/;
++ const Speech = root.SpeechRecognition || root.webkitSpeechRecognition || null;
++ const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');
++ const IC = {
++ mic: 'M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3zM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',
++ x: 'M7 7l10 10M17 7L7 17',
++ file: 'M7 3h7l5 5v12a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM14 3v5h5',
++ slash: 'M15 4L9 20',
++ at: 'M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0zM16 12v1.5a2.5 2.5 0 0 0 5 0V12a9 9 0 1 0-3.5 7.1',
++ gear: 'M12 9a3 3 0 1 1 0 6 3 3 0 0 1 0-6zM12 2.8v2.4M12 18.8v2.4M2.8 12h2.4M18.8 12h2.4M5.5 5.5l1.7 1.7M16.8 16.8l1.7 1.7M5.5 18.5l1.7-1.7M16.8 7.2l1.7-1.7',
++ back: 'M9 14l-5-5 5-5M4 9h11a5 5 0 0 1 0 10h-3',
++ };
++ const svg = (d, cls) => `<svg${cls ? ` class="${cls}"` : ''} viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;
++ const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };
++ const store = {
++ get(k) { try { return JSON.parse(root.localStorage.getItem(k) || 'null'); } catch { return null; } },
++ set(k, v) { try { if (v == null) root.localStorage.removeItem(k); else root.localStorage.setItem(k, JSON.stringify(v)); } catch { /* private mode */ } },
++ };
++ const size = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(n < 10485760 ? 1 : 0)} MB`);
++
++ // ── Your settings: this browser's copy, and your account's when the Network keeps them (the same on every site).
++ let prefs = Object.assign({}, DEFAULTS, store.get(PREFS_KEY) || {});
++ let synced = false, rev = null;
++ const token = () => { try { const n = root.OpenVibeNavbar; return n && typeof n.token === 'function' ? n.token() : null; } catch { return null; } };
++ const NET = 'https://openvibe.network/api/v1/me/composer';
++ async function pull() {
++ const t = token(); if (!t) return;
++ try {
++ const r = await fetch(NET, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' });
++ if (!r.ok) return;
++ const j = await r.json();
++ synced = true; rev = j.rev != null ? j.rev : null;
++ prefs = Object.assign({}, DEFAULTS, j.prefs || j);
++ store.set(PREFS_KEY, prefs);
++ live.forEach((c) => c.refresh());
++ } catch { /* local only */ }
++ }
++ async function push() {
++ const t = token(); if (!t || !synced) return;
++ try {
++ const r = await fetch(NET, { method: 'PUT', headers: { authorization: `Bearer ${t}`, 'content-type': 'application/json', accept: 'application/json' }, credentials: 'omit', body: JSON.stringify({ prefs, rev }) });
++ if (r.status === 409) { await pull(); return; }
++ if (r.ok) { const j = await r.json().catch(() => ({})); if (j.rev != null) rev = j.rev; }
++ } catch { /* kept locally */ }
++ }
++ function setPrefs(next) {
++ prefs = Object.assign({}, DEFAULTS, prefs, next);
++ prefs.snippets = (prefs.snippets || []).filter((s) => s && /^[\w-]{1,30}$/.test(s.trigger || '') && s.text).slice(0, 50);
++ store.set(PREFS_KEY, prefs);
++ live.forEach((c) => c.refresh());
++ push();
++ }
++ root.addEventListener('storage', (e) => { if (e.key === PREFS_KEY) { prefs = Object.assign({}, DEFAULTS, store.get(PREFS_KEY) || {}); live.forEach((c) => c.refresh()); } });
++
++ // ── One box.
++ function enhance(form) {
++ if (!form || form.__ovc) return form && form.__ovc;
++ styles();
++ const text = form.querySelector('[data-ovt-text]') || form.querySelector('textarea');
++ if (!text) return null;
++ const row = form.querySelector('.ovt-c-row');
++ const tools = form.querySelector('.ovt-c-tools');
++ const note = form.querySelector('[data-ovt-note]');
++ const send = form.querySelector('[data-ovt-send]') || form.querySelector('[type="submit"]');
++ const inputs = () => [...form.querySelectorAll('input[type="file"]')];
++ const mine = () => mods.filter((m) => !m.match || m.match(form));
++ const uploader = () => { const m = mine().find((x) => typeof x.upload === 'function'); return m ? m.upload : null; };
++ // data-ovc-no-files: the box handles its own files (the Dock's Vibe box uploads a picture as it is picked).
++ const noFiles = form.hasAttribute('data-ovc-no-files');
++ const canFile = () => !noFiles && !!(inputs().length || uploader());
++ const maxFiles = Number(form.getAttribute('data-ovc-max-files')) || 10;
++ const maxBytes = (Number(form.getAttribute('data-ovc-max-mb')) || 25) * 1048576;
++ const files = [];
++ let quiet = '';
++ const wasRequired = !!text.required; // a box that was not required stays so (the Dock's Vibe box)
++
++ const say = (s) => { if (!note) return; if (!quiet) quiet = note.textContent; note.textContent = s; clearTimeout(say.t); say.t = setTimeout(() => { note.textContent = quiet; quiet = ''; }, 5000); };
++ const fire = () => text.dispatchEvent(new Event('input', { bubbles: true }));
++ const ctx = {
++ form, text,
++ get value() { return text.value; },
++ set(v) { text.value = v; fire(); },
++ insert(s) { const a = text.selectionStart, b = text.selectionEnd; text.setRangeText(s, a, b, 'end'); fire(); text.focus(); },
++ files: () => files.map((f) => f.file),
++ add: (list) => add(list),
++ note: say,
++ send: () => { if (form.requestSubmit) form.requestSubmit(send || undefined); else form.submit(); },
++ };
++
++ // Files: a strip of chips above the box.
++ const strip = el('div', 'ovc-files');
++ strip.hidden = true;
++ strip.setAttribute('aria-label', 'Attached files');
++ const firstLabel = form.querySelector(`label[for="${text.id}"]`);
++ form.insertBefore(strip, firstLabel || text);
++ const accepts = (file) => {
++ const acc = (inputs()[0] && inputs()[0].getAttribute('accept')) || '';
++ if (!acc) return true;
++ const name = (file.name || '').toLowerCase(), type = (file.type || '').toLowerCase();
++ return acc.split(',').map((x) => x.trim().toLowerCase()).some((a) => !a || (a.startsWith('.') ? name.endsWith(a) : a.endsWith('/*') ? type.startsWith(a.slice(0, -1)) : type === a));
++ };
++ function syncInputs() {
++ if (noFiles) return;
++ if (uploader()) { inputs().forEach((i) => { i.value = ''; }); return; }
++ const [first, ...rest] = inputs();
++ if (!first || typeof DataTransfer === 'undefined') return;
++ const dt = new DataTransfer();
++ files.forEach((f) => dt.items.add(f.file));
++ first.files = dt.files;
++ rest.forEach((i) => { i.value = ''; });
++ }
++ function paintFile(f) {
++ const kind = (f.file.type || '').split('/')[0];
++ const chip = f.chip || el('div', 'ovc-file');
++ f.chip = chip;
++ chip.classList.toggle('is-bad', !!f.error);
++ chip.classList.toggle('is-up', f.progress != null && f.progress < 1 && !f.error);
++ chip.style.setProperty('--p', String(f.progress == null ? 1 : f.progress));
++ const ext = ((f.file.name || '').split('.').pop() || 'file').slice(0, 4).toUpperCase();
++ const thumb = f.url && kind === 'image' ? `<img class="ovc-file-th" src="${f.url}" alt="">` : f.url && kind === 'video' ? `<video class="ovc-file-th" src="${f.url}" muted preload="metadata"></video>` : `<span class="ovc-file-th ovc-file-ic">${f.pasted ? svg(IC.file) : ext}</span>`;
++ chip.innerHTML = `${thumb}<span class="ovc-file-t"><span class="ovc-file-n"></span><span class="ovc-file-s"></span></span>`
++ + (f.pasted ? `<button type="button" class="ovc-file-b" data-ovc-back aria-label="Put the text back in the box" title="Put it back in the box">${svg(IC.back)}</button>` : '')
++ + `<button type="button" class="ovc-file-x" data-ovc-x aria-label="Remove">${svg(IC.x)}</button>`;
++ chip.querySelector('.ovc-file-n').textContent = f.pasted ? 'Pasted text' : f.file.name || 'File';
++ chip.querySelector('.ovc-file-s').textContent = f.error || (f.pasted ? `${f.lines} lines · ${size(f.file.size)}` : f.progress != null && f.progress < 1 ? `${Math.round(f.progress * 100)}%` : size(f.file.size));
++ chip.querySelector('[data-ovc-x]').setAttribute('aria-label', `Remove ${f.pasted ? 'the pasted text' : f.file.name || 'the file'}`);
++ if (!chip.isConnected) strip.appendChild(chip);
++ }
++ function remove(f) {
++ const i = files.indexOf(f); if (i < 0) return;
++ files.splice(i, 1);
++ if (f.url) URL.revokeObjectURL(f.url);
++ if (f.hidden) f.hidden.remove();
++ form.dispatchEvent(new CustomEvent('ovc:file-removed', { detail: { file: f.file, chip: f.chip } }));
++ f.chip.remove();
++ strip.hidden = !files.length;
++ syncInputs();
++ syncSend();
++ }
++ function add(list, pasted) {
++ const up = uploader();
++ for (const file of [...(list || [])]) {
++ if (files.length >= maxFiles) { say(`Up to ${maxFiles} files at a time.`); break; }
++ if (!accepts(file)) { say(`${file.name || 'That file'}: this box does not take that kind of file.`); continue; }
++ if (file.size > maxBytes) { say(`${file.name || 'That file'} is ${size(file.size)}: the limit is ${size(maxBytes)}.`); continue; }
++ const kind = (file.type || '').split('/')[0];
++ const f = { file, url: kind === 'image' || kind === 'video' ? URL.createObjectURL(file) : null, pasted: !!pasted, lines: pasted ? pasted.lines : 0, text: pasted ? pasted.text : null };
++ files.push(f);
++ paintFile(f);
++ form.dispatchEvent(new CustomEvent('ovc:file-added', { detail: { file, chip: f.chip } }));
++ if (up) {
++ f.progress = 0; paintFile(f);
++ Promise.resolve(up(file, (p) => { f.progress = Math.max(0, Math.min(0.99, Number(p) || 0)); paintFile(f); syncSend(); }))
++ .then((r) => {
++ if (!files.includes(f)) return;
++ f.progress = 1; f.id = r && r.id;
++ if (f.id) { f.hidden = el('input'); f.hidden.type = 'hidden'; f.hidden.name = form.getAttribute('data-ovc-ids') || 'attachments'; f.hidden.value = f.id; form.appendChild(f.hidden); }
++ paintFile(f); syncSend();
++ }, (e) => { f.error = (e && e.message) || 'Did not upload'; paintFile(f); syncSend(); });
++ }
++ }
++ strip.hidden = !files.length;
++ syncInputs();
++ syncSend();
++ }
++ strip.addEventListener('click', (e) => {
++ const chip = e.target.closest && e.target.closest('.ovc-file'); if (!chip) return;
++ const f = files.find((x) => x.chip === chip); if (!f) return;
++ if (e.target.closest('[data-ovc-x]')) { remove(f); text.focus(); }
++ else if (e.target.closest('[data-ovc-back]')) { const t = f.text; remove(f); ctx.insert(t); }
++ });
++ form.addEventListener('change', (e) => {
++ const i = e.target; if (noFiles || !i || i.type !== 'file' || i.__ovcSync) return;
++ const picked = [...(i.files || [])];
++ i.__ovcSync = true; i.value = ''; i.__ovcSync = false;
++ add(picked);
++ });
++ text.addEventListener('paste', (e) => {
++ const cd = e.clipboardData; if (!cd || !canFile()) return;
++ const got = [...(cd.files || [])];
++ if (got.length) { e.preventDefault(); add(got); return; }
++ const t = cd.getData('text/plain') || '';
++ const lines = t.split('\n').length;
++ if (t.length > 6000 || lines > 120) {
++ // Only where the box takes a text file (or a mod uploads): a box for pictures keeps the paste as text.
++ const asFile = new File([t], 'pasted-text.txt', { type: 'text/plain' });
++ if (!uploader() && !accepts(asFile)) return;
++ e.preventDefault();
++ add([asFile], { lines, text: t });
++ say('The long paste went in as a file: ↩ on it puts it back in the box.');
++ }
++ });
++ const zone = form.closest('[data-ovt]') || form;
++ let depth = 0;
++ const hasFiles = (e) => e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files');
++ zone.addEventListener('dragenter', (e) => { if (!hasFiles(e) || !canFile()) return; depth++; form.classList.add('ovc-dropping'); });
++ zone.addEventListener('dragleave', () => { if (depth && --depth === 0) form.classList.remove('ovc-dropping'); });
++ zone.addEventListener('dragover', (e) => { if (hasFiles(e) && canFile()) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; } });
++ zone.addEventListener('drop', (e) => { if (!hasFiles(e) || !canFile()) return; e.preventDefault(); depth = 0; form.classList.remove('ovc-dropping'); add(e.dataTransfer.files); text.focus(); });
++
++ // Send waits for words or a file, and for uploads to finish.
++ const busy = () => files.some((f) => f.progress != null && f.progress < 1 && !f.error);
++ function syncSend() {
++ if (!send || form.classList.contains('is-busy')) return;
++ send.disabled = busy() || (!text.value.trim() && !files.length);
++ if (wasRequired && text.required && files.length) text.required = false;
++ else if (wasRequired && !files.length && !text.required && !form.classList.contains('is-busy')) text.required = true;
++ }
++ text.addEventListener('input', syncSend);
++
++ // The slash list and the mention list: one popover above the box.
++ const pop = el('div', 'ovc-pop');
++ pop.setAttribute('role', 'listbox');
++ pop.id = `ovc-pop-${Math.random().toString(36).slice(2, 8)}`;
++ pop.hidden = true;
++ form.appendChild(pop);
++ text.setAttribute('aria-controls', pop.id);
++ let items = [], at = 0, trig = null, seq = 0;
++ function siteCommands() {
++ let list = [];
++ try { list = JSON.parse(form.getAttribute('data-ovt-commands') || '[]'); } catch { /* none */ }
++ return list.filter((c) => c && /^[\w-]{1,30}$/.test(c.name || ''));
++ }
++ function commands() {
++ const own = (prefs.snippets || []).map((s) => ({ name: s.trigger, hint: s.label || s.text.slice(0, 60), insert: s.text, mine: true }));
++ const fromMods = mine().flatMap((m) => m.commands || []);
++ return [...siteCommands(), ...fromMods, ...own, { name: 'settings', hint: 'Your composer: what sends, voice, your own commands', run: () => openSettings() }];
++ }
++ function caretWord() {
++ const upto = text.value.slice(0, text.selectionStart);
++ let m = upto.match(/(?:^|\n)\/([\w-]{0,30})$/);
++ if (m) return { kind: 'slash', q: m[1], from: upto.length - m[1].length - 1 };
++ m = upto.match(/(?:^|[\s(])@([\w.-]{0,30})$/);
++ if (m && mine().some((x) => x.mentions && typeof x.mentions.search === 'function')) return { kind: 'at', q: m[1], from: upto.length - m[1].length - 1 };
++ return null;
++ }
++ function close() { pop.hidden = true; items = []; trig = null; text.removeAttribute('aria-activedescendant'); text.setAttribute('aria-expanded', 'false'); }
++ function paintPop() {
++ if (!items.length) { close(); return; }
++ pop.innerHTML = '';
++ items.forEach((it, i) => {
++ const b = el('button', 'ovc-pop-i');
++ b.type = 'button'; b.id = `${pop.id}-${i}`;
++ b.setAttribute('role', 'option');
++ b.setAttribute('aria-selected', i === at ? 'true' : 'false');
++ const lead = it.avatar ? el('img', 'ovc-pop-av') : el('span', 'ovc-pop-ic', svg(trig && trig.kind === 'at' ? IC.at : it.name === 'settings' ? IC.gear : IC.slash));
++ if (it.avatar) { lead.src = it.avatar; lead.alt = ''; }
++ const t = el('span', 'ovc-pop-t');
++ const n = el('b'); n.textContent = trig && trig.kind === 'at' ? it.label : `/${it.name}`;
++ const h = el('span'); h.textContent = trig && trig.kind === 'at' ? (it.sub || (it.handle ? `@${it.handle}` : '')) : it.hint || '';
++ t.append(n, h);
++ b.append(lead, t);
++ if (it.mine) b.appendChild(el('span', 'ovc-pop-tag', 'Yours'));
++ b.addEventListener('mousedown', (e) => e.preventDefault());
++ b.addEventListener('click', () => choose(i));
++ pop.appendChild(b);
++ });
++ pop.hidden = false;
++ text.setAttribute('aria-expanded', 'true');
++ text.setAttribute('aria-activedescendant', `${pop.id}-${at}`);
++ const cur = pop.children[at]; if (cur && cur.scrollIntoView) cur.scrollIntoView({ block: 'nearest' });
++ }
++ async function look() {
++ const w = caretWord();
++ if (!w) { close(); return; }
++ trig = w; const mineSeq = ++seq;
++ if (w.kind === 'slash') {
++ const q = w.q.toLowerCase();
++ items = commands().filter((c) => !q || c.name.toLowerCase().startsWith(q) || (c.hint || '').toLowerCase().includes(q)).slice(0, 12);
++ at = 0; paintPop(); return;
++ }
++ const srcs = mine().filter((m) => m.mentions && typeof m.mentions.search === 'function');
++ const lists = await Promise.all(srcs.map((m) => Promise.resolve(m.mentions.search(w.q)).catch(() => [])));
++ if (mineSeq !== seq) return;
++ items = lists.flat().filter((x) => x && x.label).slice(0, 8);
++ at = 0; paintPop();
++ }
++ function choose(i) {
++ const it = items[i]; if (!it || !trig) return;
++ const end = text.selectionStart;
++ const from = trig.from, kind = trig.kind;
++ close();
++ if (kind === 'at') { text.setRangeText(it.insert || `@${it.handle || it.label} `, from, end, 'end'); fire(); text.focus(); return; }
++ text.setRangeText('', from, end, 'end');
++ if (typeof it.run === 'function') { fire(); try { it.run(ctx); } catch (e) { console.error(e); } return; }
++ text.setRangeText(String(it.insert || ''), from, from, 'end'); fire(); text.focus();
++ }
++ let lookT = 0;
++ text.addEventListener('input', () => { clearTimeout(lookT); lookT = setTimeout(() => { lookT = 0; look(); }, trig && trig.kind === 'at' ? 140 : 0); });
++ text.addEventListener('blur', () => setTimeout(() => { if (document.activeElement !== text) close(); }, 120));
++ text.setAttribute('aria-autocomplete', 'list');
++
++ // Keys, before the thread's own: the list's arrows and Enter, what sends, and the marks.
++ form.addEventListener('keydown', (e) => {
++ if (e.target !== text) return;
++ // Enter right after the last letter: the slash list is read now, so "/settings" + Enter picks it.
++ if (e.key === 'Enter' && lookT) { clearTimeout(lookT); lookT = 0; const w = caretWord(); if (w && w.kind === 'slash') look(); }
++ if (!pop.hidden && items.length) {
++ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); at = (at + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length; paintPop(); return; }
++ if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); choose(at); return; }
++ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return; }
++ }
++ const mod = mac ? e.metaKey : e.ctrlKey;
++ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
++ if (mod) { e.preventDefault(); e.stopPropagation(); if (!send || !send.disabled) ctx.send(); return; }
++ if (prefs.send_key === 'mod-enter') { e.stopPropagation(); return; }
++ if (busy()) { e.preventDefault(); e.stopPropagation(); say('A file is still uploading.'); return; }
++ if (!text.value.trim() && files.length && (root.matchMedia ? root.matchMedia('(pointer: fine)').matches : true)) { e.preventDefault(); e.stopPropagation(); ctx.send(); return; }
++ }
++ const wrap = { b: '**', i: '_', e: '`' }[mod && !e.altKey && !e.shiftKey ? e.key.toLowerCase() : ''];
++ if (wrap) {
++ e.preventDefault(); e.stopPropagation();
++ const a = text.selectionStart, z = text.selectionEnd, sel = text.value.slice(a, z);
++ text.setRangeText(wrap + sel + wrap, a, z, 'select');
++ if (!sel) text.setSelectionRange(a + wrap.length, a + wrap.length);
++ else text.setSelectionRange(a + wrap.length, z + wrap.length);
++ fire();
++ }
++ }, true);
++
++ // The send: the mods may hold it; files still uploading hold it; the draft goes.
++ const draftKey = form.hasAttribute('data-ovc-no-draft') ? null : `ov:draft:${location.pathname}#${form.id || form.getAttribute('action') || ''}`;
++ form.addEventListener('submit', (e) => {
++ if (busy()) { e.preventDefault(); e.stopImmediatePropagation(); say('A file is still uploading.'); return; }
++ for (const m of mine()) {
++ if (typeof m.beforeSend !== 'function') continue;
++ let ok; try { ok = m.beforeSend(ctx); } catch (err) { console.error(err); }
++ if (ok === false) { e.preventDefault(); e.stopImmediatePropagation(); return; }
++ }
++ if (draftKey) store.set(draftKey, null);
++ close();
++ }, true);
++ if (draftKey) {
++ const d = store.get(draftKey);
++ if (d && d.t && !text.value && Date.now() - (d.at || 0) < 7 * 864e5) { text.value = d.t; fire(); }
++ let dt = 0;
++ text.addEventListener('input', () => { clearTimeout(dt); dt = setTimeout(() => store.set(draftKey, text.value.trim() ? { t: text.value, at: Date.now() } : null), 400); });
++ }
++
++ // The count, near the box's limit.
++ let count = null;
++ if (text.maxLength > 0 && row) {
++ count = el('span', 'ovc-count'); count.setAttribute('aria-live', 'polite');
++ row.insertBefore(count, send && send.parentNode === row ? send : null);
++ // The count shows only near the limit: the last 15% of a box, at most 200 and at least 20 characters (a 120-character
++ // search box said "120 left" while empty, 2026-11-11).
++ const paintCount = () => { const left = text.maxLength - text.value.length; count.hidden = left > Math.min(200, Math.max(20, Math.round(text.maxLength * 0.15))); count.textContent = `${left} left`; count.classList.toggle('is-low', left < 20); };
++ text.addEventListener('input', paintCount); paintCount();
++ }
++
++ // Voice: the browser listens, the words go in at the caret as they come.
++ let mic = null, rec = null;
++ function micButton() {
++ if (!Speech || !tools) return;
++ if (!prefs.voice) { if (mic) { mic.remove(); mic = null; } return; }
++ if (mic) return;
++ mic = el('button', 'ovt-btn ovc-mic', svg(IC.mic));
++ mic.type = 'button'; mic.setAttribute('aria-label', 'Speak'); mic.title = 'Speak'; mic.setAttribute('aria-pressed', 'false');
++ mic.addEventListener('click', () => {
++ if (rec) { rec.stop(); return; }
++ rec = new Speech();
++ rec.lang = document.documentElement.lang || navigator.language || 'en-US';
++ rec.interimResults = true; rec.continuous = true;
++ const a = text.selectionStart, z = text.selectionEnd, head = text.value.slice(0, a), tail = text.value.slice(z);
++ const pad = head && !/\s$/.test(head) ? ' ' : '';
++ rec.onresult = (ev) => { let s = ''; for (let i = 0; i < ev.results.length; i++) s += ev.results[i][0].transcript; text.value = head + pad + s + tail; text.setSelectionRange((head + pad + s).length, (head + pad + s).length); fire(); };
++ rec.onend = () => { rec = null; mic.setAttribute('aria-pressed', 'false'); mic.title = 'Speak'; text.focus(); };
++ rec.onerror = (ev) => { if (ev.error === 'not-allowed') say('The microphone is blocked for this site.'); };
++ try { rec.start(); mic.setAttribute('aria-pressed', 'true'); mic.title = 'Stop listening'; } catch { rec = null; }
++ });
++ tools.appendChild(mic);
++ }
++
++ // The mods' tools: buttons in the row, in the order the mods came.
++ let modTools = [];
++ function paintTools() {
++ modTools.forEach((b) => b.remove()); modTools = [];
++ if (!tools) return;
++ for (const t of mine().flatMap((m) => m.tools || [])) {
++ if (!t || !t.label || typeof t.run !== 'function') continue;
++ const b = el('button', 'ovt-btn ovc-tool', svg(PATH_OK.test(t.path || '') ? t.path : IC.slash));
++ b.type = 'button'; b.setAttribute('aria-label', t.label); b.title = t.label;
++ b.addEventListener('click', () => { try { t.run(ctx); } catch (e) { console.error(e); } });
++ tools.insertBefore(b, mic && mic.parentNode === tools ? mic : null);
++ modTools.push(b);
++ }
++ }
++
++ const api = {
++ form, ctx,
++ refresh() { form.classList.toggle('ovc-compact', prefs.density === 'compact'); micButton(); paintTools(); syncSend(); },
++ add, files: () => files.map((f) => f.file), clearFiles: () => { [...files].forEach(remove); },
++ uploads: () => files.map((f) => f.id).filter(Boolean),
++ clear() { files.slice().forEach(remove); text.value = ''; if (draftKey) store.set(draftKey, null); fire(); },
++ destroy() { live.delete(api); pop.remove(); strip.remove(); if (mic) mic.remove(); modTools.forEach((b) => b.remove()); form.__ovc = null; },
++ };
++ form.__ovc = api;
++ form.addEventListener('reset', () => setTimeout(() => api.clear(), 0));
++ live.add(api);
++ form.classList.add('ovc');
++ api.refresh();
++ return api;
++ }
++
++ // ── Your settings, in a sheet: what sends, voice, compact, your own commands.
++ let sheet = null;
++ function openSettings(from) {
++ const opener = from || document.activeElement;
++ needWindow().then((W) => settingsIn(W, opener));
++ }
++ function settingsIn(W, opener) {
++ let win = null;
++ if (!W && !sheet) {
++ sheet = el('dialog', 'ovc-set');
++ sheet.setAttribute('aria-label', 'Your composer');
++ sheet.addEventListener('click', (e) => { if (e.target === sheet) sheet.close(); });
++ document.body.appendChild(sheet);
++ }
++ const k = mac ? '⌘' : 'Ctrl';
++ const host = W ? el('div', 'ovc-set ovc-set-in') : sheet;
++ host.innerHTML = `<form method="dialog" class="ovc-set-f">${W ? '' : `<header class="ovc-set-h"><b>Your composer</b><button class="ovt-btn" value="done" aria-label="Done">${svg(IC.x)}</button></header>`}
++<fieldset class="ovc-set-g"><legend>Send with</legend><label><input type="radio" name="send_key" value="enter"> Enter <small>(Shift+Enter for a new line)</small></label><label><input type="radio" name="send_key" value="mod-enter"> ${k}+Enter <small>(Enter for a new line)</small></label></fieldset>
++<label class="ovc-set-c"><input type="checkbox" name="voice"> A microphone button${Speech ? '' : ' <small>(this browser cannot listen)</small>'}</label>
++<label class="ovc-set-c"><input type="checkbox" name="compact"> A compact box</label>
++<fieldset class="ovc-set-g"><legend>Your commands</legend><p class="ovc-set-p">Type / and the word, and its text goes in. Up to 50.</p><ul class="ovc-set-l"></ul>
++<div class="ovc-set-add"><input name="trigger" placeholder="word" maxlength="30" pattern="[A-Za-z0-9_-]+" aria-label="The word after /"><textarea name="snippet" rows="2" maxlength="4000" placeholder="The text it puts in" aria-label="The text it puts in"></textarea><button type="button" class="ovc-set-btn" data-ovc-add>Add</button></div></fieldset>
++<p class="ovc-set-p">${synced ? 'Saved to your account: the same on every OpenVibe site.' : 'Saved in this browser.'}</p></form>`;
++ const f = host.querySelector('form');
++ f.send_key.value = prefs.send_key;
++ f.voice.checked = !!prefs.voice; f.voice.disabled = !Speech;
++ f.compact.checked = prefs.density === 'compact';
++ const list = host.querySelector('.ovc-set-l');
++ const paintList = () => {
++ list.innerHTML = '';
++ (prefs.snippets || []).forEach((s, i) => {
++ const li = el('li');
++ const w = el('b'); w.textContent = `/${s.trigger}`;
++ const t = el('span'); t.textContent = s.text.slice(0, 80);
++ const x = el('button', 'ovt-btn', svg(IC.x)); x.type = 'button'; x.setAttribute('aria-label', `Remove /${s.trigger}`);
++ x.addEventListener('click', () => { const next = prefs.snippets.slice(); next.splice(i, 1); setPrefs({ snippets: next }); paintList(); });
++ li.append(w, t, x); list.appendChild(li);
++ });
++ };
++ paintList();
++ f.addEventListener('change', (e) => {
++ if (e.target.name === 'send_key') setPrefs({ send_key: f.send_key.value });
++ else if (e.target.name === 'voice') setPrefs({ voice: f.voice.checked });
++ else if (e.target.name === 'compact') setPrefs({ density: f.compact.checked ? 'compact' : 'comfy' });
++ });
++ host.querySelector('[data-ovc-add]').addEventListener('click', () => {
++ const w = f.trigger.value.trim().replace(/^\//, ''), t = f.snippet.value;
++ if (!/^[\w-]{1,30}$/.test(w)) { f.trigger.focus(); return; }
++ if (!t.trim()) { f.snippet.focus(); return; }
++ setPrefs({ snippets: (prefs.snippets || []).filter((s) => s.trigger !== w).concat([{ trigger: w, text: t }]) });
++ f.trigger.value = ''; f.snippet.value = '';
++ paintList(); f.trigger.focus();
++ });
++ f.addEventListener('submit', (e) => e.preventDefault());
++ if (W) win = W.open({ id: 'ov-composer-settings', title: 'Your composer', body: host, kind: 'dialog', size: 'sm', from: opener, focus: 'input[name=send_key]:checked' });
++ else { if (typeof sheet.showModal === 'function') sheet.showModal(); else sheet.setAttribute('open', ''); const first = f.querySelector('input[name=send_key]:checked'); if (first) first.focus(); }
++ return win;
++ }
++
++ function mod(m) {
++ if (!m || !m.id) return;
++ const i = mods.findIndex((x) => x.id === m.id);
++ if (i >= 0) mods[i] = m; else mods.push(m);
++ live.forEach((c) => c.refresh());
++ }
++ function scan(scope) { (scope || document).querySelectorAll('[data-ovt-composer], [data-ovc]').forEach((f) => enhance(f)); }
++
++ root.OpenVibeComposer = { enhance, mod, scan, settings: openSettings, get prefs() { return Object.assign({}, prefs); }, setPrefs };
++ if (typeof module === 'object' && module.exports) module.exports = { enhance, mod, scan };
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => scan(), { once: true }); else scan();
++ document.addEventListener('ov:boost:load', () => { live.forEach((c) => { if (!c.form.isConnected) c.destroy(); }); scan(); });
++ pull();
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/browser/shared/ov-float.js b/browser/shared/ov-float.js
nnew file mode 100644
iindex 0000000..5b45b29
---- /dev/null
++++ b/browser/shared/ov-float.js
@@@ -0,0 +1,815 @@
++/**
++ * ov-float.js (openvibe-shared 4.208.0): the move engine behind a picture-in-picture, a panel window or any box that
++ * floats over a page. It is Live's PiP move (public/js/stream-pip.js, 2026-11-11: "move it anywhere … mobile and
++ * desktop … more solid feeling and more responsive") lifted out whole, so every floating thing behaves the same:
++ *
++ * const f = OVFloat.attach(el, { frame, key, defaults, aspect, minPx, onChange });
++ *
++ * - Docked in `frame` (an element) or floating over the page (position: fixed). It follows the finger or the mouse
++ * with a transform each animation frame and lands with a spring (FLIP); a flick carries on where it was thrown.
++ * - Dropped over its frame it docks; anywhere else it floats. Near an edge it snaps; thrown past a side it tucks into
++ * that side, leaving a tab with its picture still showing. Two fingers pinch it, the corner sizes it, a double tap
++ * toggles small and large, and with focus the arrows move it, + and − size it, T tucks, F pops it out of its frame
++ * (or back in) and Home puts it back where it started.
++ * - Its place is kept as fractions of its frame: in this browser under `key`, and in the person's account when the
++ * page has GET/PATCH /api/me/layout (the `pip` map Live keeps). `OVFloat.store` is the account plug:
++ * { get(key), put(key, value), load?() }; assign your own to keep it somewhere else.
++ * - `f.geom()`, `f.set(patch, { save })`, `f.tuck()`, `f.untuck()`, `f.toggleMode()`, `f.toggleSize()`, `f.reset()`,
++ * `f.paint()`, `f.settle()`, `f.save()`, `f.relayout()`, `f.destroy()`. `OVFloat.layout()` lists every attached box,
++ * and each save dispatches `ov:float` ({ key, geom, el }).
++ *
++ * The CSS (injected once) is behaviour, not looks: positioning, the lift, the tuck's translate, the tab and the resize
++ * grip. A component adds its own colours, border, shadow and children. `aspect: null`, `size: false` and
++ * `resize: false` let a panel window keep its own height and width while it gets the move, the tuck and the place.
++ */
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OVFloat) return;
++
++ const BASE = 16 / 9; // the shape `w` is measured for
++ const MIN_W = 0.08, MAX_W = 0.60;
++ const MAX_H = 0.85; // of the frame's height, whatever the shape
++ const MIN_PX = 150; // the smallest 16:9 width on a phone-sized frame
++ const SNAP = 0.04; // within this fraction of an edge, a drop snaps to it
++ const EDGE_PX = 12; // the margin it snaps to
++ const TAB_PX = 30; // what stays on screen of a tucked box
++ const DRAG_PX = 4; // a press that moves less is a tap
++ const BIG_W = 0.5; // a double tap's large size
++ const PROJECT_MS = 120; // how far a flick carries
++ const TAP_MS = 300;
++ const ACTIVE_MS = 3200;
++ const LAND_MS = 300;
++ const DEFAULT_IGNORE = 'a, button, input, textarea, select, [contenteditable], .ov-float-resize, [data-ov-float-resize], [data-ov-float-no-drag]';
++
++ const CSS = `:where(.ov-float) { position: absolute; box-sizing: border-box; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent; }
++.ov-float.is-floating { position: fixed; z-index: 900; }
++.ov-float.is-lifted { position: fixed; z-index: 905; will-change: transform; transition: none; }
++.ov-float.is-dragging { cursor: grabbing; }
++.ov-float.is-tucked-left { translate: calc(-100% + var(--ov-float-tab, ${TAB_PX}px)) 0; }
++.ov-float.is-tucked-right { translate: calc(100% - var(--ov-float-tab, ${TAB_PX}px)) 0; }
++.ov-float.is-tucked .ov-float-tab { display: grid; }
++.ov-float-tab { position: absolute; top: 0; bottom: 0; z-index: 4; width: var(--ov-float-tab, ${TAB_PX}px); display: none; place-items: center; padding: 0; border: 0; cursor: pointer;
++ color: #fff; font: 600 15px/1 system-ui, sans-serif; background: rgba(10, 12, 18, .55); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
++.ov-float.is-tucked-left .ov-float-tab { right: 0; }
++.ov-float.is-tucked-right .ov-float-tab { left: 0; background: linear-gradient(270deg, rgba(0, 0, 0, .55), rgba(0, 0, 0, .25)); }
++.ov-float-tab-l, .ov-float-tab-r { display: none; }
++.ov-float.is-tucked-left .ov-float-tab-r, .ov-float.is-tucked-right .ov-float-tab-l { display: block; }
++.ov-float-resize { position: absolute; right: 0; bottom: 0; z-index: 3; width: 22px; height: 22px; cursor: nwse-resize; touch-action: none; opacity: 0;
++ background: linear-gradient(135deg, transparent 52%, rgba(255, 255, 255, .72) 52%, rgba(255, 255, 255, .72) 60%, transparent 60%, transparent 70%, rgba(255, 255, 255, .72) 70%, rgba(255, 255, 255, .72) 78%, transparent 78%); }
++.ov-float:hover .ov-float-resize, .ov-float:focus-within .ov-float-resize, .ov-float.is-resizing .ov-float-resize { opacity: 1; }
++@media (pointer: coarse) { .ov-float-resize { width: 30px; height: 30px; opacity: .85; } }
++html[data-ov-motion="off"] .ov-float { transition: none !important; }
++html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-resize { transition: none !important; }
++@media (prefers-reduced-motion: reduce) {
++ html:not([data-ov-motion="on"]) .ov-float { transition: none !important; }
++}`;
++
++ const instances = new Set();
++ let uid = 0;
++
++ function clamp(v, lo, hi, dflt) {
++ const n = Number(v);
++ if (!Number.isFinite(n)) return dflt;
++ return Math.min(hi, Math.max(lo, n));
++ }
++
++ function viewport(inst) {
++ const de = document.documentElement;
++ // A floating element's percentage place is measured on the document's client box, not on innerWidth when a
++ // scrollbar takes 15 px of it. Live's PiP keeps the window size it always used; a free panel asks for `client`.
++ const client = inst && inst.options.viewport === 'client' && de;
++ const w = (client ? de.clientWidth : root.innerWidth) || 0;
++ const h = (client ? de.clientHeight : root.innerHeight) || 0;
++ return { left: 0, top: 0, width: w, height: h, right: w, bottom: h };
++ }
++
++ function rectOf(el) {
++ return el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : viewport();
++ }
++
++ /** The box an absolutely positioned child measures its percentages on: the frame's padding box, not its border. */
++ function contentRect(el) {
++ const r = rectOf(el);
++ if (!el || !el.clientWidth || !el.clientHeight) return r;
++ const left = r.left + (el.clientLeft || 0), top = r.top + (el.clientTop || 0);
++ return { left, top, width: el.clientWidth, height: el.clientHeight, right: left + el.clientWidth, bottom: top + el.clientHeight };
++ }
++
++ function reduced() {
++ const m = document.documentElement && document.documentElement.getAttribute('data-ov-motion');
++ if (m === 'off') return true;
++ try { return m !== 'on' && root.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
++ }
++
++ function inFullscreen(inst) {
++ const fs = typeof document !== 'undefined' && document.fullscreenElement;
++ return !!(fs && inst && inst.frame && (fs === inst.frame || fs.contains(inst.frame)));
++ }
++
++ function frameRect(inst) {
++ if (inst.geom.mode === 'float' && !inFullscreen(inst)) return viewport(inst);
++ return inst.frame ? contentRect(inst.frame) : viewport(inst);
++ }
++
++ /* ── geometry (pure; the tests call these) ─────────────────────────────────────────────────────────────── */
++
++ /**
++ * The box as fractions of its frame: width, height and the place it may go, for the source's own shape.
++ * aspect: null keeps the element's own height (a panel window); `elHeight` is its measured height.
++ */
++ function boxOf(geom, rect, opts) {
++ const o = opts || {};
++ const a = o.aspect === null ? null : (Number(o.aspect) > 0 ? Number(o.aspect) : BASE);
++ const minW = o.minW == null ? MIN_W : o.minW;
++ const maxW = o.maxW == null ? MAX_W : o.maxW;
++ const maxH = o.maxH == null ? MAX_H : o.maxH;
++ const rw = (rect && rect.width) || 0, rh = (rect && rect.height) || 0;
++ const rl = (rect && rect.left) || 0, rt = (rect && rect.top) || 0;
++ let w = clamp(geom && geom.w, minW, maxW, Math.min(0.25, maxW));
++ if (a == null) {
++ return { a: null, wf: w, hf: rh && o.elHeight ? o.elHeight / rh : 0, rw, rh, rl, rt };
++ }
++ // On a phone-sized frame a quarter of the width is a postage stamp: never smaller than a 150 px wide 16:9 box
++ // (the saved size is unchanged, so a desktop shows it as saved).
++ const minPx = o.minPx == null ? MIN_PX : o.minPx;
++ if (rw && rw < (o.phoneFramePx == null ? 640 : o.phoneFramePx)) w = Math.max(w, Math.min(maxW, minPx / rw));
++ let wf = w * Math.sqrt(a / BASE); // the same area as a 16:9 box of width w
++ if (rw && rh && (wf * rw) / a > maxH * rh) wf = (maxH * rh * a) / rw;
++ wf = Math.min(wf, 1);
++ const hf = rw && rh ? (wf * rw) / a / rh : wf / a;
++ return { a, wf, hf, rw, rh, rl, rt };
++ }
++
++ /** After a drop: keep it inside, and snap it to an edge it was dropped close to. */
++ function settleGeom(geom, box, opts) {
++ const o = opts || {};
++ const edge = o.edgePx == null ? EDGE_PX : o.edgePx;
++ const snap = o.snap == null ? SNAP : o.snap;
++ const mx = box.rw ? edge / box.rw : 0, my = box.rh ? edge / box.rh : 0;
++ const maxX = Math.max(0, 1 - box.wf), maxY = Math.max(0, 1 - (box.hf || 0));
++ let x = clamp(geom.x, 0, maxX, 0), y = clamp(geom.y, 0, maxY, 0);
++ if (x < snap) x = Math.min(mx, maxX); else if (x > maxX - snap) x = Math.max(0, maxX - mx);
++ if (y < snap) y = Math.min(my, maxY); else if (y > maxY - snap) y = Math.max(0, maxY - my);
++ geom.x = x; geom.y = y;
++ return geom;
++ }
++
++ /** The last 90 ms of a drag: the speed it was let go at, and where a flick carries it. */
++ function flick(hist, from, opts) {
++ const o = opts || {};
++ const h = Array.isArray(hist) && hist.length ? hist : [{ x: 0, y: 0, t: 0 }];
++ const a = h[0], z = h[h.length - 1];
++ const dt = Math.max(16, (z.t || 0) - (a.t || 0));
++ const vx = ((z.x || 0) - (a.x || 0)) / dt, vy = ((z.y || 0) - (a.y || 0)) / dt;
++ const fast = Math.hypot(vx, vy) > (o.fastAt == null ? 0.6 : o.fastAt);
++ return { vx, vy, fast, left: from.left + (fast ? vx * PROJECT_MS : 0), top: from.top + (fast ? vy * PROJECT_MS : 0) };
++ }
++
++ /** Past a side of its frame by more than a third of itself (or thrown at it): tucked into that side. */
++ function tuckFor(point, from, frame, speed, opts) {
++ const o = opts || {};
++ const edge = o.edgePx == null ? EDGE_PX : o.edgePx;
++ const out = from.width / 3;
++ if (point.left < frame.left - out || (speed.fast && speed.vx < -1.2 && point.left < frame.left + edge)) return 'left';
++ if (point.left + from.width > frame.right + out || (speed.fast && speed.vx > 1.2 && point.left + from.width > frame.right - edge)) return 'right';
++ return null;
++ }
++
++ function overFrame(cx, cy, frame) {
++ return !!frame && cx >= frame.left && cx <= frame.right && cy >= frame.top && cy <= frame.bottom;
++ }
++
++ /** Draw a geometry: the percentages, the shape, and the state classes the component styles. */
++ function paint(el, geom, box, opts) {
++ const o = opts || {};
++ const tuck = geom.tuck;
++ const floating = !!o.floating;
++ const classes = el.classList;
++ classes.toggle('is-floating', floating);
++ classes.toggle('is-tucked', !!tuck);
++ classes.toggle('is-tucked-left', tuck === 'left');
++ classes.toggle('is-tucked-right', tuck === 'right');
++ classes.toggle('is-portrait', !!box.a && box.a < 1);
++ // Shown inside its frame even when a turn of the phone or a smaller window would push it out; the saved place
++ // is not changed, so it comes back when there is room again. A tucked one sits at its side (CSS `translate`
++ // leaves only its tab on screen; `transform` stays free for the landing).
++ const left = tuck === 'left' ? 0 : tuck === 'right' ? Math.max(0, 1 - box.wf) : Math.min(Math.max(0, geom.x), Math.max(0, 1 - box.wf));
++ el.style.left = `${left * 100}%`;
++ el.style.top = `${Math.min(Math.max(0, geom.y), Math.max(0, 1 - (box.hf || 0))) * 100}%`;
++ if (o.size !== false) {
++ el.style.width = `${box.wf * 100}%`;
++ if (box.a) el.style.aspectRatio = String(box.a);
++ }
++ }
++
++ /* ── the account plug (GET/PATCH /api/me/layout, the shape Live's stream-pip.js keeps) ───────────────── */
++
++ function makeDefaultStore() {
++ const state = { data: null, loading: null, pending: {}, timer: 0, off: false };
++ function load() {
++ if (state.off || typeof fetch !== 'function') return Promise.resolve(null);
++ if (!state.loading) {
++ state.loading = fetch('/api/me/layout', { credentials: 'same-origin', headers: { accept: 'application/json' } })
++ .then((r) => {
++ if (r.status === 400 || r.status === 401 || r.status === 403 || r.status === 404) { state.off = true; return null; }
++ return r.ok ? r.json() : null;
++ })
++ .then((d) => { state.data = d && typeof d === 'object' ? d : {}; return state.data; })
++ .catch(() => null);
++ }
++ return state.loading;
++ }
++ function get(key) {
++ const pip = state.data && state.data.pip;
++ return pip && pip[key] ? pip[key] : null;
++ }
++ function put(key, value) {
++ if (state.off || typeof fetch !== 'function') return;
++ state.pending[key] = value;
++ if (state.data) { state.data.pip = state.data.pip || {}; state.data.pip[key] = value; }
++ clearTimeout(state.timer);
++ state.timer = setTimeout(() => {
++ const pip = state.pending; state.pending = {};
++ fetch('/api/me/layout', {
++ method: 'PATCH', credentials: 'same-origin',
++ headers: { 'content-type': 'application/json', accept: 'application/json' },
++ body: JSON.stringify({ pip }),
++ }).then((r) => { if (r.status === 400 || r.status === 401 || r.status === 403 || r.status === 404) state.off = true; })
++ .catch(() => { /* the next move tries again */ });
++ }, 1500);
++ }
++ return { load, get, put, _state: state };
++ }
++
++ function normalizeDefaults(raw, hasFrame) {
++ const d = raw && typeof raw === 'object' ? raw : {};
++ return {
++ x: clamp(d.x, 0, 1, 0.72), y: clamp(d.y, 0, 1, 0.70), w: clamp(d.w, MIN_W, MAX_W, 0.25),
++ mode: hasFrame ? (d.mode === 'float' ? 'float' : 'dock') : 'float',
++ tuck: d.tuck === 'left' || d.tuck === 'right' ? d.tuck : null,
++ muted: typeof d.muted === 'boolean' ? d.muted : undefined,
++ at: 0,
++ };
++ }
++
++ function normalize(raw, fallback, opts) {
++ const o = opts || {};
++ if (!raw || typeof raw !== 'object') return Object.assign({}, fallback);
++ const g = Object.assign({}, fallback);
++ g.x = clamp(raw.x, 0, 1, fallback.x);
++ g.y = clamp(raw.y, 0, 1, fallback.y);
++ g.w = clamp(raw.w, o.minW == null ? MIN_W : o.minW, o.maxW == null ? MAX_W : o.maxW, fallback.w);
++ g.mode = o.hasFrame === false ? 'float' : (raw.mode === 'float' ? 'float' : raw.mode === 'dock' ? 'dock' : fallback.mode);
++ g.tuck = raw.tuck === 'left' || raw.tuck === 'right' ? raw.tuck : null;
++ if (typeof raw.muted === 'boolean') g.muted = raw.muted;
++ g.at = Number(raw.at) || 0;
++ return g;
++ }
++
++ function readSaved(key, legacyKeys) {
++ const keys = [key].concat(Array.isArray(legacyKeys) ? legacyKeys : []);
++ for (const k of keys) {
++ try {
++ const raw = root.localStorage && root.localStorage.getItem(k);
++ if (!raw) continue;
++ const v = JSON.parse(raw);
++ if (v && typeof v === 'object') return v;
++ } catch { /* private mode, or a key from another shape */ }
++ }
++ return null;
++ }
++
++ /* ── the instance ──────────────────────────────────────────────────────────────────────────────────────── */
++
++ function box(inst) {
++ return boxOf(inst.geom, frameRect(inst), {
++ aspect: inst.aspect, minPx: inst.minPx, minW: inst.minW, maxW: inst.maxW,
++ elHeight: inst.el.offsetHeight || 0,
++ });
++ }
++
++ function paintNow(inst) {
++ const b = box(inst);
++ paint(inst.el, inst.geom, b, {
++ floating: inst.geom.mode === 'float' && !inFullscreen(inst),
++ size: inst.options.size !== false,
++ });
++ return b;
++ }
++
++ function settleInst(inst) {
++ return settleGeom(inst.geom, box(inst));
++ }
++
++ function placeIn(inst) {
++ const fs = document.fullscreenElement;
++ let want;
++ if (inst.geom.mode === 'float' && !inFullscreen(inst)) want = document.body;
++ else if (inst.frame) want = fs && !fs.contains(inst.frame) ? fs : inst.frame;
++ else want = document.body;
++ if (want && inst.el.parentNode !== want) {
++ const v = inst.el.querySelector('video');
++ want.appendChild(inst.el);
++ // A media element moved in one task keeps playing; play() covers a browser that paused it.
++ if (v && v.paused) v.play().catch(() => {});
++ }
++ }
++
++ function save(inst) {
++ if (inst.destroyed) return;
++ // A drop clears the lifted inline left/top before it saves; draw the final place first so an onChange that
++ // reads the element's rect (a window's absolute place, for one) sees where it landed, not its static position.
++ if (!inst.el.classList.contains('is-lifted')) paintNow(inst);
++ inst.geom.at = Date.now();
++ try { if (root.localStorage) root.localStorage.setItem(inst.key, JSON.stringify(inst.geom)); } catch { /* private mode */ }
++ if (inst.store && typeof inst.store.put === 'function') {
++ try { inst.store.put(inst.accountKey, Object.assign({}, inst.geom)); } catch { /* the page's own store */ }
++ }
++ if (typeof inst.options.onChange === 'function') {
++ try { inst.options.onChange(Object.assign({}, inst.geom), inst.api); } catch (err) { console.error(err); }
++ }
++ try {
++ root.dispatchEvent(new root.CustomEvent('ov:float', { detail: { key: inst.key, geom: Object.assign({}, inst.geom), el: inst.el } }));
++ } catch { /* an environment without CustomEvent */ }
++ }
++
++ /** Move it to its place with a spring from where it was (`from`: its rect on screen before). */
++ function land(inst, from) {
++ placeIn(inst);
++ const el = inst.el;
++ el.style.transition = 'none';
++ el.style.transform = '';
++ paintNow(inst);
++ if (!from || reduced()) { el.style.transition = ''; return; }
++ const to = el.getBoundingClientRect();
++ if (!to.width) { el.style.transition = ''; return; }
++ const dx = from.left - to.left, dy = from.top - to.top, s = from.width / to.width;
++ if (Math.abs(dx) < 1 && Math.abs(dy) < 1 && Math.abs(s - 1) < 0.01) { el.style.transition = ''; return; }
++ el.style.transformOrigin = '0 0';
++ el.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(${s})`;
++ void el.offsetWidth;
++ el.style.transition = `transform ${LAND_MS}ms var(--ov-ease-spring, cubic-bezier(.2, 1.25, .3, 1))`;
++ el.style.transform = '';
++ clearTimeout(inst.landTimer);
++ inst.landTimer = setTimeout(() => {
++ if (!inst.destroyed) { el.style.transition = ''; el.style.transformOrigin = ''; }
++ }, LAND_MS + 40);
++ }
++
++ /** Switch frames keeping its size and place on screen (`rect`: where it is now). */
++ function toMode(inst, mode, rect) {
++ if (!inst.frame) mode = 'float';
++ inst.geom.mode = mode;
++ const b0 = box(inst);
++ if (!b0.rw || !b0.rh) return;
++ const a = inst.aspect || BASE;
++ const k = Math.sqrt(a / BASE) || 1;
++ if (inst.options.size !== false) inst.geom.w = clamp(rect.width / b0.rw / k, inst.minW, inst.maxW, inst.geom.w);
++ const b = box(inst);
++ inst.geom.x = (rect.left - b.rl) / b.rw;
++ inst.geom.y = (rect.top - b.rt) / b.rh;
++ settleInst(inst);
++ }
++
++ function tuck(inst, side) {
++ const from = inst.el.getBoundingClientRect();
++ if (side === null) { inst.geom.tuck = null; }
++ else {
++ const b = box(inst);
++ inst.geom.tuck = side || (inst.geom.x + b.wf / 2 < 0.5 ? 'left' : 'right');
++ }
++ save(inst);
++ land(inst, from);
++ }
++
++ function untuck(inst) {
++ const from = inst.el.getBoundingClientRect();
++ const b = box(inst);
++ inst.geom.tuck = null;
++ inst.geom.x = from.left + from.width / 2 < (b.rl + b.rw / 2)
++ ? (b.rw ? EDGE_PX / b.rw : 0)
++ : Math.max(0, 1 - b.wf - (b.rw ? EDGE_PX / b.rw : 0));
++ settleInst(inst);
++ save(inst);
++ land(inst, from);
++ }
++
++ function toggleMode(inst) {
++ if (!inst.frame) return false;
++ const from = inst.el.getBoundingClientRect();
++ toMode(inst, inst.geom.mode === 'float' ? 'dock' : 'float', from);
++ save(inst);
++ land(inst, from);
++ return true;
++ }
++
++ function toggleSize(inst) {
++ const from = inst.el.getBoundingClientRect();
++ const big = inst.options.bigW == null ? BIG_W : inst.options.bigW;
++ inst.geom.w = inst.geom.w < big - 0.01 ? big : clamp(inst.defaults.w, inst.minW, inst.maxW, inst.defaults.w);
++ inst.geom.tuck = null;
++ settleInst(inst);
++ save(inst);
++ land(inst, from);
++ }
++
++ function reset(inst) {
++ const from = inst.el.getBoundingClientRect();
++ const d = inst.defaults;
++ Object.assign(inst.geom, { x: d.x, y: d.y, w: d.w, mode: inst.frame ? d.mode : 'float', tuck: null });
++ settleInst(inst);
++ save(inst);
++ land(inst, from);
++ }
++
++ function relayout(inst) {
++ if (inst.destroyed || inst.el.classList.contains('is-lifted')) return;
++ placeIn(inst);
++ paintNow(inst);
++ }
++
++ function syncAccount(inst) {
++ const store = inst.store;
++ if (!store || typeof store.get !== 'function') return;
++ const apply = () => {
++ if (inst.destroyed || inst.touched) return;
++ const remote = normalize(store.get(inst.accountKey), inst.defaults, { hasFrame: !!inst.frame, minW: inst.minW, maxW: inst.maxW });
++ if ((remote.at || 0) > (inst.geom.at || 0)) {
++ inst.geom = remote;
++ placeIn(inst);
++ paintNow(inst);
++ }
++ };
++ if (typeof store.load === 'function') {
++ let p;
++ try { p = store.load(); } catch { p = null; }
++ if (p && typeof p.then === 'function') p.then(apply).catch(() => { /* no account on this page */ });
++ else apply();
++ } else apply();
++ }
++
++ /* ── gestures ──────────────────────────────────────────────────────────────────────────────────────────── */
++
++ function wireMove(inst) {
++ const el = inst.el;
++ const handle = inst.options.handle && inst.options.handle.addEventListener ? inst.options.handle : el;
++ const ignore = inst.options.ignore || DEFAULT_IGNORE;
++ const hintClass = inst.options.hintClass || 'ov-float-dock-hint';
++ const pts = new Map(); // pointerId → { x, y }
++ let drag = null, pinch = null, raf = 0, lastTap = 0, activeTimer = 0;
++ const gap = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y) || 1; };
++ const hint = (on) => { if (inst.frame && inst.frame.classList) inst.frame.classList.toggle(hintClass, !!on); };
++ const paint = () => {
++ raf = 0;
++ if (!drag || !drag.lifted) return;
++ el.style.transform = `translate3d(${drag.dx}px, ${drag.dy}px, 0)`;
++ const c = drag.r;
++ const p = inst.frame ? contentRect(inst.frame) : null;
++ if (p) hint(overFrame(c.left + drag.dx + c.width / 2, c.top + drag.dy + c.height / 2, p));
++ };
++ const lift = () => {
++ drag.lifted = true;
++ // Over the page from the first move, at the same place on screen, so it can go anywhere.
++ const r = drag.r;
++ const layer = document.fullscreenElement || document.body;
++ if (el.parentNode !== layer) layer.appendChild(el);
++ // Moving the element in the DOM releases any pointer capture it had; take the pointers it is following back.
++ for (const id of pts.keys()) { try { el.setPointerCapture(id); } catch { /* a pointer the browser no longer tracks */ } }
++ el.classList.add('is-dragging', 'is-lifted');
++ el.style.transition = 'none';
++ el.style.left = `${r.left}px`; el.style.top = `${r.top}px`; el.style.width = `${r.width}px`;
++ const v = el.querySelector('video');
++ if (v && v.paused) v.play().catch(() => {});
++ if (typeof inst.options.onLift === 'function') { try { inst.options.onLift(inst.api); } catch { /* */ } }
++ };
++ const showActive = () => {
++ el.classList.add('is-active');
++ clearTimeout(activeTimer);
++ activeTimer = setTimeout(() => el.classList.remove('is-active'), ACTIVE_MS);
++ };
++ const onDown = (e) => {
++ if (e.button > 0) return;
++ if (e.target.closest && e.target.closest(ignore)) return;
++ if (typeof inst.options.canDrag === 'function' && !inst.options.canDrag(e)) return;
++ inst.touched = true;
++ pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
++ try { el.setPointerCapture(e.pointerId); } catch { /* a pointer the browser no longer tracks */ }
++ e.preventDefault();
++ if (pts.size === 2) {
++ if (drag && drag.lifted) el.style.transform = '';
++ drag = null;
++ pinch = { d: gap(), w: inst.geom.w };
++ el.classList.add('is-resizing');
++ return;
++ }
++ if (pts.size > 2) return;
++ drag = {
++ sx: e.clientX, sy: e.clientY, dx: 0, dy: 0, r: el.getBoundingClientRect(), lifted: false,
++ hist: [{ x: e.clientX, y: e.clientY, t: performance.now() }],
++ };
++ };
++ const onMove = (e) => {
++ if (pts.has(e.pointerId)) pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
++ if (pinch && pts.size === 2) {
++ inst.geom.w = clamp(pinch.w * (gap() / pinch.d), inst.minW, inst.maxW, pinch.w);
++ paintNow(inst);
++ return;
++ }
++ if (!drag) return;
++ const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
++ if (!drag.lifted && Math.hypot(dx, dy) < DRAG_PX) return;
++ if (!drag.lifted) lift();
++ // Anywhere on screen; most of it may go past a side (that is how it tucks), never past the top or bottom.
++ const vp = viewport(inst);
++ const r = drag.r, vw = vp.width, vh = vp.height, tab = inst.options.tabPx == null ? TAB_PX : inst.options.tabPx;
++ drag.dx = Math.min(Math.max(dx, -r.left - r.width + tab), vw - r.left - tab);
++ drag.dy = Math.min(Math.max(dy, -r.top), vh - r.top - r.height);
++ const now = performance.now();
++ drag.hist.push({ x: e.clientX, y: e.clientY, t: now });
++ while (drag.hist.length > 2 && now - drag.hist[0].t > 90) drag.hist.shift();
++ if (!raf) raf = requestAnimationFrame(paint);
++ };
++ const onUp = (e) => {
++ pts.delete(e.pointerId);
++ try { el.releasePointerCapture(e.pointerId); } catch { /* */ }
++ if (pinch) {
++ if (pts.size < 2) {
++ pinch = null;
++ el.classList.remove('is-resizing');
++ // A pinch that began after the lift put the box over the page; put it back in its frame for its mode.
++ if (el.classList.contains('is-lifted')) {
++ el.classList.remove('is-dragging', 'is-lifted');
++ el.style.left = ''; el.style.top = ''; el.style.width = ''; el.style.transform = '';
++ placeIn(inst);
++ }
++ settleInst(inst);
++ paintNow(inst);
++ save(inst);
++ }
++ return;
++ }
++ if (!drag) return;
++ const d = drag; drag = null;
++ if (raf) { cancelAnimationFrame(raf); raf = 0; }
++ hint(false);
++ if (!d.lifted) {
++ // A tap: the bar for a moment (a touch screen has no hover); a second within 300 ms toggles the size.
++ const now = performance.now();
++ if (inst.options.doubleTap !== false && now - lastTap < TAP_MS) { lastTap = 0; toggleSize(inst); }
++ else {
++ lastTap = now;
++ if (e.pointerType !== 'mouse' && inst.options.activeBar !== false) showActive();
++ }
++ if (inst.geom.tuck && e.type === 'pointerup') untuck(inst);
++ return;
++ }
++ el.classList.remove('is-dragging', 'is-lifted');
++ // Where it was let go, and where a flick carries it (the last 90 ms, 120 ms on).
++ const fl = flick(d.hist, { left: d.r.left + d.dx, top: d.r.top + d.dy });
++ const from = { left: d.r.left + d.dx, top: d.r.top + d.dy, width: d.r.width, height: d.r.height };
++ const vp = viewport(inst);
++ const vhei = vp.height;
++ const top = Math.min(Math.max(0, fl.top), vhei - from.height);
++ const p = inst.frame ? contentRect(inst.frame) : null;
++ const fs = inFullscreen(inst);
++ const overPlayer = !!p && (fs || overFrame(fl.left + from.width / 2, top + from.height / 2, p));
++ const frame = overPlayer ? p : vp;
++ const tuckSide = tuckFor({ left: fl.left }, from, frame, fl);
++ el.style.transform = '';
++ el.style.left = ''; el.style.top = ''; el.style.width = '';
++ toMode(inst, overPlayer ? 'dock' : 'float', {
++ left: Math.min(Math.max(frame.left, fl.left), frame.right - from.width),
++ top,
++ width: from.width,
++ height: from.height,
++ });
++ inst.geom.tuck = tuckSide;
++ save(inst);
++ land(inst, from);
++ };
++ handle.addEventListener('pointerdown', onDown);
++ el.addEventListener('pointermove', onMove);
++ el.addEventListener('pointerup', onUp);
++ el.addEventListener('pointercancel', onUp);
++ inst.cleanups.push(() => {
++ handle.removeEventListener('pointerdown', onDown);
++ el.removeEventListener('pointermove', onMove);
++ el.removeEventListener('pointerup', onUp);
++ el.removeEventListener('pointercancel', onUp);
++ clearTimeout(activeTimer);
++ hint(false);
++ });
++ }
++
++ function wireKeys(inst) {
++ const el = inst.el;
++ const onKey = (e) => {
++ if (e.target !== el || e.altKey || e.ctrlKey || e.metaKey) return;
++ const step = e.shiftKey ? 0.1 : 0.02;
++ const k = e.key;
++ let moved = true;
++ if (k === 'ArrowLeft') inst.geom.x -= step;
++ else if (k === 'ArrowRight') inst.geom.x += step;
++ else if (k === 'ArrowUp') inst.geom.y -= step;
++ else if (k === 'ArrowDown') inst.geom.y += step;
++ else if (k === '+' || k === '=') inst.geom.w = clamp(inst.geom.w + 0.03, inst.minW, inst.maxW, inst.geom.w);
++ else if (k === '-' || k === '_') inst.geom.w = clamp(inst.geom.w - 0.03, inst.minW, inst.maxW, inst.geom.w);
++ else if (k === 'Home') { e.preventDefault(); reset(inst); return; }
++ else if (k === 't' || k === 'T' || k === 'h' || k === 'H') { e.preventDefault(); inst.geom.tuck ? untuck(inst) : tuck(inst); return; }
++ else if (k === 'f' || k === 'F') { e.preventDefault(); toggleMode(inst); return; }
++ else return;
++ e.preventDefault();
++ if (!moved) return;
++ const b = box(inst);
++ inst.geom.tuck = null;
++ inst.geom.x = Math.min(Math.max(0, inst.geom.x), Math.max(0, 1 - b.wf));
++ inst.geom.y = Math.min(Math.max(0, inst.geom.y), Math.max(0, 1 - b.hf));
++ paintNow(inst);
++ save(inst);
++ };
++ el.addEventListener('keydown', onKey);
++ inst.cleanups.push(() => el.removeEventListener('keydown', onKey));
++ }
++
++ function ensureTab(inst) {
++ let tab = inst.el.querySelector('[data-ov-float-tab]');
++ if (!tab) {
++ tab = document.createElement('button');
++ tab.type = 'button';
++ tab.className = 'ov-float-tab';
++ tab.dataset.ovFloatTab = '';
++ tab.setAttribute('aria-label', 'Bring it back');
++ tab.innerHTML = '<span class="ov-float-tab-l" aria-hidden="true">‹</span><span class="ov-float-tab-r" aria-hidden="true">›</span>';
++ inst.el.append(tab);
++ inst.ownedTab = tab;
++ }
++ const onClick = (e) => { e.preventDefault(); e.stopPropagation(); untuck(inst); };
++ tab.addEventListener('click', onClick);
++ inst.cleanups.push(() => tab.removeEventListener('click', onClick));
++ }
++
++ function ensureResize(inst) {
++ let handle = inst.el.querySelector('[data-ov-float-resize]');
++ if (!handle) {
++ handle = document.createElement('span');
++ handle.className = 'ov-float-resize';
++ handle.dataset.ovFloatResize = '';
++ handle.setAttribute('aria-hidden', 'true');
++ inst.el.append(handle);
++ inst.ownedResize = handle;
++ }
++ let start = null;
++ const onDown = (e) => {
++ if (e.button > 0) return;
++ const b = box(inst);
++ start = { px: e.clientX, py: e.clientY, w: inst.geom.w, rw: b.rw };
++ inst.touched = true;
++ inst.el.classList.add('is-resizing');
++ try { handle.setPointerCapture(e.pointerId); } catch { /* */ }
++ e.preventDefault();
++ e.stopPropagation();
++ };
++ const onMove = (e) => {
++ if (!start || !start.rw) return;
++ // The handle's diagonal pull is the box's own width; `w` is the 16:9 width of the same area.
++ const k = inst.aspect ? Math.sqrt(inst.aspect / BASE) || 1 : 1;
++ const pull = inst.aspect ? ((e.clientX - start.px) + (e.clientY - start.py) * inst.aspect) / 2 : (e.clientX - start.px);
++ inst.geom.w = clamp(start.w + pull / start.rw / k, inst.minW, inst.maxW, start.w);
++ const b = box(inst);
++ inst.geom.x = Math.min(inst.geom.x, Math.max(0, 1 - b.wf));
++ inst.geom.y = Math.min(inst.geom.y, Math.max(0, 1 - b.hf));
++ paintNow(inst);
++ };
++ const end = (e) => {
++ if (!start) return;
++ start = null;
++ inst.el.classList.remove('is-resizing');
++ try { handle.releasePointerCapture(e.pointerId); } catch { /* */ }
++ settleInst(inst);
++ paintNow(inst);
++ save(inst);
++ };
++ handle.addEventListener('pointerdown', onDown);
++ handle.addEventListener('pointermove', onMove);
++ handle.addEventListener('pointerup', end);
++ handle.addEventListener('pointercancel', end);
++ inst.cleanups.push(() => {
++ handle.removeEventListener('pointerdown', onDown);
++ handle.removeEventListener('pointermove', onMove);
++ handle.removeEventListener('pointerup', end);
++ handle.removeEventListener('pointercancel', end);
++ });
++ }
++
++ function wireWindow(inst) {
++ const onResize = () => relayout(inst);
++ root.addEventListener('resize', onResize);
++ document.addEventListener('fullscreenchange', onResize);
++ let ro = null;
++ if (typeof ResizeObserver === 'function') {
++ ro = new ResizeObserver(onResize);
++ if (inst.frame) ro.observe(inst.frame);
++ ro.observe(inst.el);
++ }
++ inst.cleanups.push(() => {
++ root.removeEventListener('resize', onResize);
++ document.removeEventListener('fullscreenchange', onResize);
++ try { ro && ro.disconnect(); } catch { /* */ }
++ });
++ }
++
++ /* ── public API ───────────────────────────────────────────────────────────────────────────────────────── */
++
++ function attach(el, options) {
++ if (!el || typeof el !== 'object' || !el.classList) throw new TypeError('OVFloat.attach(el, options): el is required');
++ const o = options || {};
++ const frame = o.frame || null;
++ const key = String(o.key || `ov-float-${++uid}`);
++ const minW = clamp(o.minW == null ? MIN_W : o.minW, 0, 1, MIN_W);
++ const maxW = clamp(o.maxW == null ? MAX_W : o.maxW, minW, 1, MAX_W);
++ const defaults = normalizeDefaults(o.defaults, !!frame);
++ const inst = {
++ el, frame, key,
++ accountKey: o.accountKey == null ? key : String(o.accountKey),
++ options: o, defaults, minW, maxW,
++ aspect: o.aspect === null ? null : (typeof o.aspect === 'function' ? Number(o.aspect()) || BASE : (Number(o.aspect) > 0 ? Number(o.aspect) : BASE)),
++ minPx: Number(o.minPx) > 0 ? Number(o.minPx) : MIN_PX,
++ store: o.store === null ? null : (o.store || root.OVFloat.store || null),
++ geom: null, touched: false, destroyed: false, cleanups: [], landTimer: 0, ownedTab: null, ownedResize: null, api: null,
++ };
++ inst.geom = normalize(readSaved(key, o.legacyKeys), defaults, { hasFrame: !!frame, minW, maxW });
++ injectStyles();
++ el.classList.add('ov-float');
++ if (o.tab !== false) ensureTab(inst);
++ if (o.resize !== false) ensureResize(inst);
++ wireMove(inst);
++ wireKeys(inst);
++ wireWindow(inst);
++ placeIn(inst);
++ paintNow(inst);
++
++ inst.api = {
++ el, key,
++ geom: () => Object.assign({}, inst.geom),
++ set(patch, opts) {
++ Object.assign(inst.geom, patch || {});
++ if (opts && opts.settle) settleInst(inst);
++ placeIn(inst);
++ paintNow(inst);
++ if (opts && opts.save) save(inst);
++ return Object.assign({}, inst.geom);
++ },
++ setAspect(a) {
++ inst.aspect = a === null ? null : (Number(a) > 0 ? Number(a) : BASE);
++ paintNow(inst);
++ },
++ metrics: () => box(inst),
++ paint: () => paintNow(inst),
++ settle: () => { settleInst(inst); paintNow(inst); return Object.assign({}, inst.geom); },
++ save: () => save(inst),
++ tuck: (side) => tuck(inst, side),
++ untuck: () => untuck(inst),
++ toggleTuck: () => (inst.geom.tuck ? untuck(inst) : tuck(inst)),
++ toggleMode: () => toggleMode(inst),
++ toggleSize: () => toggleSize(inst),
++ reset: () => reset(inst),
++ relayout: () => relayout(inst),
++ destroy: () => destroy(inst),
++ };
++ instances.add(inst);
++ syncAccount(inst);
++ return inst.api;
++ }
++
++ function destroy(inst) {
++ if (inst.destroyed) return;
++ inst.destroyed = true;
++ instances.delete(inst);
++ clearTimeout(inst.landTimer);
++ for (const fn of inst.cleanups) { try { fn(); } catch { /* */ } }
++ inst.cleanups = [];
++ if (inst.ownedTab) { try { inst.ownedTab.remove(); } catch { /* */ } }
++ if (inst.ownedResize) { try { inst.ownedResize.remove(); } catch { /* */ } }
++ inst.el.classList.remove('ov-float', 'is-floating', 'is-lifted', 'is-dragging', 'is-tucked', 'is-tucked-left', 'is-tucked-right', 'is-resizing', 'is-active');
++ }
++
++ function injectStyles() {
++ if (document.getElementById('ov-float-styles')) return;
++ const st = document.createElement('style');
++ st.id = 'ov-float-styles';
++ st.textContent = CSS;
++ (document.head || document.documentElement).appendChild(st);
++ }
++
++ const defaultStore = makeDefaultStore();
++ root.OVFloat = {
++ attach,
++ store: defaultStore,
++ newStore: makeDefaultStore,
++ layout: () => [...instances].map((i) => ({ key: i.key, geom: Object.assign({}, i.geom), el: i.el })),
++ _test: {
++ boxOf, settle: settleGeom, flick, tuckFor, overFrame, paint, normalize,
++ BASE, MAX_H, MIN_W, MAX_W, EDGE_PX, SNAP, PROJECT_MS,
++ },
++ };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/browser/shared/ov-glyphs.js b/browser/shared/ov-glyphs.js
nnew file mode 100644
iindex 0000000..5bf4b03
---- /dev/null
++++ b/browser/shared/ov-glyphs.js
@@@ -0,0 +1,139 @@
++/* Generated by scripts/build-glyphs.js from glyphs.js: edit those, then run it. */
++/**
++ * openvibe-shared/ov-glyphs.js — the OpenVibe glyphs in place of Font Awesome (plan L32). A page that loads it keeps its
++ * markup (<i class="fa-solid fa-tower-broadcast">) and shows the network's own icon instead: the same hand as the mark
++ * (24-unit grid, 2-unit round strokes), in the text's colour and size. A name the glyphs do not have yet stays Font
++ * Awesome's. A brand logo (fa-brands) is drawn from brands.js (Simple Icons, CC0; 4.72.0), fetched from beside this
++ * file the first time a page shows one, filled in the text's colour with the brand's own as --ovg-brand (add
++ * ovg-brand-color to wear it). A spinner is three dots that take turns, never a spin.
++ *
++ * Font Awesome's features keep working without its stylesheet (4.62.0): sizes (fa-2xs … fa-2xl, fa-1x … fa-10x), fa-fw,
++ * fa-ul/fa-li, fa-border, fa-pull-*, fa-rotate-* and fa-flip-*, fa-stack, fa-layers with counters and text, the
++ * animations (fa-beat, fa-bounce, fa-fade, fa-beat-fade, fa-flip, fa-shake; fa-spin breathes), fa-thin and fa-light
++ * as thinner strokes, data-fa-transform (grow, shrink, up, down, left, right, rotate, flip-h, flip-v), and a title that
++ * names the icon to screen readers. Every variable Font Awesome documents (--fa-beat-scale …) is honoured. The glyphs'
++ * own motion: ovg-draw draws the lines in once, ovg-once plays an animation a single time (4.63.0).
++ *
++ * <script src="/shared/ov-glyphs.js" defer></script> swaps what is in the page, then anything added later
++ * OVGlyphs.swap(rootEl) swaps inside one element now
++ * OVGlyphs.fromFa('fa-eye') → 'eye' the glyph a Font Awesome name maps to, or null
++ *
++ * 272 glyphs for 449 Font Awesome names (openvibe.design/icons shows them).
++ */
++(function () {
++ 'use strict';
++ if (typeof window === 'undefined' || window.OVGlyphs) return;
++ const G = {"airplane":"<path d=\"M12 3.2c0.9 0 1.4 0.9 1.4 2.4V9.8l7 4.2V15.4l-7-2.4V17.2l1.9 1.4V20L12 19.1l-3.3 0.9v-1.4l1.9-1.4v-4.2L3.6 15.4v-1.4L10.6 9.8V5.6C10.6 4.2 11.1 3.2 12 3.2Z\"/>","alert":"<path d=\"M12 4.5L3.6 18.8h16.9Z\"/><path d=\"M12 10v3.7M12 16.5h0\"/>","alert-circle":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M12 7.5v5.5M12 16.5h.01\"/>","align-left":"<path d=\"M4 5.9h16.6M4 10h10.4M4 14.3h16.6M4 18.5h10.4\"/>","archive":"<rect x=\"3.5\" y=\"4.8\" width=\"17\" height=\"4.7\" rx=\"0.9\"/><path d=\"M5.4 9.5v9.4a0.9 0.9 0 0 0 0.9 0.9h11.3a0.9 0.9 0 0 0 0.9-0.9V9.5M10.1 13.3h3.8\"/>","arrow-down":"<path d=\"M12 3.2v16.7M5.7 13.7l6.3 6.3l6.3-6.3\"/>","arrow-left":"<path d=\"M20.8 12H4M10.3 5.7l-6.3 6.3l6.3 6.3\"/>","arrow-right":"<path d=\"M3.2 12h16.7M13.7 5.7l6.3 6.3l-6.3 6.3\"/>","arrow-up":"<path d=\"M12 20.8V4M5.7 10.3l6.3-6.3l6.3 6.3\"/>","arrows-h":"<path d=\"M3.8 12h16.5M7.6 8.1l-3.9 3.9l3.9 3.9M16.4 8.1l3.9 3.9l-3.9 3.9\"/>","at":"<circle cx=\"12\" cy=\"12\" r=\"3.4\"/><path d=\"M15.4 12v1.3a2.5 2.5 0 0 0 4.9 0V12a8.3 8.3 0 1 0-3.2 6.5\"/>","back":"<path d=\"M16.1 3.6l-8.4 8.4l8.4 8.4\"/>","ban":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"m6 6 12 12\"/>","bell":"<path d=\"M6 14.6V9.5a6.1 6.1 0 0 1 12.2 0v5.1l1.5 2.1h-15.3ZM10 18.7a2.1 2.1 0 0 0 4 0\"/>","book":"<path d=\"M5 5.7a1.9 1.9 0 0 1 1.9-1.9h12.1v14.9H6.8a1.9 1.9 0 0 0-1.9 1.9Z\"/><path d=\"M5 18.7V5.7\"/>","book-open":"<path d=\"M12 6.3C10 4.8 7 4.3 3.5 4.9v13.2c3.5-0.6 6.5-0.1 8.5 1.4c2-1.5 5-2 8.5-1.4V4.9C17 4.3 14 4.8 12 6.3Z\"/><path d=\"M12 6.3v13.2\"/>","box":"<path d=\"M3.6 7.9L11.9 3.9l8.4 3.9v8.4L11.9 20.1l-8.4-3.9Z\"/><path d=\"M3.6 7.9L11.9 11.8l8.4-3.9M11.9 11.8v8.4M7.8 5.9l8.4 3.9\"/>","brain":"<path d=\"M9.2 4.2a2.8 2.8 0 0 0-2.8 2.8a2.8 2.8 0 0 0-1.9 4.8a2.8 2.8 0 0 0 1.9 4.8a2.8 2.8 0 0 0 5.8 1.1V5.8A1.9 1.9 0 0 0 9.2 4.2ZM14.9 4.2a2.8 2.8 0 0 1 2.8 2.8a2.8 2.8 0 0 1 1.9 4.8a2.8 2.8 0 0 1-1.9 4.8a2.8 2.8 0 0 1-5.8 1.1\"/>","branch":"<circle cx=\"6.7\" cy=\"5.6\" r=\"1.9\"/><circle cx=\"6.7\" cy=\"18.6\" r=\"1.9\"/><circle cx=\"17.9\" cy=\"7.5\" r=\"1.9\"/><path d=\"M6.7 7.5v9.3M17.9 9.3c0 5.1-6 3.7-10.2 7.9\"/>","bridge":"<path d=\"M3.5 16h17M7 19.5V5M17 19.5V5\"/><path d=\"M3.5 10c1.8 0 2.8-2.3 3.5-5c2.4 5.2 7.6 5.2 10 0c.7 2.7 1.7 5 3.5 5\"/><path d=\"M12 10.9V16\"/>","briefcase":"<rect x=\"3.5\" y=\"7.2\" width=\"17\" height=\"12.3\" rx=\"1.9\"/><path d=\"M9.2 7.2V5.7A1.4 1.4 0 0 1 10.6 4.3h2.8A1.4 1.4 0 0 1 14.8 5.7V7.2M3.5 12.3h7.1M13.4 12.3H20.5M10.6 11.4h2.8v2.4h-2.8Z\"/>","bug":"<rect x=\"7.2\" y=\"6.2\" width=\"9.6\" height=\"13.3\" rx=\"4.8\"/><path d=\"M9.3 6.5a2.7 2.7 0 0 1 5.3 0M12 10.9v8.5M7.2 12H3.5M20.5 12h-3.7M7.6 8.3L4.6 5.6M16.4 8.3L19.4 5.6M7.6 16.3L4.6 18.9M16.4 16.3L19.4 18.9\"/>","burst":"<path d=\"M11.9 3.7L13.6 7.8L17.8 6.2L16.2 10.4L20.3 12.1L16.2 13.8L17.8 18L13.6 16.4L11.9 20.4L10.2 16.4L6 18L7.6 13.8L3.6 12.1L7.6 10.4L6 6.2L10.2 7.8Z\"/>","busy":"<path d=\"M4.7 12h0M12.1 12h0M19.4 12h0\" stroke-width=\"4.2\"/>","calendar":"<rect x=\"4\" y=\"5.5\" width=\"16\" height=\"15\" rx=\"2\"/><path d=\"M4 10.5h16M9 3.5v4M15 3.5v4\"/>","camera":"<path d=\"M3.5 8h3.4l1.8-2.8h6.6L17.1 8h3.4v11.5h-17Z\"/><circle cx=\"12\" cy=\"13.3\" r=\"3.4\"/>","camera-flip":"<path d=\"M3.5 8h3.4l1.8-2.8h6.6L17.1 8h3.4v11.5h-17Z\"/><path d=\"M15 12.6a3.1 3.1 0 0 0-5.6-1.2M9 14.4a3.1 3.1 0 0 0 5.6 1.2M9.3 9.5v2h2M14.7 17.5v-2h-2\"/>","camera-off":"<path d=\"M3.7 7.9h3.3l1.7-2.7h6.4L16.9 7.9h3.3v11.2h-16.5Z\"/><circle cx=\"11.9\" cy=\"13\" r=\"3.3\"/><path d=\"M3.7 3.5l16.5 16.5\"/>","car":"<path d=\"M3.5 15.8v-4.5L6.2 5.8h11.6l2.7 5.5V15.8Z\"/><path d=\"M3.5 11.3h17M6.5 15.8v2.5M17.5 15.8v2.5M7.2 13.8h0M16.8 13.8h0\"/>","card":"<rect x=\"3.7\" y=\"5.8\" width=\"16.7\" height=\"12.3\" rx=\"1.8\"/><path d=\"M3.7 10.3h16.7M6.7 14.6h3.5\"/>","care":"<path d=\"M3.3 16.5h3.2l4.2 3.2h5.8a2.2 2.2 0 0 0 0-4.2H12.8M7.5 16.5l2.4-2.2h3.5\"/><path d=\"M16.6 10.6s-3.4-2.2-3.4-4.7a2.1 2.1 0 0 1 3.4-1.4a2.1 2.1 0 0 1 3.4 1.4c0 2.6-3.4 4.7-3.4 4.7Z\"/>","cart":"<path d=\"M3.5 4.1h1.9l2.2 10.2h10.2L20.3 6.9H6.5\"/><circle cx=\"9.1\" cy=\"18.6\" r=\"1.2\"/><circle cx=\"16.5\" cy=\"18.6\" r=\"1.2\"/>","cc":"<rect x=\"3.5\" y=\"5.4\" width=\"17\" height=\"13.2\" rx=\"2.4\"/><path d=\"M10.7 10.2a2.3 2.3 0 1 0 0 3.6M16.8 10.2a2.3 2.3 0 1 0 0 3.6\"/>","chart":"<path d=\"M3.9 3.4v16.7h16.7\"/><path d=\"M7.7 14.5l3.6-4.1l3.2 2.7l5.2-6.3\"/>","chat":"<path d=\"M3.8 4.3h16.8v11.7H8l-4.2 4.2Z\"/>","chat-off":"<path d=\"M4.2 5.1h15.7v10.9H8l-4 4Z\"/><path d=\"M3.7 3.7l16.7 16.7\"/>","check":"<path d=\"M3.7 12.1l5.4 5.4L20.4 6.1\"/>","check-circle":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"m8 12.4 2.8 2.8L16 9.6\"/>","check-double":"<path d=\"M3.6 11.7l3.6 3.6L14.6 7.7M11.1 15.2l0.9 0.9L20.4 7.7\"/>","chevron":"<path d=\"M7.9 3.6l8.4 8.4l-8.4 8.4\"/>","chevrons-left":"<path d=\"M10.9 19.3l-7.3-7.3l7.3-7.3M20.4 19.3l-7.3-7.3l7.3-7.3\"/>","chevrons-right":"<path d=\"M3.6 19.3l7.3-7.3l-7.3-7.3M13.1 19.3l7.3-7.3l-7.3-7.3\"/>","child":"<circle cx=\"12\" cy=\"5.8\" r=\"2.1\"/><path d=\"M7.9 10.5L12 9.2l4.1 1.4M12 9.2v5.6M9.2 20.4l2.8-5.6l2.8 5.6\"/>","chip":"<rect x=\"7.3\" y=\"7.3\" width=\"9.4\" height=\"9.4\" rx=\"1.4\"/><path d=\"M10.1 3.5v3.8M13.9 3.5v3.8M10.1 16.7v3.8M13.9 16.7v3.8M3.5 10.1h3.8M3.5 13.9h3.8M16.7 10.1h3.8M16.7 13.9h3.8\"/>","circle":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/>","city":"<path d=\"M3.5 19.7h17M5.4 19.7V8.3h4.7v11.3M10.1 19.7V3.6h5.7v16.1M15.8 19.7v-7.6h3.3v7.6M7.8 11.2h0M7.8 14.5h0M12.9 6.9h0M12.9 10.2h0M12.9 13.5h0\"/>","clapper":"<path d=\"M4 10.4h16.3v8.6a1 1 0 0 1-1 1h-14.4a1 1 0 0 1-1-1Z\"/><path d=\"M4 10.4L3.4 7.2l15.7-2.9l0.7 3.3ZM8.5 6.3l2.2 3.2M13.3 5.4l2.2 3.2\"/>","clipboard":"<rect x=\"5.7\" y=\"5\" width=\"12.6\" height=\"15.5\" rx=\"1.9\"/><path d=\"M9.1 5V3.5h5.8V5M9.1 10.8h5.8M9.1 14.7h3.9\"/>","clipboard-check":"<rect x=\"5.7\" y=\"4.9\" width=\"12.6\" height=\"15.5\" rx=\"1.9\"/><path d=\"M9.1 4.9V3.5h5.8V4.9M9.1 13.2l1.9 1.9l3.9-3.9\"/>","clock":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M12 7.5V12l3 2\"/>","close":"<path d=\"M3.7 3.7l16.7 16.7M20.4 3.7L3.7 20.4\"/>","cloud":"<path d=\"M7.8 17.6h8.9a3.4 3.4 0 0 0 0.5-7A5.2 5.2 0 0 0 6.9 10.1A3.7 3.7 0 0 0 7.8 17.6Z\"/>","cloud-off":"<path d=\"M7.5 17.6h9.5a3.8 3.8 0 0 0 0.5-7.5A5.7 5.7 0 0 0 6.4 9.6A4.1 4.1 0 0 0 7.5 17.6Z\"/><path d=\"M4 3.9l16.1 16.1\"/>","code":"<path d=\"M7.8 7.8l-4.2 4.2l4.2 4.2M16.2 7.8l4.2 4.2l-4.2 4.2M14.2 4.6l-4.2 14.8\"/>","coin":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M14.5 9.2c-.5-.8-1.4-1.2-2.5-1.2-1.5 0-2.5.8-2.5 2s1 1.6 2.5 2 2.5.8 2.5 2-1 2-2.5 2c-1.1 0-2-.4-2.5-1.2M12 6.5v11\"/>","columns":"<rect x=\"3.5\" y=\"4.5\" width=\"17\" height=\"15\" rx=\"2\"/><path d=\"M12 4.5v15M3.5 9h17\"/>","commit":"<circle cx=\"12\" cy=\"12\" r=\"3.4\"/><path d=\"M3.5 12h5.1M15.4 12H20.5\"/>","compass":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"m15.6 8.4-2.1 5.1-5.1 2.1 2.1-5.1Z\"/>","compress":"<path d=\"M8.8 3.5v5.3H3.5M15.2 3.5v5.3h5.3M8.8 20.5v-5.3H3.5M15.2 20.5v-5.3h5.3\"/>","computer":"<rect x=\"3.5\" y=\"4.5\" width=\"17\" height=\"11.3\" rx=\"1.9\"/><path d=\"M8.2 19.7h7.6M12 15.9v3.8\"/>","contrast":"<circle cx=\"11.7\" cy=\"12\" r=\"8.4\"/><path d=\"M11.7 3.6v16.7M11.7 7.9h6.5M11.7 12h8.4M11.7 16.2h6.5\"/>","cookie":"<path d=\"M20.6 12.3a8.4 8.4 0 1 1-8.9-8.9a3 3 0 0 0 3.9 3.5a3 3 0 0 0 4.9 5.3Z\"/><path d=\"M8.8 9.4h0M12.2 13.8h0M8.3 15.3h0M15.7 15.3h0\"/>","copy":"<rect x=\"8\" y=\"8\" width=\"12.5\" height=\"12.5\" rx=\"2.1\"/><path d=\"M16.3 8V5.8a2.1 2.1 0 0 0-2.1-2.1H5.8a2.1 2.1 0 0 0-2.1 2.1v8.4a2.1 2.1 0 0 0 2.1 2.1h2.1\"/>","couch":"<path d=\"M5.4 10.1V7.2a1.9 1.9 0 0 1 1.9-1.9h9.4a1.9 1.9 0 0 1 1.9 1.9v2.8\"/><path d=\"M3.5 11.9a1.9 1.9 0 0 1 3.8 0v1.4h9.4V11.9a1.9 1.9 0 0 1 3.8 0v4.7H3.5ZM5.4 16.7v1.9M18.6 16.7v1.9\"/>","crown":"<path d=\"M3.5 8.3L7.8 12L11.9 5.5l4.2 6.5l4.2-3.7L18.4 18.1H5.4Z\"/>","cubes":"<path d=\"M12 3.2L8.1 5.2v4.4l3.9 2l3.9-2V5.2ZM7.6 11.6L3.6 13.6v4.4l3.9 2l3.9-2V13.6ZM16.4 11.6l-3.9 2v4.4l3.9 2l3.9-2V13.6Z\"/>","database":"<ellipse cx=\"12\" cy=\"6.4\" rx=\"6.6\" ry=\"2.8\"/><path d=\"M5.4 6.4v11.3c0 1.6 2.9 2.8 6.6 2.8s6.6-1.2 6.6-2.8V6.4M5.4 12c0 1.6 2.9 2.8 6.6 2.8s6.6-1.2 6.6-2.8\"/>","dice":"<rect x=\"3.7\" y=\"3.6\" width=\"16.7\" height=\"16.7\" rx=\"3\"/><path d=\"M8.6 8.6h0M15.5 8.6h0M12.1 12h0M8.6 15.4h0M15.5 15.4h0\" stroke-width=\"2.6\"/>","dish":"<path d=\"M3.8 9.2A10.9 10.9 0 0 0 14.8 20.2Z\"/><path d=\"M9 15l5.2-5.2M14.8 3.4a5.8 5.8 0 0 1 5.8 5.8M14.8 7.5a1.8 1.8 0 0 1 1.8 1.8\"/>","dollar":"<path d=\"M15.7 8C15 6.6 13.7 5.6 12 5.6C9.6 5.6 8.3 6.9 8.3 8.5s1.6 2.5 3.7 2.8s3.7 1.3 3.7 3.1s-1.5 3-3.7 3c-1.8 0-3.2-0.9-3.7-2.4M12 3.7v16.7\"/>","donate":"<circle cx=\"11.9\" cy=\"8.4\" r=\"5.2\"/><path d=\"M11.9 6v4.3M3.7 15.6h16.5V20H3.7ZM8.3 17.7h7.2\"/>","door":"<path d=\"M13.7 4.7L6.3 3.2v16.7l7.5-1.4ZM13.7 5.1h3.3a0.9 0.9 0 0 1 0.9 0.9v14M3.5 20h16.7M11 11.6h0\"/>","down":"<path d=\"M3.6 7.9l8.4 8.4l8.4-8.4\"/>","download":"<path d=\"M12 3.2v11.5M6.8 9.5l5.2 5.2l5.2-5.2M4.7 20h14.7\"/>","drive":"<rect x=\"3.5\" y=\"12.3\" width=\"17\" height=\"6.6\" rx=\"1.9\"/><path d=\"M3.9 12.3L6.8 4.7h10.4l2.8 7.6M16.7 15.6h0M13.8 15.6h0\"/>","drop":"<path d=\"M12 3.5s5.9 6.4 5.9 10.8a5.9 5.9 0 0 1-11.8 0c0-4.4 5.9-10.8 5.9-10.8Z\"/>","ear":"<path d=\"M6.2 9.5a5.7 5.7 0 1 1 10.8 2.4c-0.9 1.9-3.1 3-3.1 5.2a3.4 3.4 0 0 1-6.5 1.6\"/><path d=\"M9.7 9.9a2.4 2.4 0 1 1 3.8 1.5\"/>","enter":"<path d=\"M13.8 3.6h4.1a2.1 2.1 0 0 1 2.1 2.1v12.5a2.1 2.1 0 0 1-2.1 2.1h-4.1M9.5 7.9l4.1 4.1l-4.1 4.1M13.8 12H3.2\"/>","eraser":"<path d=\"M14.3 4.4l5.5 5.5l-9 9H6.6l-2.6-2.6a1.5 1.5 0 0 1 0-2.1Z\"/><path d=\"M9.3 9.4l5.5 5.5M10.8 18.9H20.3\"/>","expand":"<path d=\"M3.5 8.8V3.5h5.3M20.5 8.8V3.5h-5.3M3.5 15.2v5.3h5.3M20.5 15.2v5.3h-5.3\"/>","eye":"<path d=\"M3.5 12S6.6 6.2 12 6.2S20.5 12 20.5 12S17.4 17.8 12 17.8S3.5 12 3.5 12Z\"/><circle cx=\"12\" cy=\"12\" r=\"2.7\"/>","eye-off":"<path d=\"M3.7 12s3.2-6.1 8.3-6.1c1.5 0 2.7 0.5 4 1.1M20.5 12s-3.2 6.1-8.3 6.1c-1.5 0-2.7-0.5-4-1.1\"/><path d=\"M10.1 9.9a2.7 2.7 0 0 0 3.9 3.9M4.6 4.5l14.9 14.9\"/>","fast-forward":"<path d=\"M4 6.4v11.2L11.6 12ZM13.1 6.4v11.2L20.7 12Z\"/>","file":"<path d=\"M6.4 3.5h7.6l3.8 3.8v13.2H6.4Z\"/><path d=\"M13.9 3.5v3.8h3.8\"/>","file-code":"<path d=\"M13.9 3.5H7.4a1.9 1.9 0 0 0-1.9 1.9v13a1.9 1.9 0 0 0 1.9 1.9h9.3a1.9 1.9 0 0 0 1.9-1.9V8.2Z\"/><path d=\"M13.9 3.5v4.6h4.6M10.1 12.4L8.3 14.3l1.9 1.9M13.9 12.4l1.9 1.9l-1.9 1.9\"/>","film":"<rect x=\"3.5\" y=\"4.4\" width=\"17\" height=\"15.1\" rx=\"1.9\"/><path d=\"M7.7 4.4v15.1M16.3 4.4v15.1M3.5 9.2h4.3M3.5 14.8h4.3M16.3 9.2H20.5M16.3 14.8H20.5\"/>","filter":"<path d=\"M3.5 6h17M6.7 12.4h10.6M9.9 18.7h4.2\"/>","finish":"<path d=\"M5.2 20.7V4\"/><rect x=\"5.2\" y=\"4.6\" width=\"13.9\" height=\"8.6\" rx=\"1\"/><path d=\"M5.2 8.8h13.9M9.6 4.6v8.6M14.3 4.6v8.6\"/>","fire":"<path d=\"M12 20.5a6.8 6.8 0 0 0 6.8-6.8c0-4.3-2.9-6.8-4.2-10.1c-2 2.3-2.6 4.5-2.6 4.5S10.7 5.8 9.5 3.8C7 6.6 5.2 10 5.2 13.7a6.8 6.8 0 0 0 6.8 6.8Z\"/>","fish":"<path d=\"M8.1 12c1.9-3.1 4.3-4.6 7.2-4.6c2.5 0 4.3 1.9 5.2 4.6c-1 2.7-2.8 4.6-5.2 4.6c-2.9 0-5.2-1.6-7.2-4.6Z\"/><path d=\"M8.1 12L3.7 8.8v6.7Z\"/><path d=\"M16.5 10.7h0\"/>","fist":"<path d=\"M6.4 10.1V6.4a1.6 1.6 0 0 1 3.2 0V9.1M9.5 8.5v-3.2a1.6 1.6 0 0 1 3.2 0v3.2M12.6 8.5v-2.1a1.6 1.6 0 0 1 3.2 0V9.1M15.7 9.1V8a1.6 1.6 0 0 1 3.2 0v5.2a7.3 7.3 0 0 1-7.3 7.3H11.1a5.7 5.7 0 0 1-5.7-5.7V12.3A2.1 2.1 0 0 1 7.4 11.2h2.1a1.6 1.6 0 0 1 0 3.2H8\"/>","flag":"<path d=\"M6.7 20.7V4M6.7 4h10.8l-2 3.9l2 3.9H6.7\"/>","flask":"<path d=\"M9.5 3.2h4.9M10 3.2V9.2L4.9 17.7a1.5 1.5 0 0 0 1.3 2.3h11.6a1.5 1.5 0 0 0 1.3-2.3L14 9.2V3.2\"/><path d=\"M7.3 14.6h9.5\"/>","focus":"<circle cx=\"12\" cy=\"12\" r=\"2.8\"/><path d=\"M3.5 9V5.9A2.4 2.4 0 0 1 5.9 3.5H9M15 3.5h3.1A2.4 2.4 0 0 1 20.5 5.9V9M20.5 15v3.1a2.4 2.4 0 0 1-2.4 2.4H15M9 20.5H5.9A2.4 2.4 0 0 1 3.5 18.1V15\"/>","folder":"<path d=\"M3.8 7.3a1.9 1.9 0 0 1 1.9-1.9h3.6l1.9 1.9h7.4a1.9 1.9 0 0 1 1.9 1.9v7.4a1.9 1.9 0 0 1-1.9 1.9H5.8a1.9 1.9 0 0 1-1.9-1.9Z\"/>","folder-tree":"<path d=\"M4.1 3.3v13.2a1 1 0 0 0 1 1H9.7M4.1 8.3H9.7\"/><path d=\"M10.2 5.3h3.1l1 1.5h5.6V10.9h-9.7ZM10.2 14.5h3.1l1 1.5h5.6V20.1h-9.7Z\"/>","food":"<path d=\"M8.2 4v6.5M6.3 4v4.2a1.9 1.9 0 0 0 3.7 0V4M8.2 10.5v10.2M17.5 20.8V4c-1.9 1.1-3 3.3-3 6.5v2.8H17.5\"/>","game":"<rect x=\"3.7\" y=\"7.2\" width=\"16.7\" height=\"9.7\" rx=\"4.4\"/><path d=\"M7.6 10.7v2.7M6.4 12h2.7M15.1 11.6h0M17.4 12.9h0\"/>","gauge":"<path d=\"M3.6 16.9a8.4 8.4 0 1 1 16.7 0\"/><path d=\"M11.9 13.2l4.1-5.7\"/><circle cx=\"11.9\" cy=\"13.2\" r=\"1.4\"/>","gavel":"<path d=\"M13.7 4.1l5.1 5.1M10.5 7.4l5.1 5.1M12.1 5.8l-5.6 5.6l5 5l5.6-5.6M3.5 19.5l5.9-5.9M13.7 19.5h6.5\"/>","gear":"<circle cx=\"12\" cy=\"12.1\" r=\"2.3\"/><path d=\"M17.6 14.3a1.3 1.3 0 0 0 0.2 1.5l0.1 0.1a1.5 1.5 0 1 1-2.4 2.4l-0.1-0.1a1.3 1.3 0 0 0-1.5-0.2a1.3 1.3 0 0 0-0.8 1.3V18.8a1.5 1.5 0 1 1-3.4 0v-0.1a1.3 1.3 0 0 0-0.9-1.3a1.3 1.3 0 0 0-1.5 0.2l-0.1 0.1a1.5 1.5 0 1 1-2.4-2.4l0.1-0.1a1.3 1.3 0 0 0 0.2-1.5a1.3 1.3 0 0 0-1.3-0.8H5.4a1.5 1.5 0 1 1 0-3.4h0.1a1.3 1.3 0 0 0 1.3-0.9a1.3 1.3 0 0 0-0.2-1.5l-0.1-0.1a1.5 1.5 0 1 1 2.4-2.4l0.1 0.1a1.3 1.3 0 0 0 1.5 0.2H9.8a1.3 1.3 0 0 0 0.8-1.3V5.4a1.5 1.5 0 1 1 3.4 0v0.1a1.3 1.3 0 0 0 0.8 1.3a1.3 1.3 0 0 0 1.5-0.2l0.1-0.1a1.5 1.5 0 1 1 2.4 2.4l-0.1 0.1a1.3 1.3 0 0 0-0.2 1.5V9.8a1.3 1.3 0 0 0 1.3 0.8H18.7a1.5 1.5 0 1 1 0 3.4h-0.1a1.3 1.3 0 0 0-1.3 0.8Z\"/>","gem":"<path d=\"M6.8 4.9h10.3L20.2 9.5l-8.3 10.3L3.6 9.5Z\"/><path d=\"M3.6 9.5h16.7M9.6 4.9L8.1 9.5l3.8 10.3M14.2 4.9L15.7 9.5l-3.8 10.3\"/>","ghost":"<path d=\"M4.7 20V10.6a7.3 7.3 0 0 1 14.7 0v9.4l-2.4-1.9l-2.5 1.9l-2.4-1.9L9.6 20l-2.5-1.9Z\"/><path d=\"M9.4 10.1h0M14.6 10.1h0\"/>","gift":"<rect x=\"4.4\" y=\"8.8\" width=\"15.1\" height=\"10.4\" rx=\"0.9\"/><path d=\"M3.5 8.8h17M12 8.8v10.4M12 8.8c-1.4-2.8-4.7-3.8-4.7-1.4S12 8.8 12 8.8s4.7-0.9 4.7-3.3S13.4 6 12 8.8\"/>","globe":"<circle cx=\"11.9\" cy=\"11.9\" r=\"8.3\"/><path d=\"M3.6 11.9h16.7M11.9 3.6c2.7 2.7 2.7 13.9 0 16.7M11.9 3.6c-2.7 2.7-2.7 13.9 0 16.7\"/>","grid":"<rect x=\"3.5\" y=\"3.5\" width=\"7.4\" height=\"7.4\" rx=\"1.6\"/><rect x=\"13.1\" y=\"3.5\" width=\"7.4\" height=\"7.4\" rx=\"1.6\"/><rect x=\"3.5\" y=\"13.1\" width=\"7.4\" height=\"7.4\" rx=\"1.6\"/><rect x=\"13.1\" y=\"13.1\" width=\"7.4\" height=\"7.4\" rx=\"1.6\"/>","grip":"<path d=\"M8.3 4.5h0M15.8 4.5h0M8.3 12h0M15.8 12h0M8.3 19.5h0M15.8 19.5h0\" stroke-width=\"3.8\"/>","group":"<path d=\"M3.5 7.3V3.5h3.8M16.7 3.5h3.8v3.8M20.5 16.7v3.8h-3.8M7.3 20.5H3.5v-3.8\"/><rect x=\"7.3\" y=\"7.3\" width=\"5.7\" height=\"4.7\" rx=\"0.9\"/><rect x=\"11.1\" y=\"12\" width=\"5.7\" height=\"4.7\" rx=\"0.9\"/>","hammer":"<path d=\"M13.1 3.7L19.3 9.9l-2.7 2.7l-6.2-6.2Z\"/><path d=\"M13.6 9.4L5 18.1a1.3 1.3 0 0 0 1.9 1.9l8.6-8.6\"/>","hand":"<path d=\"M9.9 13.6V6.2a1.5 1.5 0 0 1 3 0V11.7M12.8 10.7V5.3a1.5 1.5 0 0 1 3 0V11.7M15.8 11.2V6.7a1.5 1.5 0 0 1 3 0v6.9c0 3.9-2.5 6.9-6.4 6.9c-2.6 0-4.1-1.3-5.6-3.5L4.9 14.2a1.5 1.5 0 0 1 2.4-1.8L9.9 15.6\"/>","handshake":"<path d=\"M3.6 9.9v5.3M20.3 9.9v5.3\"/><path d=\"M3.6 10.8h2.2l3.5-2.7l2.9 1.3L14.5 8.2l3.5 2.7h2.2\"/><path d=\"M5.7 10.8l6 6a1.2 1.2 0 0 0 1.7-1.6M13.4 15.3l0.5 0.5a1.2 1.2 0 0 0 1.7-1.6M15.6 14.2l0.3 0.3a1.2 1.2 0 0 0 1.7-1.6L18 10.8M17.5 12.8l-4-4l-2.3 2.1a1.3 1.3 0 0 1-1.9-1.9l1.9-2\"/>","hash":"<path d=\"M5 9h15M4 15h15M10 3.5 8 20.5M16 3.5l-2 17\"/>","heading":"<path d=\"M5.6 3.5v17M18.4 3.5v17M5.6 12h12.7M3.5 3.5h4.2M16.2 3.5h4.2M3.5 20.5h4.2M16.2 20.5h4.2\"/>","headphones":"<path d=\"M4.4 13.9v-2.8a7.5 7.5 0 0 1 14.9 0v2.8\"/><rect x=\"3.5\" y=\"13\" width=\"4.2\" height=\"6\" rx=\"1.4\"/><rect x=\"16.1\" y=\"13\" width=\"4.2\" height=\"6\" rx=\"1.4\"/>","heart":"<path d=\"M11.9 20.3s-8.1-5.1-8.1-11.6a4.6 4.6 0 0 1 8.1-3A4.6 4.6 0 0 1 20 9.2c0 6.5-8.1 11.6-8.1 11.6Z\"/>","heart-broken":"<path d=\"M11.9 20.3s-8.1-5.1-8.1-11.6a4.6 4.6 0 0 1 8.1-3A4.6 4.6 0 0 1 20 9.2c0 6.5-8.1 11.6-8.1 11.6Z\"/><path d=\"M11.9 5.8L10 9.8l3.5 2.3l-1.6 3.9\"/>","heart-pulse":"<path d=\"M19 12.3c-1.7 3.6-7 6.7-7 6.7s-5.3-3.1-7-6.7M4.3 9.7A4.3 4.3 0 0 1 12 7.4a4.3 4.3 0 0 1 7.7 2.2\"/><path d=\"M3.5 11.9h4l1.3-2.2l2.2 4.5l1.8-3.1h7.6\"/>","help":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.8M12 17h.01\"/>","history":"<path d=\"M3.8 12.1a8.3 8.3 0 1 0 2.5-5.9L3.8 8.5M3.8 4.2v4.2h4.2\"/><path d=\"M12 7.9v4.3l3.1 1.9\"/>","home":"<path d=\"M3.6 10.6L12 3.3l8.4 7.3v8.4a1 1 0 0 1-1 1h-4.2v-6.3h-6.3v6.3H4.7a1 1 0 0 1-1-1Z\"/>","hot-tub":"<circle cx=\"8.6\" cy=\"5.8\" r=\"1.7\"/><path d=\"M3.8 11.9h16.6v3.3a4.2 4.2 0 0 1-4.2 4.2h-8.4A4.2 4.2 0 0 1 3.8 15.2ZM6.8 11.9v-1.4A2.4 2.4 0 0 1 8.9 8.2H9.4M13.7 9.5c0.8-0.9-0.8-1.9 0-2.8M17.3 9.5c0.8-0.9-0.8-1.9 0-2.8\"/>","hourglass":"<path d=\"M6 3.5h12M6 20.5h12M7.5 3.5c0 4.5 4.5 5.5 4.5 8.5s-4.5 4-4.5 8.5M16.5 3.5c0 4.5-4.5 5.5-4.5 8.5s4.5 4 4.5 8.5\"/>","id":"<rect x=\"3.7\" y=\"5.5\" width=\"16.7\" height=\"13\" rx=\"1.9\"/><circle cx=\"9.2\" cy=\"11\" r=\"1.9\"/><path d=\"M6.4 15.6c0.5-1.4 1.6-2.2 2.8-2.2s2.4 0.8 2.8 2.2M14.8 10.1h2.8M14.8 13.4h2.8\"/>","idea":"<path d=\"M9.1 17.6h5.8M10.1 20.5h3.9M8.7 14.3A5.8 5.8 0 1 1 15.2 14.3c-0.7 0.6-1.4 1.5-1.4 2.3h-3.9c0-0.9-0.7-1.7-1.4-2.3Z\"/>","image":"<rect x=\"3.5\" y=\"5.4\" width=\"16.7\" height=\"13\" rx=\"2.8\"/><circle cx=\"9.1\" cy=\"10\" r=\"1.5\"/><path d=\"M20.3 15.6l-4.7-4.7l-7.4 7.4\"/>","inbox":"<path d=\"M3.5 12.7L5.9 4.8h12.1l2.4 7.9V17.8a0.9 0.9 0 0 1-0.9 0.9H4.4a0.9 0.9 0 0 1-0.9-0.9ZM3.5 12.7h4.6l1.4 2.4h4.6l1.4-2.4h4.6\"/>","infinity":"<path d=\"M12 12c-1.8-2.4-3.3-3.6-4.9-3.6a3.6 3.6 0 0 0 0 7.2c1.6 0 3.1-1.2 4.9-3.6Zm0 0c1.8 2.4 3.3 3.6 4.9 3.6a3.6 3.6 0 0 0 0-7.2c-1.6 0-3.1 1.2-4.9 3.6Z\"/>","info":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M12 11v5M12 8h.01\"/>","invite":"<circle cx=\"9.2\" cy=\"8.2\" r=\"2.9\"/><path d=\"M3.8 18.8c0-3 2.4-4.8 5.3-4.8s5.3 2 5.3 4.8M17.9 8.2v5.3M15.3 10.9h5.3\"/>","key":"<circle cx=\"8.1\" cy=\"15.9\" r=\"4.3\"/><path d=\"M11.1 12.9l9.4-9.4M16.8 7.3l3.2 3.2M14.6 9.4l2.2 2.2\"/>","keyboard":"<rect x=\"3.5\" y=\"6.6\" width=\"17\" height=\"10.7\" rx=\"1.8\"/><path d=\"M6.6 10.2h0M9.3 10.2h0M12 10.2h0M14.7 10.2h0M17.4 10.2h0M8.4 13.8h7.2\"/>","language":"<path d=\"M4.6 5.4h8.4M8.8 3.6v1.9M6.4 5.4c0.5 2.8 2.3 5.6 5.6 7M11.1 5.4c-0.6 3.2-2.6 6.3-6 7.9M12 20.3l3.7-8.4l3.7 8.4M13.3 17.5h4.8\"/>","laugh":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M8 13.5h8a4 4 0 0 1-8 0ZM7.5 8.6l2.2 1.2-2.2 1.2M16.5 8.6l-2.2 1.2 2.2 1.2\"/>","layers":"<path d=\"M12 5.4l8.3 4.6l-8.3 4.6l-8.3-4.6Z\"/><path d=\"M3.8 14l8.3 4.6l8.3-4.6\"/>","leaf":"<path d=\"M3.8 20.1c0-9.5 5.6-15.2 16.7-16.7c-1 11.1-6.7 16.7-16.7 16.7ZM3.8 20.1l8.9-8.9\"/>","leave":"<path d=\"M12.7 3.5h4.2a2.1 2.1 0 0 1 2.1 2.1v12.7a2.1 2.1 0 0 1-2.1 2.1h-4.2M8.5 16.2l-4.2-4.2l4.2-4.2M4.2 12h10.6\"/>","lifebuoy":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><circle cx=\"12\" cy=\"12\" r=\"3.8\"/><path d=\"M6 6l3.4 3.4M14.6 14.7l3.4 3.4M18 6l-3.4 3.4M9.4 14.7l-3.4 3.4\"/>","link":"<path d=\"M9.9 14.1a4.3 4.3 0 0 0 6.1 0l3.2-3.2a4.3 4.3 0 0 0-6.1-6.1l-1.1 1.1M14.1 9.9a4.3 4.3 0 0 0-6.1 0l-3.2 3.2a4.3 4.3 0 0 0 6.1 6.1l1.1-1.1\"/>","link-off":"<path d=\"M10 13.8a4 4 0 0 0 5.6 0l2.9-2.9a4 4 0 0 0-5.6-5.6l-1 1M13.8 10a4 4 0 0 0-5.6 0l-2.9 2.9a4 4 0 0 0 5.6 5.6l1-1\"/><path d=\"M3.6 3.6l16.7 16.7\"/>","list":"<path d=\"M8.2 5.5h11.9M8.2 12h11.9M8.2 18.5h11.9M3.3 5.5h0M3.3 12h0M3.3 18.5h0\"/>","list-ordered":"<path d=\"M9.7 6.8h10.8M9.7 12.8h10.8M9.7 18.8h10.8M3.8 5.4L4.7 5v4.4M3.8 9.3h2M3.8 13.7a1 1 0 0 1 2 0.3c0 1.1-2 1.7-2 2.7h2.2\"/>","live":"<circle cx=\"12\" cy=\"11.8\" r=\"2\"/><path d=\"M8.6 8.4a4.9 4.9 0 0 0 0 7.1M15.4 8.4a4.9 4.9 0 0 1 0 7.1M6.3 6.1a8.1 8.1 0 0 0 0 11.9M17.7 6.1a8.1 8.1 0 0 1 0 11.9\"/>","lock":"<rect x=\"4.7\" y=\"10.7\" width=\"14.5\" height=\"9.4\" rx=\"2.1\"/><path d=\"M7.8 10.7V7.5a4 4 0 0 1 8.3 0v3.2\"/>","lungs":"<path d=\"M12 3.5v8M12 9.4l-3 2.1M12 9.4l3 2.1M8.4 6.5C5.6 6.5 3.5 10.4 3.5 15.2c0 2.8 1.1 4.8 3.2 4.8s2.7-2.1 2.7-4.8V8.3M15.6 6.5C18.4 6.5 20.5 10.4 20.5 15.2c0 2.8-1.1 4.8-3.2 4.8s-2.7-2.1-2.7-4.8V8.3\"/>","mail":"<rect x=\"3.5\" y=\"5.5\" width=\"17\" height=\"13.2\" rx=\"1.9\"/><path d=\"M4.4 7.4l7.6 5.7l7.6-5.7\"/>","map":"<path d=\"M9.1 4.6L3.5 6.5v13l5.6-1.9l5.6 1.9l5.6-1.9V4.6l-5.6 1.9Z\"/><path d=\"M9.1 4.6v13M14.7 6.5v13\"/>","mask":"<path d=\"M3.5 7c3 1.3 5.8 1.8 8.5 1.8S17.5 8.3 20.5 7c0 6-2.5 10-5 10-1.4 0-2-1.5-3.5-1.5S9.9 17 8.5 17c-2.5 0-5-4-5-10Z\"/><path d=\"M7.5 11.5h2M14.5 11.5h2\"/>","medical":"<rect x=\"3.5\" y=\"7.1\" width=\"17\" height=\"12.3\" rx=\"1.9\"/><path d=\"M9.2 7.1V5.2a0.9 0.9 0 0 1 0.9-0.9h3.8a0.9 0.9 0 0 1 0.9 0.9v1.9M12 10.4v5.7M9.2 13.3h5.7\"/>","megaphone":"<path d=\"M3.6 9.5v4a1 1 0 0 0 1 1H7.1l9 4.5v-15L7.1 8.5H4.5a1 1 0 0 0-1 1Z\"/><path d=\"M7.1 14.5l1.3 5h2.4L10.1 15.1M19.1 9a3.2 3.2 0 0 1 0 5\"/>","members":"<circle cx=\"9.2\" cy=\"7.9\" r=\"3\"/><path d=\"M3.6 19.1c0-3.1 2.6-5.1 5.6-5.1s5.6 2.1 5.6 5.1M15.7 5a3 3 0 0 1 0 5.9M17.6 14.3c1.8 0.6 2.8 2.3 2.8 4.8\"/>","menu":"<path d=\"M3.5 6.7h17M3.5 12h17M3.5 17.3h17\"/>","mic":"<rect x=\"9.2\" y=\"3.5\" width=\"5.7\" height=\"10.4\" rx=\"2.8\"/><path d=\"M5.4 11.1a6.6 6.6 0 0 0 13.2 0M12 17.7v2.8\"/>","mic-off":"<rect x=\"9.2\" y=\"3.6\" width=\"5.7\" height=\"10.4\" rx=\"2.8\"/><path d=\"M5.5 11.1a6.6 6.6 0 0 0 11 5M18.7 11.1c0 0.8-0.1 1.5-0.4 2.2M12.1 17.7v2.8M4.5 4.5l15.1 15.1\"/>","minus":"<path d=\"M3.5 12h17\"/>","minus-circle":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M8 12h8\"/>","money":"<rect x=\"3.5\" y=\"6.6\" width=\"17\" height=\"10.7\" rx=\"1.8\"/><circle cx=\"12\" cy=\"12\" r=\"2.2\"/><path d=\"M6.6 9.8v0M17.4 14.2v0\"/>","moon":"<path d=\"M20.5 14.6A8.6 8.6 0 1 1 9.4 3.5a6.9 6.9 0 0 0 11.2 11.2Z\"/>","more":"<circle cx=\"5.2\" cy=\"12\" r=\"1.6\"/><circle cx=\"12\" cy=\"12\" r=\"1.6\"/><circle cx=\"18.8\" cy=\"12\" r=\"1.6\"/>","mountain":"<path d=\"M3.6 17.5L9.3 6l3.7 6.3L15.5 9.1L20.3 17.5Z\"/>","move":"<path d=\"M11.9 3.6v16.7M3.6 11.9h16.7M9 6.5l2.9-2.9l2.9 2.9M9 17.3l2.9 2.9l2.9-2.9M6.5 9l-2.9 2.9l2.9 2.9M17.3 9l2.9 2.9l-2.9 2.9\"/>","mug":"<path d=\"M4.8 9.1h11v7a4 4 0 0 1-4 4h-3a4 4 0 0 1-4-4Z\"/><path d=\"M15.8 10.5H17.3a2.5 2.5 0 0 1 0 5h-1.5M8.3 3.5c-0.8 1 0.8 2 0 3M11.8 3.5c-0.8 1 0.8 2 0 3\"/>","music":"<path d=\"M8.6 17.7V5.6l11.1-2.1v12.2\"/><circle cx=\"6\" cy=\"17.7\" r=\"2.6\"/><circle cx=\"17.2\" cy=\"15.7\" r=\"2.6\"/>","mute":"<path d=\"M3.9 9.4h3.6L12.2 5.8v12.4l-4.6-3.6H3.9Z\"/><path d=\"M16.3 9.9l4.1 4.1M20.5 9.9l-4.1 4.1\"/>","news":"<path d=\"M3.5 3.5h13.8v14.9a2.1 2.1 0 0 0 2.1 2.1H5.6a2.1 2.1 0 0 1-2.1-2.1Z\"/><path d=\"M17.3 7.7h3.2v10.6a2.1 2.1 0 0 1-2.1 2.1M7.2 7.7h6.4M7.2 11.5h6.4M7.2 15.2h3.7\"/>","nodes":"<circle cx=\"12\" cy=\"5.4\" r=\"2.1\"/><circle cx=\"5.8\" cy=\"17.9\" r=\"2.1\"/><circle cx=\"18.2\" cy=\"17.9\" r=\"2.1\"/><path d=\"M11 7.4L6.8 15.9M13 7.4l4.3 8.6M7.9 17.9h8.3\"/>","open":"<path d=\"M14 3.8h6.3v6.3M20.2 3.8l-9.4 9.4M18.2 14.2v4.2a2.1 2.1 0 0 1-2.1 2.1H5.6a2.1 2.1 0 0 1-2.1-2.1V7.9a2.1 2.1 0 0 1 2.1-2.1h4.2\"/>","palette":"<path d=\"M12.2 3.8a8.4 8.4 0 1 0 0 16.8c1.1 0 1.7-0.9 1.7-1.6c0-1.1-0.9-1.5-0.9-2.6s0.9-1.6 1.9-1.6H16.9a3.7 3.7 0 0 0 3.7-3.7C20.6 7.2 16.9 3.8 12.2 3.8Z\"/><circle cx=\"8\" cy=\"11.4\" r=\"1.1\"/><circle cx=\"10.3\" cy=\"7.9\" r=\"1.1\"/><circle cx=\"14.5\" cy=\"7.9\" r=\"1.1\"/>","parking":"<rect x=\"3.7\" y=\"3.7\" width=\"16.7\" height=\"16.7\" rx=\"3\"/><path d=\"M9.6 17V7.2h3.4a3 3 0 0 1 0 5.9H9.6\"/>","pause":"<path d=\"M7.1 3.5v17M16.9 3.5v17\"/>","paw":"<path d=\"M7.8 18.5c0-3 1.9-5.3 4.2-5.3s4.2 2.3 4.2 5.3c0 1.4-1.1 2.1-2.1 2.1c-0.8 0-1.4-0.5-2.1-0.5s-1.3 0.5-2.1 0.5c-1.1 0-2.1-0.7-2.1-2.1Z\"/><circle cx=\"5.4\" cy=\"10.6\" r=\"1.9\"/><circle cx=\"9.2\" cy=\"5.7\" r=\"1.9\"/><circle cx=\"14.8\" cy=\"5.7\" r=\"1.9\"/><circle cx=\"18.6\" cy=\"10.6\" r=\"1.9\"/>","pencil":"<path d=\"M11.9 19.6h8.4M16.1 4.2a2 2 0 0 1 2.8 2.8L7.2 18.7l-3.7 0.9l0.9-3.7Z\"/>","phone":"<path d=\"M5.7 3.4h3.9l2 4.9l-2.5 1.5a10.8 10.8 0 0 0 4.9 4.9L15.6 12.3l4.9 2v3.9a2 2 0 0 1-2 2A15.8 15.8 0 0 1 3.8 5.4a2 2 0 0 1 2-2\"/>","phone-off":"<path d=\"M5.6 4.1h3.7l1.9 4.7l-2.4 1.4a10.3 10.3 0 0 0 4.7 4.7L15.1 12.5l4.7 1.9v3.7a1.9 1.9 0 0 1-1.9 1.9A15.1 15.1 0 0 1 3.8 5.9a1.9 1.9 0 0 1 1.9-1.9\"/><path d=\"M4.3 3.6l16.1 16.1\"/>","piggy":"<path d=\"M3.5 11.4c0-3.2 3.3-5.7 7.7-5.7c1.3 0 2.4 0.2 3.4 0.6L17.4 4.7v3.3c0.8 0.7 1.4 1.6 1.6 2.5h1.4v3.4H18.8c-0.5 1-1.3 1.7-2.3 2.3V19.1h-2.9v-1.9c-0.8 0.2-1.7 0.3-2.5 0.3c-0.8 0-1.6-0.1-2.3-0.3v1.9h-2.9v-2.9C4.4 15.1 3.5 13.4 3.5 11.4Z\"/><path d=\"M14.9 10.1h0M8.7 8.1h2.9\"/>","pin":"<path d=\"M12 20.7s-6.2-5.4-6.2-10.5a6.2 6.2 0 0 1 12.4 0C18.2 15.2 12 20.7 12 20.7Z\"/><circle cx=\"12\" cy=\"10\" r=\"2.3\"/>","plane":"<path d=\"M20 4L3.5 10.8l6.6 3.1l3.1 6.6Z\"/><path d=\"M10.1 13.9l9.9-9.9\"/>","play":"<path d=\"M5.6 3.6v16.9l13.4-8.5Z\"/>","plug":"<path d=\"M9.2 4v4.6M14.8 4v4.6M6.9 8.7h10.2v2.8a5.1 5.1 0 0 1-10.2 0ZM12 16.6V20.7\"/>","plus":"<path d=\"M12 3.5v17M3.5 12h17\"/>","plus-circle":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M12 8v8M8 12h8\"/>","print":"<path d=\"M7.1 8.9V3.5h9.9V8.9\"/><rect x=\"3.6\" y=\"8.9\" width=\"16.7\" height=\"7.9\" rx=\"2\"/><path d=\"M7.1 13.8h9.9v6.4H7.1ZM16.4 11.9h0\"/>","puzzle":"<path d=\"M3.8 7h4.2a2.6 2.6 0 1 1 4.8 0h4.2v4.2a2.6 2.6 0 1 1 0 4.8v4.2h-4.2a2.6 2.6 0 1 0-4.8 0H3.8v-4.2a2.6 2.6 0 1 0 0-4.8Z\"/>","quote":"<path d=\"M9.8 6.2C5.8 7.2 3.6 10 3.6 13.9v3.3h5.6v-5.6h-5.6M20.3 6.2c-3.9 1.1-6.1 3.9-6.1 7.8v3.3h5.6v-5.6h-5.6\"/>","radio":"<rect x=\"3.5\" y=\"8.3\" width=\"16.8\" height=\"11.3\" rx=\"1.9\"/><path d=\"M7.3 8.3L16.7 3.9\"/><circle cx=\"15.3\" cy=\"14\" r=\"2.4\"/><path d=\"M6.8 12.4h3.8M6.8 15.4h3.8\"/>","ranking":"<path d=\"M3.5 19.9h17M4.5 19.9V13.8h4.7M9.2 19.9V10h5.7v9.9M14.9 19.9V15.7h4.7v4.3\"/><path d=\"M12 3.4l0.9 1.7l1.9 0.3l-1.4 1.3l0.3 1.9L12 7.7l-1.7 0.9l0.3-1.9L9.3 5.4l1.9-0.3Z\"/>","record":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>","recycle":"<path d=\"M7.8 18.2H5.7a1.2 1.2 0 0 1-1-1.8l2.3-3.9M10 5.5l0.9-1.5a1.2 1.2 0 0 1 2.1 0l2.3 3.9M17.7 12.4l1.6 2.7a1.2 1.2 0 0 1-1 1.8h-4.5\"/><path d=\"M11.1 16.5l1.8 1.8l-1.8 1.8M4.5 10.4l2.4-0.7l0.7 2.5M17.2 6.1l-0.7 2.5l-2.5-0.7\"/>","reply":"<path d=\"M9.2 5.8L4 11l5.1 5.1M4 11h10.3a6.2 6.2 0 0 1 6.2 6.2v1\"/>","restroom":"<circle cx=\"7.1\" cy=\"5.3\" r=\"1.7\"/><circle cx=\"16.4\" cy=\"5.3\" r=\"1.7\"/><path d=\"M11.7 3.6v16.7M5.6 20.3v-5.1H4.7V10.6A1.4 1.4 0 0 1 6.1 9.2h1.9a1.4 1.4 0 0 1 1.4 1.4v4.6H8.5V20.3M15.5 20.3v-3.7h-2.4l1.9-6.1a0.9 0.9 0 0 1 0.9-0.9h0.9a0.9 0.9 0 0 1 0.9 0.9l1.9 6.1H17.3v3.7\"/>","retry":"<path d=\"M3.7 12a8.2 8.2 0 0 1 14.1-5.9L20.2 8.4M20.2 3.8v4.6h-4.6M20.2 12a8.2 8.2 0 0 1-14.1 5.9L3.7 15.6M3.7 20.3v-4.6h4.6\"/>","rewind":"<path d=\"M20 6.4v11.2L12.4 12ZM10.9 6.4v11.2L3.3 12Z\"/>","road":"<path d=\"M8.2 3.5L4.4 20.5M15.8 3.5l3.8 17M12 4.4v2.4M12 10.6v2.8M12 17.2V19.6\"/>","robot":"<rect x=\"5.4\" y=\"8.3\" width=\"13.2\" height=\"10.4\" rx=\"2.8\"/><path d=\"M12 4.6v3.8M9.2 13.1h0M14.8 13.1h0M9.6 16.4h4.7M3.5 13.1v1.9M20.5 13.1v1.9\"/>","rocket":"<path d=\"M11.8 15.7L8.4 12.3C9.9 6.9 13.8 3.9 20.2 3.9C20.2 10.3 17.3 14.2 11.8 15.7ZM8.4 12.3L4.9 11.8L7.4 8.3h3.9M11.8 15.7l0.5 3.4l3.4-2.5v-3.9M6.4 17.7c-1.5 0-3 1.5-3 3c1.5 0 3-1.5 3-3Z\"/><circle cx=\"15.3\" cy=\"8.8\" r=\"1.5\"/>","route":"<circle cx=\"6\" cy=\"18\" r=\"2.4\"/><circle cx=\"18\" cy=\"6\" r=\"2.4\"/><path d=\"M8.4 18H16a3 3 0 0 0 0-6H8a3 3 0 0 1 0-6h7.6\"/>","rss":"<path d=\"M4 3.5a16.5 16.5 0 0 1 16.5 16.5M4 10.4A9.6 9.6 0 0 1 13.6 20.1\"/><path d=\"M4.6 19.4h0\" stroke-width=\"3.9\"/>","save":"<path d=\"M4.7 3.5h11.5l3.2 3.2v13.6H4.7Z\"/><path d=\"M7.8 3.5v5.2h7.3V3.5M7.8 20.2v-6.3h8.4v6.3\"/>","scale":"<path d=\"M12 4.3V19.5M8.5 19.5h6.9M5.5 7.4h13M12 4.3h0M3.7 13.4L5.9 7.8L8.1 13.4M15.9 13.4L18.1 7.8l2.2 5.6M3.7 13.4a2.2 2.2 0 0 0 4.3 0M15.9 13.4a2.2 2.2 0 0 0 4.3 0\"/>","scissors":"<circle cx=\"6\" cy=\"7\" r=\"2.6\"/><circle cx=\"6\" cy=\"17\" r=\"2.6\"/><path d=\"M8.2 8.6 20 18M8.2 15.4 20 6\"/>","scroll":"<path d=\"M8.3 3.8h10.1a1.9 1.9 0 0 1 1.9 1.9v1.5H17M8.3 3.8a1.9 1.9 0 0 0-1.9 1.9v11.7a2.9 2.9 0 0 1-2.9 2.9h10.7a2.9 2.9 0 0 0 2.9-2.9v-11.7a1.9 1.9 0 0 0-1.9-1.9M10.2 9.2h3.9M10.2 13h3.9\"/>","search":"<circle cx=\"10.6\" cy=\"10.6\" r=\"7\"/><path d=\"M20.4 20.4l-4.5-4.5\"/>","seedling":"<path d=\"M12 20.8v-9.8M12 11C12 6.7 9.3 4 4.4 4c0 4.3 2.7 7 7.6 7ZM12 13.7c0-4.3 2.7-7 7.6-7c0 4.3-2.7 7-7.6 7Z\"/>","server":"<rect x=\"3.5\" y=\"3.5\" width=\"17\" height=\"7.4\" rx=\"2.1\"/><rect x=\"3.5\" y=\"13.1\" width=\"17\" height=\"7.4\" rx=\"2.1\"/><path d=\"M7.8 7.2h0M7.8 16.8h0\"/>","shapes":"<circle cx=\"7.5\" cy=\"7.7\" r=\"3.4\"/><path d=\"M16.4 3.7l3.9 6.9h-7.9ZM13.9 14.1h5.9v5.9h-5.9ZM3.6 20l3.4-5.9l3.4 5.9Z\"/>","share":"<path d=\"M12 15.2V3.3M7.4 8.1L12 3.3L16.6 8.1M4.9 12.9v6.1a1 1 0 0 0 1 1h12.2a1 1 0 0 0 1-1v-6.1\"/>","shelter":"<path d=\"M3.7 10.5L12 4l8.3 6.5M5.9 9.2v10.4M18.1 9.2v10.4\"/><circle cx=\"12\" cy=\"11\" r=\"1.6\"/><path d=\"M9.8 19.6v-3.9a2.2 2.2 0 0 1 4.3 0V19.6\"/>","shield":"<path d=\"M12 3.7L5.5 6.6v5.6c0 4.1 2.8 7.1 6.5 8.4c3.7-1.3 6.5-4.2 6.5-8.4V6.6Z\"/>","shirt":"<path d=\"M8.2 3.8L3.7 6.6l1.8 3.8l1.8-0.9v11.2h9.3V9.4l1.8 0.9l1.8-3.8l-4.7-2.7c-0.5 1.4-1.8 2.3-3.8 2.3S8.8 5.1 8.2 3.8Z\"/>","signature":"<path d=\"M3.9 14c2.4 0 3.3-8.4 5.6-8.4c1.4 0 0.9 5.6 2.4 5.6c0.9 0 1.4-2.4 2.4-2.4s0.8 1.9 1.9 1.9c0.8 0 1.4-0.6 1.9-1.1M3.9 17.8h16.7\"/>","skip":"<path d=\"M4.8 3.5l10.8 8.4l-10.8 8.4Z\"/><path d=\"M19.7 3.5v16.7\"/>","skull":"<path d=\"M12 3.7a6.5 6.5 0 0 0-6.5 6.5c0 2.2 1 3.6 2.4 4.6v2.8h8.4v-2.8c1.3-1 2.4-2.6 2.4-4.6a6.5 6.5 0 0 0-6.5-6.5Z\"/><circle cx=\"9.6\" cy=\"10.7\" r=\"1.3\"/><circle cx=\"14.4\" cy=\"10.7\" r=\"1.3\"/><path d=\"M10.6 17.7v2.8M13.4 17.7v2.8\"/>","slash":"<path d=\"M3.6 3.6l16.7 16.7\"/>","sliders":"<path d=\"M3.5 6.8h10M18.7 6.8H20.3M3.5 17.2h3.6M12.4 17.2H20.3\"/><circle cx=\"16.2\" cy=\"6.8\" r=\"2.4\"/><circle cx=\"9.8\" cy=\"17.2\" r=\"2.4\"/>","smile":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M8.5 14a4 4 0 0 0 7 0M9 9.5h.01M15 9.5h.01\"/>","snow":"<path d=\"M12 3.5v17M4.6 7.8l14.7 8.5M4.6 16.3l14.7-8.5M9.6 4.9L12 7.3l2.4-2.4M9.6 19.1L12 16.7l2.4 2.4\"/>","sort":"<path d=\"M3.5 5.8h9.2M3.5 12h6.2M3.5 18.2h3M17.5 4.8v14.4M14.3 16.2l3 3l3-3\"/>","sort-asc":"<path d=\"M3.4 5.4h3M3.4 11.6h6.3M3.4 17.9h9.3M17.5 4.3v14.6M14.3 16l3 3l3-3\"/>","spark":"<path d=\"M10.4 3.6l1.7 4.8L17 10.1l-4.8 1.7L10.4 16.7l-1.7-4.8L3.9 10.1l4.8-1.7ZM17 14.8l0.8 2.1L19.8 17.6l-2.1 0.8L17 20.3l-0.8-2.1L14.3 17.6l2.1-0.8Z\"/>","square":"<rect x=\"3.5\" y=\"3.5\" width=\"17\" height=\"17\" rx=\"2.7\"/>","star":"<path d=\"M12 4l2.6 5.3l5.7 0.6l-4.3 3.9l1.2 5.7L12 16.8l-5.2 2.9l1.2-5.7l-4.3-3.9l5.7-0.6Z\"/>","steps":"<path d=\"M9.7 5.7h11M9.7 11.8h11M9.7 17.8h11\"/><path d=\"M3.7 5.7l1.5 1.5L7.7 4.8M3.7 11.8l1.5 1.5L7.7 10.8M3.7 17.8l1.5 1.5L7.7 16.8\"/>","stop":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"m8.5 8.5 7 7\"/>","store":"<path d=\"M3.7 8.8L5.3 3.7h13.4L20.3 8.8M3.7 8.8h16.5v1.6a2.8 2.8 0 0 1-5.4 0a2.8 2.8 0 0 1-5.6 0A2.8 2.8 0 0 1 3.7 10.4ZM4.8 12.9v7.2h14.5v-7.2M9.9 20.1v-4.1h4.1v4.1\"/>","storm":"<path d=\"M7.8 16a3.8 3.8 0 1 1 0.8-7.4A4.5 4.5 0 0 1 17.4 10.5a3.1 3.1 0 0 1-0.4 6.2\"/><path d=\"M12.9 12.2l-2.1 3.3h2.5L11.3 18.9\"/>","sun":"<circle cx=\"11.9\" cy=\"11.9\" r=\"3.4\"/><path d=\"M11.9 3.5v1.8M11.9 18.6v1.8M6 6l1.2 1.2M16.7 16.7l1.2 1.2M3.5 11.9h1.8M18.6 11.9h1.8M6 17.9l1.2-1.2M16.7 7.2l1.2-1.2\"/>","swap":"<path d=\"M3.5 7.8h14.9l-3.7-3.7M20.5 16.3H5.6l3.7 3.7\"/>","sword":"<path d=\"M20.3 3.7v3.7L10.4 17.3L6.7 13.6L16.7 3.7Z\"/><path d=\"M5.2 12L12 18.8M8.3 15.7L3.6 20.4\"/>","table":"<rect x=\"3.5\" y=\"4.3\" width=\"17\" height=\"15.1\" rx=\"1.9\"/><path d=\"M3.5 9.5h17M3.5 14.7h17M9.2 9.5V19.4M14.8 9.5V19.4\"/>","tack":"<path d=\"M9.3 4h5.6l-0.9 4.6l2.8 2.8H7.4l2.8-2.8ZM12 11.4v9.3\"/>","tag":"<path d=\"M3.8 11.2V3.8h7.4l9.3 9.3l-7.4 7.4Z\"/><circle cx=\"8\" cy=\"8\" r=\"1.2\"/>","target":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><circle cx=\"12\" cy=\"12\" r=\"5\"/><circle cx=\"12\" cy=\"12\" r=\"1.5\"/>","tent":"<path d=\"M3.5 18.4h17M12 4.9L4.8 18.4M12 4.9l7.2 13.4M12 12.5L9.3 18.4M12 12.5l2.7 5.8\"/>","terminal":"<rect x=\"3.7\" y=\"4.6\" width=\"16.7\" height=\"14.9\" rx=\"1.9\"/><path d=\"M7.4 9.2l2.8 2.8l-2.8 2.8M12.9 14.8h3.7\"/>","text":"<path d=\"M5.2 20.3L10.9 3.6h2.1l5.7 16.7M7.4 14h9.2\"/>","thumbs-up":"<path d=\"M7.3 10.4v9.6H4.8a1 1 0 0 1-1-1v-7.6a1 1 0 0 1 1-1Z\"/><path d=\"M7.3 10.4L10.9 3.3a2.2 2.2 0 0 1 2.5 2.6L12.7 9.4H18.5a2 2 0 0 1 2 2.4l-1.3 6.6a2 2 0 0 1-2 1.6H7.3\"/>","ticket":"<path d=\"M3.6 7.4h16.7v2.8a1.9 1.9 0 0 0 0 3.7v2.8H3.6v-2.8a1.9 1.9 0 0 0 0-3.7Z\"/><path d=\"M13.8 7.4v9.3\"/>","timeline":"<path d=\"M3.5 12h0.9M8.2 12h1.9M13.9 12h1.9M19.6 12h0.9\"/><circle cx=\"6.3\" cy=\"12\" r=\"1.9\"/><circle cx=\"12\" cy=\"12\" r=\"1.9\"/><circle cx=\"17.7\" cy=\"12\" r=\"1.9\"/><path d=\"M6.3 10.1V5.9M12 13.9v4.3M17.7 10.1V5.9\"/>","toolbox":"<rect x=\"3.5\" y=\"8.1\" width=\"17\" height=\"11.3\" rx=\"1.9\"/><path d=\"M9.2 8.1V5.8A1.4 1.4 0 0 1 10.6 4.3h2.8A1.4 1.4 0 0 1 14.8 5.8V8.1M3.5 12.8h17M8.2 11.4v2.8M15.8 11.4v2.8\"/>","train":"<rect x=\"6.4\" y=\"3.8\" width=\"11.2\" height=\"12.1\" rx=\"2.8\"/><path d=\"M6.4 10.3h11.2M9.2 13.1h0M14.8 13.1h0M8.7 20.5l1.9-3.3M15.3 20.5l-1.9-3.3\"/>","trash":"<path d=\"M4.1 6.7h15.8M10 10.6v5.9M14 10.6v5.9M6.1 6.7l1 11.8a2 2 0 0 0 2 2h5.9a2 2 0 0 0 2-2l1-11.8M9 6.7V3.7h5.9v3\"/>","tree":"<path d=\"M12.1 3.6L7 10.6h2.8L5.6 16.6h13l-4.2-6h2.8ZM12.1 16.6v3.7\"/>","trend-down":"<path d=\"M3.2 7.2l5.6 5.6l3.8-3.8l7.4 7.4\"/><path d=\"M14.4 16.6h5.6v-5.6\"/>","trend-up":"<path d=\"M3.2 16.8L8.8 11.3l3.8 3.8l7.4-7.4\"/><path d=\"M14.4 7.4h5.6v5.6\"/>","trophy":"<path d=\"M7.9 4h8.4v5.2a4.1 4.1 0 0 1-8.4 0ZM7.9 6.1H4.7a3.2 3.2 0 0 0 3.2 4.1M16.2 6.1h3.2a3.2 3.2 0 0 1-3.2 4.1M12 13.4v4.1M7.9 20.8h8.4\"/>","tv":"<rect x=\"3.5\" y=\"7.8\" width=\"17\" height=\"11.6\" rx=\"1.8\"/><path d=\"M8.4 4.3l3.6 3.6l3.6-3.6\"/>","umbrella":"<path d=\"M3.8 12.5a8.4 8.4 0 0 1 16.6 0ZM12.1 12.5v6a1.9 1.9 0 0 1-3.7 0M12.1 4.1v0\"/>","undo":"<path d=\"M9.2 14.2L4 8.9l5.2-5.2\"/><path d=\"M4 8.9h11a5.8 5.8 0 0 1 0 11.5H11.3\"/>","up":"<path d=\"M3.7 16.1l8.3-8.3l8.3 8.3\"/>","upload":"<path d=\"M12 20.8V9.2M6.8 14.5l5.2-5.2l5.2 5.2M4.7 4h14.7\"/>","user":"<circle cx=\"12\" cy=\"7.4\" r=\"3.8\"/><path d=\"M4.3 20.5c0-3.9 3.4-6.6 7.7-6.6s7.7 2.6 7.7 6.6\"/>","user-check":"<circle cx=\"9.2\" cy=\"8.1\" r=\"2.8\"/><path d=\"M4 19c0-2.9 2.4-4.9 5.4-4.9s5.4 1.9 5.4 4.9M15.4 10.8l1.8 1.8l3.6-3.6\"/>","user-circle":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><circle cx=\"12\" cy=\"10\" r=\"3\"/><path d=\"M6.6 18.3a6.5 6.5 0 0 1 10.8 0\"/>","user-lock":"<circle cx=\"9.8\" cy=\"7.3\" r=\"3.3\"/><path d=\"M3.5 18.8c0-3.4 2.8-5.7 6.2-5.7c1.2 0 2.2 0.3 3.1 0.7\"/><rect x=\"14.6\" y=\"14.5\" width=\"5.7\" height=\"4.8\" rx=\"1\"/><path d=\"M16 14.5v-1.2a1.5 1.5 0 0 1 2.9 0v1.2\"/>","user-minus":"<circle cx=\"10.1\" cy=\"8\" r=\"3.3\"/><path d=\"M4 19.3c0-3.3 2.7-5.7 6.1-5.7c1.1 0 2.2 0.3 3 0.7\"/><path d=\"M15.1 17h5.7\"/>","user-off":"<circle cx=\"12\" cy=\"8.2\" r=\"3.5\"/><path d=\"M5.1 20c0-3.6 3-5.9 6.9-5.9s6.9 2.3 6.9 5.9\"/><path d=\"M3.7 3.7l16.7 16.7\"/>","user-pen":"<circle cx=\"9.8\" cy=\"8\" r=\"3.3\"/><path d=\"M3.8 19.1c0-3.3 2.7-5.6 6-5.6c1.1 0 2.1 0.3 3 0.7\"/><path d=\"M19.1 12.1l1.4 1.4l-4.7 4.7l-1.9 0.5l0.5-1.9Z\"/>","user-tag":"<circle cx=\"10\" cy=\"7.6\" r=\"3.3\"/><path d=\"M3.8 18.9c0-3.3 2.8-5.6 6.1-5.6c1.1 0 2.2 0.3 3.1 0.7\"/><path d=\"M14.2 14.2h3.7l2.4 2.4l-2.4 2.4h-3.7Z\"/><path d=\"M16.1 16.5h0\"/>","video":"<rect x=\"3.4\" y=\"6.4\" width=\"12.3\" height=\"11.3\" rx=\"1.9\"/><path d=\"M15.6 10.2l4.7-2.8v9.4l-4.7-2.8\"/>","volume":"<path d=\"M3.5 9.5h3.7L11.8 5.8v12.6l-4.7-3.7H3.5Z\"/><path d=\"M15.5 9.5a3.7 3.7 0 0 1 0 5.2M18.1 6.9a7.3 7.3 0 0 1 0 10.5\"/>","walk":"<circle cx=\"12.3\" cy=\"5.1\" r=\"1.7\"/><path d=\"M8.8 20.3l2.4-5.5l1.9 2.2V20.3M11 14.8l0.9-5.2l-2.7 1.4l-1.4 2.7M11.9 9.6l1.8 2.7l2.7 0.9\"/>","wallet":"<path d=\"M3.5 6.8A2.7 2.7 0 0 1 6.2 4.2H18.4v4.2M3.5 6.8V18a2.1 2.1 0 0 0 2.1 2.1h14.9V8.4H6.2A2.7 2.7 0 0 1 3.5 6.8Z\"/><path d=\"M16.2 14.2h0\"/>","water":"<path d=\"M3.5 10.2c2.1 0 2.1-1.8 4.3-1.8s2.1 1.8 4.2 1.8s2.1-1.8 4.3-1.8s2.1 1.8 4.2 1.8M3.5 15.6c2.1 0 2.1-1.8 4.3-1.8s2.1 1.8 4.2 1.8s2.1-1.8 4.3-1.8s2.1 1.8 4.2 1.8\"/>","wave":"<path d=\"M3.7 16.2c2.1 0 2.1-1.7 4.2-1.7s2.1 1.7 4.2 1.7s2.1-1.7 4.2-1.7s2.1 1.7 4.2 1.7\"/><path d=\"M6.5 12.7A5.6 5.6 0 0 1 16.4 9.4c-1.9-0.4-3.5 0.3-4 2\"/>","weight":"<circle cx=\"12\" cy=\"5.2\" r=\"2\"/><path d=\"M7.1 8.7h9.9l3 11.3H4.1Z\"/>","wifi":"<path d=\"M3.7 9.4a12.4 12.4 0 0 1 16.7 0M6.4 12.5a8.4 8.4 0 0 1 11.5 0M9.1 15.6a4.4 4.4 0 0 1 6.2 0M12.1 18.6h0\"/>","wind":"<path d=\"M3.5 9.9h10.4a2.8 2.8 0 1 0-2.8-2.8M3.5 13.7h14.2a2.8 2.8 0 1 1-2.8 2.8M3.5 17.5h6.6\"/>","window":"<rect x=\"3.5\" y=\"4.5\" width=\"17\" height=\"15\" rx=\"2\"/><path d=\"M3.5 9h17\"/>","window-restore":"<rect x=\"3.5\" y=\"8.5\" width=\"12\" height=\"11\" rx=\"2\"/><path d=\"M8.5 8.5v-2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-3\"/>","wizard":"<path d=\"M3.6 20h16.7M6.3 20L9.9 9.1L15.7 3.3L14.1 10.1L17.8 20\"/><path d=\"M8.4 15.4h7.3\"/>","wrench":"<path d=\"M14.6 3.6a5.2 5.2 0 0 0-4.6 7.3L3.7 17.3V20.3h3.1l6.4-6.4a5.2 5.2 0 0 0 7.3-4.6l-3.2 1.2l-2.5-2.5l1.2-3.2Z\"/>","x-circle":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"m9 9 6 6M15 9l-6 6\"/>","zap":"<path d=\"M13.4 3.5L5.9 12.9h5.7l-0.9 7.6l7.6-9.4h-5.7Z\"/>"};
++ const S = {"airplane":"<path d=\"M11.8 2.6c1.3 0 2 1.2 2 3V9.5l7.2 4.2v2.5L13.8 13.7v3.5l2 1.5v2.5L11.8 19.7l-3.7 1.2v-2.5l2-1.5v-3.5l-7.2 2.5v-2.5L9.8 9.5V5.3c0-1.8 0.7-3 2-3Z\"/>","alert":"<path fill-rule=\"evenodd\" d=\"M10.5 4.1a1.7 1.7 0 0 1 3 0l7.7 13.3a1.7 1.7 0 0 1-1.5 2.6H4.3a1.7 1.7 0 0 1-1.5-2.6ZM11 8.8h2v5.2h-2ZM12 15.2a1.2 1.2 0 1 1 0 2.4a1.2 1.2 0 0 1 0-2.4Z\"/>","alert-circle":"<path fill-rule=\"evenodd\" d=\"M12.9 2.7a9.4 9.4 0 1 0 0 18.7a9.4 9.4 0 0 0 0-18.7ZM11.7 6.9h2.5v7.1h-2.5ZM12.9 15.3a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3Z\"/>","archive":"<rect x=\"2.6\" y=\"3.4\" width=\"18.8\" height=\"5.4\" rx=\"1.2\"/><path fill-rule=\"evenodd\" d=\"M4.4 10h15.2v9.2a1.6 1.6 0 0 1-1.6 1.6H6a1.6 1.6 0 0 1-1.6-1.6ZM9.6 12.4v1.8h4.8v-1.8Z\"/>","bell":"<path d=\"M5.2 15.4V9.6a6.8 6.8 0 0 1 13.7 0v5.7l1.9 2.6H3.3ZM9.7 19.3h4.6a2.3 2.3 0 0 1-4.6 0Z\"/>","book":"<path d=\"M4.3 5.2A2.4 2.4 0 0 1 6.8 2.7h12.2v14.6H6.8a2.4 2.4 0 0 0-2.4 2.4Z\"/><path class=\"ln\" d=\"M7.2 20.2h11.2\"/>","book-open":"<path d=\"M10.9 5.8C8.8 4.4 6 3.8 2.5 4.5v14c3.5-0.6 6.3 0 8.4 1.5ZM13.1 5.8c2.1-1.5 5-2 8.4-1.4v14c-3.5-0.6-6.3 0-8.4 1.5Z\"/>","box":"<path d=\"M12 2.7l9.4 4.5l-9.4 4.5l-9.4-4.5ZM2.6 8.9l8.6 4.1v8.2l-8.6-4.3ZM21.4 8.9l-8.6 4.1v8.2l8.6-4.3Z\"/>","brain":"<path d=\"M8.9 3.8a3.2 3.2 0 0 0-3.2 3.1a3.1 3.1 0 0 0-2.2 5.3a3.1 3.1 0 0 0 2.2 5.2a3.2 3.2 0 0 0 5.7 1.3V5.6A2.3 2.3 0 0 0 8.9 3.8Z\"/><path d=\"M15.1 3.8a3.2 3.2 0 0 1 3.2 3.1a3.1 3.1 0 0 1 2.2 5.3a3.1 3.1 0 0 1-2.2 5.2a3.2 3.2 0 0 1-5.7 1.3V5.6A2.3 2.3 0 0 1 15.1 3.8Z\"/>","bridge":"<path d=\"M5.7 4.6h2.6v15.4H5.7ZM15.7 4.6h2.6v15.4h-2.6ZM3.4 14.6h17.2v2.8H3.4Z\"/><path class=\"ln\" d=\"M4 10c1.6 0 2.5-2.3 3-5c2.4 5.2 7.6 5.2 10 0c.5 2.7 1.4 5 3 5M12 10.9V15\"/>","briefcase":"<path d=\"M2.7 9.1a2.5 2.5 0 0 1 2.5-2.5h13.6a2.5 2.5 0 0 1 2.5 2.5v2.8H2.7ZM2.7 13.1h7.2v1.5h4.3v-1.5h7.2V17.9a2.5 2.5 0 0 1-2.5 2.5H5.2a2.5 2.5 0 0 1-2.5-2.5Z\"/><path class=\"ln\" d=\"M9.1 6.6V5.7A1.5 1.5 0 0 1 10.5 4.2h2.9A1.5 1.5 0 0 1 14.9 5.7v0.9\"/>","bug":"<path fill-rule=\"evenodd\" d=\"M12 7a4.8 4.8 0 0 1 4.8 4.8v3.4a4.8 4.8 0 0 1-9.6 0v-3.4A4.8 4.8 0 0 1 12 8Zm-0.7 4.4v7.4h1.4v-7.4Z\"/><path d=\"M9.4 5.8a2.6 2.6 0 0 1 5.2 0Z\"/><path class=\"ln\" d=\"M7.2 12.5H3.8M20.2 12.5h-3.4M7.6 9.4L5 7M16.4 9.4L19 7M7.6 16.6L5 19M16.4 16.6L19 19\"/>","burst":"<path d=\"M12.1 2.6l2 4.6l4.7-1.9l-1.9 4.7l4.6 2l-4.6 2l1.9 4.7l-4.7-1.9l-2 4.6l-2-4.6l-4.7 1.9l1.9-4.7L2.7 12l4.6-2l-1.9-4.7l4.7 1.9Z\"/>","calendar":"<path fill-rule=\"evenodd\" d=\"M4.1 8.1a2 2 0 0 1 2-2h11.8a2 2 0 0 1 2 2V19.5a2 2 0 0 1-2 2H6.1a2 2 0 0 1-2-2Zm2.2 3.9V19.3h11.4v-7.3Z\"/><path class=\"ln\" d=\"M9 3.9v3.5M15 3.9v3.5\"/>","camera":"<path fill-rule=\"evenodd\" d=\"M2.5 7.8a1.9 1.9 0 0 1 1.9-1.9h2.6L8.8 3.3h6.3l1.8 2.6h2.6A1.9 1.9 0 0 1 21.5 7.8v10.7a1.9 1.9 0 0 1-1.9 1.9H4.4A1.9 1.9 0 0 1 2.5 18.5Zm9.5 2a3.6 3.6 0 1 0 0 7.2a3.6 3.6 0 0 0 0-7.2Z\"/>","camera-flip":"<path fill-rule=\"evenodd\" d=\"M2.5 7.8a1.9 1.9 0 0 1 1.9-1.9h2.6L8.8 3.3h6.3l1.8 2.6h2.6A1.9 1.9 0 0 1 21.5 7.8v10.7a1.9 1.9 0 0 1-1.9 1.9H4.4A1.9 1.9 0 0 1 2.5 18.5ZM7.7 11.7A4.5 4.5 0 0 1 16.3 11.7L13.9 12.6A2 2 0 0 0 10.1 12.6ZM16.3 14.8A4.5 4.5 0 0 1 7.7 14.8L10.1 14A2 2 0 0 0 13.9 14Z\"/>","camera-off":"<path d=\"M8.5 4.6L9 3.8h6.1l1.7 2.5h2.5A1.8 1.8 0 0 1 21.2 8.1v9.1ZM3.8 7.3L16.7 20.2H4.8A1.8 1.8 0 0 1 3 18.4V8.1Z\"/><path class=\"ln\" d=\"M4 3.8l16.2 16.2\"/>","car":"<path fill-rule=\"evenodd\" d=\"M5.6 5.2h12.7l2.7 5.5H3ZM2.6 12.2h18.7v4.5H2.6ZM5.9 14.4a1.1 1.1 0 1 0 2.3 0a1.1 1.1 0 1 0-2.3 0ZM15.8 14.4a1.1 1.1 0 1 0 2.3 0a1.1 1.1 0 1 0-2.3 0ZM5 17.5h2.7v1.8H5ZM16.3 17.5h2.7v1.8h-2.7Z\"/>","card":"<path fill-rule=\"evenodd\" d=\"M2.6 7.4a2.4 2.4 0 0 1 2.4-2.4h13.9a2.4 2.4 0 0 1 2.4 2.4v9.3a2.4 2.4 0 0 1-2.4 2.4h-13.9a2.4 2.4 0 0 1-2.4-2.4ZM2.6 9.5v1.7h18.7V9.5ZM5.9 14.2v1.5h4.8v-1.5Z\"/>","care":"<path d=\"M17 11s-4.1-2.4-4.1-5.4a2.3 2.3 0 0 1 4.1-1.6a2.3 2.3 0 0 1 4.1 1.6c0 2.9-4.1 5.4-4.1 5.4Z\"/><path class=\"ln\" d=\"M3.5 16.2h3.2l4.3 3.2h5.9a2.1 2.1 0 0 0 0-4.3H13.3M7.9 16.2l2.3-2.1h3.6\"/>","cart":"<path d=\"M6.3 7h15l-2.5 8.3H8.1Z\"/><circle cx=\"9.5\" cy=\"19.4\" r=\"1.6\"/><circle cx=\"17\" cy=\"19.4\" r=\"1.6\"/><path class=\"ln\" d=\"M3.8 4.8H5.8l2.3 10.5\"/>","cc":"<path fill-rule=\"evenodd\" d=\"M5.6 4.6h12.7a3 3 0 0 1 3 3v8.8a3 3 0 0 1-3 3h-12.7a3 3 0 0 1-3-3v-8.8a3 3 0 0 1 3-3ZM10.8 9.5A3.2 3.2 0 1 0 10.8 14.5L9.6 13A1.3 1.3 0 1 1 9.6 11ZM17.4 9.5A3.2 3.2 0 1 0 17.4 14.5L16.2 13A1.3 1.3 0 1 1 16.2 11Z\"/>","chart":"<path class=\"ln\" d=\"M4 3.5v16.2h16.2\"/><path d=\"M7 14.9L11 10.3l3 2.5l5.2-6.2V17.7H7Z\"/>","chat":"<path d=\"M2.7 4.7A1.8 1.8 0 0 1 4.5 3h15.2a1.8 1.8 0 0 1 1.8 1.8v10.9a1.8 1.8 0 0 1-1.8 1.8H8.3L2.7 21.8Z\"/>","chat-off":"<path d=\"M8.1 4.6H19a1.6 1.6 0 0 1 1.6 1.6v9.5a1.6 1.6 0 0 1-0.4 1ZM3.4 7.3L12.9 16.8H8.3l-4.9 4.6Z\"/><path class=\"ln\" d=\"M4 4.2l16 16\"/>","check-circle":"<path fill-rule=\"evenodd\" d=\"M12.9 2.7a9.4 9.4 0 1 0 0 18.7a9.4 9.4 0 0 0 0-18.7ZM17.3 8.9l1.6 1.6l-7 7l-4.1-4.1l1.6-1.6l2.6 2.6Z\"/>","child":"<circle cx=\"12\" cy=\"5.2\" r=\"2.6\"/><path class=\"ln\" d=\"M7.7 10L12 8.6l4.3 1.4M12 8.6v5.8M9.1 20.1l2.9-5.8l2.9 5.8\"/>","chip":"<path fill-rule=\"evenodd\" d=\"M8.6 7.2h6.8a1.4 1.4 0 0 1 1.4 1.4v6.8a1.4 1.4 0 0 1-1.4 1.4H8.6a1.4 1.4 0 0 1-1.4-1.4V8.6a1.4 1.4 0 0 1 1.4-1.4Zm1.7 3.1v3.4h3.4v-3.4Z\"/><path class=\"ln\" d=\"M10.2 4v3.2M13.8 4v3.2M10.2 16.8V20M13.8 16.8V20M4 10.2h3.2M4 13.8h3.2M16.8 10.2H20M16.8 13.8H20\"/>","circle":"<circle cx=\"12\" cy=\"12\" r=\"9.5\"/>","city":"<path fill-rule=\"evenodd\" d=\"M5.4 7.7h4.6v10.9H5.4Zm1.9 2.2v1.4h1.2v-1.4Zm0 2.9v1.4h1.2v-1.4Z\"/><path fill-rule=\"evenodd\" d=\"M10.7 3.4h5.1v15.2h-5.1Zm1.7 2.4v1.4h1.4V5.8Zm0 2.9v1.4h1.4V8.7Zm0 2.9v1.4h1.4v-1.4Z\"/><path d=\"M16.5 11.3h2.2v7.4h-2.2Z\"/><path class=\"ln\" d=\"M3.9 18.6h16.1\"/>","clapper":"<path fill-rule=\"evenodd\" d=\"M2.6 5.9L19.9 2.7l0.9 4.3L3.2 8.7ZM7 5.1L9.2 8.1l2.6-0.2L9.6 4.6ZM12.6 4l2.2 3.6l2.6-0.3L15.2 3.6Z\"/><path d=\"M3.1 9.9h18.2v9a1.6 1.6 0 0 1-1.6 1.6h-15a1.6 1.6 0 0 1-1.6-1.6Z\"/>","clipboard":"<path fill-rule=\"evenodd\" d=\"M7.5 4.2h9a2 2 0 0 1 2 2v13.1a2 2 0 0 1-2 2H7.5a2 2 0 0 1-2-2V6.3a2 2 0 0 1 2-2ZM9.1 9.9v1.6h5.8v-1.6Zm0 3.9v1.6h3.9v-1.6Z\"/><rect x=\"8.7\" y=\"2.5\" width=\"6.6\" height=\"3.7\" rx=\"1.2\"/>","clipboard-check":"<path fill-rule=\"evenodd\" d=\"M7.5 4.2h9a2 2 0 0 1 2 2v13.1a2 2 0 0 1-2 2H7.5a2 2 0 0 1-2-2V6.3a2 2 0 0 1 2-2ZM8 13.1L11 16.1l5-5l-1.6-1.6l-3.4 3.4l-1.5-1.5Z\"/><rect x=\"8.7\" y=\"2.5\" width=\"6.6\" height=\"3.7\" rx=\"1.2\"/>","clock":"<path fill-rule=\"evenodd\" d=\"M12.9 2.7a9.4 9.4 0 1 0 0 18.7a9.4 9.4 0 0 0 0-18.7ZM11.9 6.6h2.3v5l3.3 2l-1.1 2l-4.3-2.7Z\"/>","cloud":"<path d=\"M7.9 18.2h9.6a4.2 4.2 0 0 0 0.6-8.3A6.1 6.1 0 0 0 6.5 9.2A4.5 4.5 0 0 0 7.9 18.2Z\"/>","cloud-off":"<path d=\"M10.1 5.7A6 6 0 0 1 17.9 9.8a4.1 4.1 0 0 1 2.8 6.5ZM6.6 9.1L15.5 18H7.9A4.4 4.4 0 0 1 6.4 9.2Z\"/><path class=\"ln\" d=\"M5.1 4.2l15 15\"/>","coin":"<path fill-rule=\"evenodd\" d=\"M13 2.6a9.4 9.4 0 1 0 0 18.7a9.4 9.4 0 0 0 0-18.7ZM12 5.8h2.1v1.2c1.1 0.2 2.1 0.8 2.6 1.7l-1.7 1c-0.3-0.6-1-1-1.8-1c-0.9 0-1.5 0.4-1.5 1s0.5 0.8 1.8 1.1c1.8 0.4 3.5 1 3.5 3.1c0 1.4-1 2.5-2.8 2.8V18h-2.1v-1.3c-1.2-0.2-2.3-0.9-2.8-2l1.7-1c0.4 0.7 1.1 1.1 2.1 1.1c1 0 1.6-0.4 1.6-1s-0.6-0.8-2-1.1c-1.7-0.4-3.4-1-3.4-3c0-1.3 1-2.4 2.7-2.7Z\"/>","columns":"<path d=\"M2.6 6.3a2.7 2.7 0 0 1 2.7-2.7h13.4a2.7 2.7 0 0 1 2.7 2.7v1.5H2.6ZM2.6 9.9h8.3v10.4H5.3a2.7 2.7 0 0 1-2.7-2.7ZM13 9.9h8.3v7.7a2.7 2.7 0 0 1-2.7 2.7H13Z\"/>","compass":"<path fill-rule=\"evenodd\" d=\"M12.9 2.7a9.4 9.4 0 1 0 0 18.7a9.4 9.4 0 0 0 0-18.7ZM17.4 7.7L14.9 14.1L8.5 16.5L11 10.1Z\"/>","compress":"<path d=\"M9.7 9.7H2.7L9.7 2.7ZM14.3 9.7h7l-7-7ZM9.7 14.3H2.7l7 7ZM14.3 14.3h7l-7 7Z\"/>","computer":"<rect x=\"2.6\" y=\"3.4\" width=\"18.8\" height=\"12.8\" rx=\"2.2\"/><path class=\"ln\" d=\"M8 20h8M12 16.2v3.8\"/>","contrast":"<path fill-rule=\"evenodd\" d=\"M12.4 2.7a9.4 9.4 0 1 0 0 18.7a9.4 9.4 0 0 0 0-18.7Zm0 2.1a7.2 7.2 0 0 1 0 14.4Z\"/>","cookie":"<path fill-rule=\"evenodd\" d=\"M21.6 12.4a9.4 9.4 0 1 1-10-10a3.4 3.4 0 0 0 4.4 3.9a3.4 3.4 0 0 0 5.5 5.9ZM7 9.1a1.3 1.3 0 1 0 2.7 0a1.3 1.3 0 1 0-2.7 0ZM10.9 14a1.3 1.3 0 1 0 2.7 0a1.3 1.3 0 1 0-2.7 0ZM6.5 15.7a1.3 1.3 0 1 0 2.7 0a1.3 1.3 0 1 0-2.7 0ZM14.8 15.7a1.3 1.3 0 1 0 2.7 0a1.3 1.3 0 1 0-2.7 0Z\"/>","copy":"<rect x=\"7.5\" y=\"7.5\" width=\"13.8\" height=\"13.8\" rx=\"2.5\"/><path class=\"ln\" d=\"M16.4 7.5V5.9a2.1 2.1 0 0 0-2.1-2.1H5.9a2.1 2.1 0 0 0-2.1 2.1v8.5a2.1 2.1 0 0 0 2.1 2.1h1.7\"/>","couch":"<path d=\"M7.1 4.2h9.7A2.5 2.5 0 0 1 19.4 6.7v1.9a3.1 3.1 0 0 0-3.1 3.1v0.1H7.7v-0.1A3.1 3.1 0 0 0 4.6 8.7V6.7A2.5 2.5 0 0 1 7.1 4.2ZM2.7 11.6a2.5 2.5 0 0 1 5.1 0v1.9h8.6V11.6a2.5 2.5 0 0 1 5.1 0v5.5H2.7Z\"/><path class=\"ln\" d=\"M5.2 17.1v1.9M18.8 17.1v1.9\"/>","crown":"<path d=\"M2.6 7.3L7.4 11.5L12 4.1L16.6 11.5l4.8-4.2l-2 11.7H4.6Z\"/>","cubes":"<path d=\"M12 2.3L7.8 4.4v4.8l4.2 2.1l4.2-2.1V4.4ZM6.8 12.1L2.7 14.2v4.8l4.2 2.1l4.2-2.1v-4.8ZM17.2 12.1l-4.2 2.1v4.8l4.2 2.1l4.2-2.1v-4.8Z\"/>","database":"<ellipse cx=\"12\" cy=\"5.7\" rx=\"7.9\" ry=\"3.2\"/><path d=\"M4.1 7.5a7.9 3.2 0 0 0 15.9 0v4.3a7.9 3.2 0 0 1-15.9 0ZM4.1 13.4a7.9 3.2 0 0 0 15.9 0v4.7a7.9 3.2 0 0 1-15.9 0Z\"/>","dice":"<path fill-rule=\"evenodd\" d=\"M6.2 2.7h11.5a3.6 3.6 0 0 1 3.6 3.6v11.5a3.6 3.6 0 0 1-3.6 3.6H6.2a3.6 3.6 0 0 1-3.6-3.6V6.2a3.6 3.6 0 0 1 3.6-3.6ZM6.2 8.2a2 2 0 1 0 3.9 0a2 2 0 1 0-3.9 0ZM13.8 8.2a2 2 0 1 0 3.9 0a2 2 0 1 0-3.9 0ZM10 12a2 2 0 1 0 3.9 0a2 2 0 1 0-3.9 0ZM6.2 15.8a2 2 0 1 0 3.9 0a2 2 0 1 0-3.9 0ZM13.8 15.8a2 2 0 1 0 3.9 0a2 2 0 1 0-3.9 0Z\"/>","dish":"<path d=\"M2.7 9.7A11.7 11.7 0 0 0 14.4 21.4Z\"/><path class=\"ln\" d=\"M8.8 15.3l4.9-4.9M14.2 4a5.8 5.8 0 0 1 5.8 5.8M14.2 8.1a1.8 1.8 0 0 1 1.8 1.8\"/>","donate":"<path fill-rule=\"evenodd\" d=\"M12 2.2a5.9 5.9 0 1 0 0 11.8a5.9 5.9 0 0 0 0-11.8Zm-0.8 3.4h1.7v5.1h-1.7ZM2.9 15h18.2v6H2.9ZM7.8 17.1v1.6h8.5V17.1Z\"/>","door":"<path fill-rule=\"evenodd\" d=\"M14.3 4.5L6.2 3v17.1l8.2-1.5ZM10.2 11.5a1 1 0 1 0 2 0a1 1 0 1 0-2 0Z\"/><path class=\"ln\" d=\"M15.5 5.3h1.4a0.9 0.9 0 0 1 0.9 0.9v13.3M15.5 19.5H20M4 19.5h2.1\"/>","download":"<path d=\"M10.6 2.3h2.5v7h4.7L11.9 15.4l-5.9-5.9h4.7Z\"/><path class=\"ln\" d=\"M4.5 19.7h14.9\"/>","drive":"<path fill-rule=\"evenodd\" d=\"M5.3 11.4h13.5a2.7 2.7 0 0 1 2.7 2.7v3a2.7 2.7 0 0 1-2.7 2.7H5.3a2.7 2.7 0 0 1-2.7-2.7v-3a2.7 2.7 0 0 1 2.7-2.7ZM16.8 14.4a1.1 1.1 0 1 1 0 2.3a1.1 1.1 0 0 1 0-2.3ZM13.7 14.4a1.1 1.1 0 1 1 0 2.3a1.1 1.1 0 0 1 0-2.3Z\"/><path class=\"ln\" d=\"M4.1 11.9L6.8 4.7h10.4l2.8 7.2\"/>","drop":"<path d=\"M12 2.2s7 7.4 7 12.6a7 7 0 0 1-13.9 0C5 9.6 12 2.2 12 2.2Z\"/>","eraser":"<path d=\"M14 3.9l6.1 6.1-5.2 5.2-6.1-6.1ZM7.4 10l6.2 6.2-3.2 3.2H6.1l-2.9-2.9a1.6 1.6 0 0 1 0-2.3Z\"/><path class=\"ln\" d=\"M13 19.4h7\"/>","expand":"<path d=\"M2.7 2.7h7L2.7 9.7ZM21.3 2.7h-7l7 7ZM2.7 21.3h7l-7-7ZM21.3 21.3h-7l7-7Z\"/>","eye":"<path fill-rule=\"evenodd\" d=\"M12 5.3C6.4 5.3 2.6 12 2.6 12s3.9 6.7 9.4 6.7S21.5 12 21.5 12S17.6 5.3 12 5.3Zm0 3.3a3.3 3.3 0 1 1 0 6.7a3.3 3.3 0 0 1 0-6.7Z\"/><circle cx=\"12\" cy=\"12\" r=\"1.6\"/>","eye-off":"<path d=\"M9 5.8A10 10 0 0 1 21.4 12a10 10 0 0 1-2.5 3.8L15.3 12.2a3.3 3.3 0 0 0-3.5-3.5ZM15 18.2A10 10 0 0 1 2.6 12a10 10 0 0 1 2.5-3.8L8.7 11.8a3.3 3.3 0 0 0 3.5 3.5Z\"/><path class=\"ln\" d=\"M4.6 4.6l14.8 14.8\"/>","fast-forward":"<path d=\"M3 5.5v13.1L12 12ZM12.7 5.5v13.1L21.8 12Z\"/>","file":"<path d=\"M5.8 2.4h7.6v5.2h5V21.2H5.8ZM15 2.7l3.1 3.3h-3.1Z\"/>","file-code":"<path fill-rule=\"evenodd\" d=\"M5.8 2.4h7.6v5.2h5V21.2H5.8ZM9.8 11l1.5 1.4l-1.6 1.7l1.6 1.7l-1.5 1.4l-2.9-3.1ZM14.4 11l-1.5 1.4l1.6 1.7l-1.6 1.7l1.5 1.4l2.9-3.1Z\"/><path d=\"M15 2.7l3.1 3.3h-3.1Z\"/>","film":"<path fill-rule=\"evenodd\" d=\"M4.8 3.6h14.4a2.3 2.3 0 0 1 2.3 2.3v12.3a2.3 2.3 0 0 1-2.3 2.3H4.8A2.3 2.3 0 0 1 2.5 18.2V5.8A2.3 2.3 0 0 1 4.8 3.6Zm3.8 2v12.9h6.8V5.5ZM4.4 6v2h2.1V6Zm0 5v1.8h2.1v-1.8Zm0 4.9v2h2.1v-2Zm13.1-10v2h2.1V6Zm0 5v1.8h2.1v-1.8Zm0 4.9v2h2.1v-2Z\"/>","finish":"<path d=\"M6.5 4.2h3.9v4.6H6.5ZM14.8 4.2h4a0.9 0.9 0 0 1 0.9 0.9v3.7h-4.9ZM10.5 8.6h4.5v4.6H10.5Z\"/><path class=\"ln\" d=\"M6 20.3V4.1\"/>","fire":"<path fill-rule=\"evenodd\" d=\"M12.1 19.8a6.8 6.8 0 0 0 6.8-6.8c0-4.3-2.9-6.8-4.2-10.3c-2.1 2.3-2.6 4.6-2.6 4.6S10.8 5.1 9.6 3C7.1 5.9 5.2 9.2 5.2 12.9A6.8 6.8 0 0 0 12.1 21.4Zm0-2.8a2.8 2.8 0 0 0 2.8-2.8c0-1.6-1.2-2.7-1.7-4c-0.9 1-1.1 1.8-1.1 1.8s-0.5-0.9-1-1.6c-1 1.2-1.7 2.5-1.7 3.9a2.8 2.8 0 0 0 2.7 2.7Z\"/>","fish":"<path fill-rule=\"evenodd\" d=\"M7.6 12c2.1-3.6 4.8-5.4 7.9-5.4 2.9 0 5 2.2 6.1 5.4-1.1 3.2-3.2 5.4-6.1 5.4-3.1 0-5.8-1.8-7.9-5.4ZM15.7 10.6a1.1 1.1 0 1 0 2.2 0a1.1 1.1 0 1 0-2.2 0Z\"/><path d=\"M8.4 12 2.9 7.4v9.2Z\"/>","flag":"<path class=\"ln\" d=\"M7.2 20.5V4.3\"/><path d=\"M7.8 4.1h10.9l-2 4.1l2 4.1H7.8Z\"/>","focus":"<circle cx=\"12\" cy=\"12\" r=\"3.6\"/><path class=\"ln\" d=\"M3.8 9V6.1A2.3 2.3 0 0 1 6.1 3.8H9M15 3.8h2.9A2.3 2.3 0 0 1 20.2 6.1V9M20.2 15v2.9a2.3 2.3 0 0 1-2.3 2.3H15M9 20.2H6.1A2.3 2.3 0 0 1 3.8 17.9V15\"/>","folder":"<path d=\"M2.7 6.4a2.1 2.1 0 0 1 2.1-2.1h4.4l2.1 2.1h8.1a2.1 2.1 0 0 1 2.1 2.1v8.7a2.1 2.1 0 0 1-2.1 2.1H4.8a2.1 2.1 0 0 1-2.1-2.1Z\"/>","folder-tree":"<path d=\"M9.6 5h4l1 1.5h6.4v5.3H9.6ZM9.6 14.3h4l1 1.5h6.4v5.3H9.6Z\"/><path class=\"ln\" d=\"M4.1 3.6v13.2a1 1 0 0 0 1 1H9.6M4.1 8.8H9.6\"/>","food":"<path d=\"M4.7 3.3h1.5v4.7h0.6V3.3h1.4v4.7h0.6V3.3h1.5v4.9A2.9 2.9 0 0 1 8.6 10.8V21.7H6.5V10.8a2.9 2.9 0 0 1-1.8-2.7ZM18.9 21.7h-2.3v-7.4h-3V10.6c0-3.9 1.7-6.6 5.2-7.7Z\"/>","game":"<path fill-rule=\"evenodd\" d=\"M7.6 6.3h8.8a5.8 5.8 0 0 1 0 11.5H7.6a5.8 5.8 0 0 1 0-11.5Zm-1.4 3.7v1.6H4.7v1.8h1.6v1.6H7.9v-1.6h1.6v-1.8H7.9v-1.6Zm9.2 1a1.2 1.2 0 1 0 0 2.3a1.2 1.2 0 0 0 0-2.3Zm2.5 1.6a1.2 1.2 0 1 0 0 2.3a1.2 1.2 0 0 0 0-2.3Z\"/>","gauge":"<path fill-rule=\"evenodd\" d=\"M3.7 18A9.4 9.4 0 1 1 19.4 19.6ZM15 8.6l-2.2 5.6l-2.1 0.3l-0.4-2.1Z\"/>","gavel":"<path d=\"M11.3 5.5L5.5 11.3l6.1 6.1l5.8-5.8Z\"/><path class=\"ln\" d=\"M13.7 4.2l5 5M3.8 19.1l5.7-5.7M13.7 19.1h6.3\"/>","gear":"<path fill-rule=\"evenodd\" d=\"M18.2 14.6a1.4 1.4 0 0 0 0.3 1.7l0.1 0.1a1.7 1.7 0 1 1-2.7 2.7l-0.1-0.1a1.4 1.4 0 0 0-1.7-0.3a1.4 1.4 0 0 0-0.9 1.4V19.6a1.7 1.7 0 1 1-3.8 0v-0.1a1.4 1.4 0 0 0-1-1.4a1.4 1.4 0 0 0-1.7 0.3l-0.1 0.1a1.7 1.7 0 1 1-2.7-2.7l0.1-0.1a1.4 1.4 0 0 0 0.3-1.7a1.4 1.4 0 0 0-1.4-0.9H4.6a1.7 1.7 0 1 1 0-3.8h0.1a1.4 1.4 0 0 0 1.4-1a1.4 1.4 0 0 0-0.3-1.7l-0.1-0.1a1.7 1.7 0 1 1 2.7-2.7l0.1 0.1a1.4 1.4 0 0 0 1.7 0.3H9.6a1.4 1.4 0 0 0 0.9-1.4V4.6a1.7 1.7 0 1 1 3.8 0v0.1a1.4 1.4 0 0 0 0.9 1.4a1.4 1.4 0 0 0 1.7-0.3l0.1-0.1a1.7 1.7 0 1 1 2.7 2.7l-0.1 0.1a1.4 1.4 0 0 0-0.3 1.7V9.6a1.4 1.4 0 0 0 1.4 0.9H19.6a1.7 1.7 0 1 1 0 3.8h-0.1a1.4 1.4 0 0 0-1.4 0.9ZM12.1 9.3a2.8 2.8 0 1 0 0 6.5a2.8 2.8 0 0 0 0-6.5Z\"/>","gem":"<path d=\"M6.4 3.9h11.2l3.8 5.3l-9.4 11.6L2.6 9.2Z\"/>","ghost":"<path fill-rule=\"evenodd\" d=\"M3.4 21.3V9.9a8.7 8.7 0 0 1 17.2 0v11.5l-2.9-2.1l-2.9 2.1l-2.9-2.1l-2.9 2.1l-2.9-2.1ZM8.8 7.8a1.6 1.6 0 1 0 0 3.1a1.6 1.6 0 0 0 0-3.1Zm6.2 0a1.6 1.6 0 1 0 0 3.1a1.6 1.6 0 0 0 0-3.1Z\"/>","gift":"<path d=\"M3.1 8.6h17.7v3.3H3.1ZM4.4 12.9h6.6v8.2H5.4a1 1 0 0 1-1-1ZM13 12.9h6.6v7.2a1 1 0 0 1-1 1H13Z\"/><path class=\"ln\" d=\"M12 8.6c-1.5-3.1-5.1-4.1-5.1-1.5S12 8.6 12 8.6s5.1-1 5.1-3.6s-3.6-1.5-5.1 3.6\"/>","globe":"<path d=\"M2.6 11.1A9.5 9.5 0 0 1 12 2.6a4.5 9.5 0 0 0-4.5 8.5ZM21.4 11.1A9.5 9.5 0 0 0 12 2.6a4.5 9.5 0 0 1 4.5 8.5ZM2.6 12.9A9.5 9.5 0 0 0 12 21.5a4.5 9.5 0 0 1-4.5-8.5ZM21.4 12.9A9.5 9.5 0 0 1 12 21.5a4.5 9.5 0 0 0 4.5-8.5ZM12 2.6a2.7 9.5 0 0 0-2.7 8.5h5.3A2.7 9.5 0 0 0 12 2.6ZM12 21.5a2.7 9.5 0 0 1-2.7-8.5h5.3A2.7 9.5 0 0 1 12 21.5Z\"/>","grid":"<rect x=\"2.7\" y=\"2.7\" width=\"8\" height=\"8\" rx=\"2.2\"/><rect x=\"13.3\" y=\"2.7\" width=\"8\" height=\"8\" rx=\"2.2\"/><rect x=\"2.7\" y=\"13.3\" width=\"8\" height=\"8\" rx=\"2.2\"/><rect x=\"13.3\" y=\"13.3\" width=\"8\" height=\"8\" rx=\"2.2\"/>","group":"<rect x=\"6.9\" y=\"6.8\" width=\"6.4\" height=\"4.8\" rx=\"1.1\"/><rect x=\"10.8\" y=\"12.5\" width=\"6.4\" height=\"4.6\" rx=\"1.1\"/><path class=\"ln\" d=\"M4 7.6V4h3.6M16.5 4h3.6v3.6M20 16.4v3.6h-3.6M7.6 20H4v-3.6\"/>","hammer":"<path d=\"M13.1 2.5l7.5 7.5l-3.7 3.7l-7.5-7.5ZM12.9 8.9L3.7 18.1a2.1 2.1 0 0 0 2.9 2.9l9.2-9.2Z\"/>","hand":"<path d=\"M9 14.3V4.9a1.4 1.4 0 0 1 2.8 0v5.2a0.5 0.5 0 0 0 1.1 0V3.8a1.4 1.4 0 0 1 2.8 0v6.4a0.5 0.5 0 0 0 1.1 0V5.7a1.4 1.4 0 0 1 2.8 0v7.7c0 4.5-2.8 7.7-7.3 7.7c-2.9 0-4.7-1.5-6.3-4l-1.8-2.9a1.7 1.7 0 0 1 2.8-2Z\"/>","headphones":"<path class=\"ln\" d=\"M4 14.5V12a8 8 0 0 1 16 0v2.5\"/><rect x=\"2.6\" y=\"13.4\" width=\"5.4\" height=\"7.6\" rx=\"1.8\"/><rect x=\"16\" y=\"13.4\" width=\"5.4\" height=\"7.6\" rx=\"1.8\"/>","heart":"<path d=\"M12 21.6s-9.3-5.7-9.3-13a5.3 5.3 0 0 1 9.3-3.6a5.3 5.3 0 0 1 9.3 3.6c0 7.4-9.3 13-9.3 13Z\"/>","heart-broken":"<path fill-rule=\"evenodd\" d=\"M12 21.6s-9.3-5.7-9.3-13a5.3 5.3 0 0 1 9.3-3.6a5.3 5.3 0 0 1 9.3 3.6c0 7.4-9.3 13-9.3 13ZM10.9 5.9l2.7 0.6l-1.4 2.7l3.6 2.5l-2.6 5.9l-2.5-1.1l1.5-3.7l-3.7-2.5Z\"/>","help":"<path fill-rule=\"evenodd\" d=\"M13 2.7a9.4 9.4 0 1 0 0 18.7a9.4 9.4 0 0 0 0-18.7ZM9.3 9.4A3.7 3.7 0 1 1 13.9 12.8v1.6h-2.1V10.9h1a1.6 1.6 0 1 0-1.6-1.6ZM13 15.8a1.3 1.3 0 1 1 0 2.6a1.3 1.3 0 0 1 0-2.6Z\"/>","history":"<path d=\"M5.8 5.8A9.1 9.1 0 1 1 3.2 12.2h3a6.1 6.1 0 1 0 1.8-4.3ZM2.5 2.7v7.2h7.2ZM11.2 8.1h2.2v3.7l2.7 1.6l-1.1 1.8l-3.8-2.3Z\"/>","home":"<path d=\"M2.7 10.4L12 2.3l9.3 8.1v9.5a1.1 1.1 0 0 1-1.1 1.1h-5.6v-6.9h-5.4v6.9H3.8a1.1 1.1 0 0 1-1.1-1.1Z\"/>","hot-tub":"<circle cx=\"8.2\" cy=\"5.4\" r=\"2.4\"/><path d=\"M2.6 10.9h18.8v4.2a5.1 5.1 0 0 1-5.1 5.1h-8.5a5.1 5.1 0 0 1-5.1-5.1Z\"/><path class=\"ln\" d=\"M6.3 10.9v-0.6A2.4 2.4 0 0 1 8.7 8H9.2M13.5 9.4c0.8-0.9-0.8-1.9 0-2.8M17.3 9.4c0.8-0.9-0.8-1.9 0-2.8\"/>","id":"<path fill-rule=\"evenodd\" d=\"M4.8 4.6h14.4a2.3 2.3 0 0 1 2.3 2.3v10.3a2.3 2.3 0 0 1-2.3 2.3H4.8A2.3 2.3 0 0 1 2.5 17.2V6.9A2.3 2.3 0 0 1 4.8 4.6Zm4.1 4.2a2.2 2.2 0 1 0 0 4.3a2.2 2.2 0 0 0 0-4.3Zm-3.2 7.7h6.4c-0.6-1.7-1.8-2.7-3.2-2.7s-2.6 0.9-3.2 2.7Zm8.8-7.2v1.5h4V9.4Zm0 3.6v1.5h4v-1.5Z\"/>","idea":"<path d=\"M8.3 14.9A6.5 6.5 0 1 1 15.7 14.9c-0.7 0.6-1.3 1.3-1.4 2.2H9.7c-0.1-0.9-0.7-1.6-1.4-2.2Z\"/><path class=\"ln\" d=\"M9.6 20.5h4.8\"/>","image":"<path fill-rule=\"evenodd\" d=\"M2.8 6.1a2.7 2.7 0 0 1 2.7-2.7h13.3A2.7 2.7 0 0 1 21.5 6.1v12.3a2.7 2.7 0 0 1-2.7 2.7H5.4A2.7 2.7 0 0 1 2.8 18.4Zm6.2 2.2a2 2 0 1 0 0 3.9a2 2 0 0 0 0-3.9Zm7.5 3.5l-8.3 7.8h10.7a1.5 1.5 0 0 0 1.5-1.5v-3.1Z\"/>","inbox":"<path fill-rule=\"evenodd\" d=\"M2.7 12.4L5.2 3.7h13.6l2.5 8.8V17.9a1.6 1.6 0 0 1-1.6 1.6H4.2A1.6 1.6 0 0 1 2.7 17.9ZM6.4 5.6L4.6 11.5h3.8l1.5 2.4h4.3l1.5-2.4h3.8L17.6 5.6Z\"/>","info":"<path fill-rule=\"evenodd\" d=\"M12.9 2.6a9.4 9.4 0 1 0 0 18.7a9.4 9.4 0 0 0 0-18.7ZM11.7 10.5h2.5V16.9h-2.5ZM12.9 6.4a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3Z\"/>","invite":"<circle cx=\"8.8\" cy=\"7.7\" r=\"3.2\"/><path d=\"M2.9 19.2c0-3.4 2.7-5.8 5.8-5.8s5.8 2.4 5.8 5.8Z\"/><path class=\"ln\" d=\"M17.6 7.8v5.3M15 10.5h5.3\"/>","key":"<path fill-rule=\"evenodd\" d=\"M8.3 10.1a5.4 5.4 0 1 1 0 10.9a5.4 5.4 0 0 1 0-10.9Zm0 3.6a1.9 1.9 0 1 0 0 3.8a1.9 1.9 0 0 0 0-3.8Z\"/><path class=\"ln\" d=\"M11.7 12.2l8.7-8.7M16.7 7.2l3.1 3.1M14.4 9.5l2.1 2.1\"/>","keyboard":"<path fill-rule=\"evenodd\" d=\"M4.8 5.7h14.4A2.3 2.3 0 0 1 21.5 8v8a2.3 2.3 0 0 1-2.3 2.3H4.8A2.3 2.3 0 0 1 2.5 16V8a2.3 2.3 0 0 1 2.3-2.3ZM5.3 9.1h1.9v1.9H5.3ZM8.2 9.1h1.9v1.9H8.2ZM11 9.1h1.9v1.9h-1.9ZM13.9 9.1h1.9v1.9h-1.9ZM16.8 9.1h1.9v1.9h-1.9ZM7.8 13.3h8.4v1.7H7.8Z\"/>","laugh":"<path fill-rule=\"evenodd\" d=\"M13 2.7a9.4 9.4 0 1 0 0 18.7a9.4 9.4 0 0 0 0-18.7ZM7.9 8.1l3.5 1.7l-3.5 1.7l-0.7-1.4l0.7-0.4l-0.7-0.4ZM18 8.1l-3.5 1.7l3.5 1.7l0.7-1.4l-0.7-0.4l0.7-0.4ZM8.5 13.5h8.9a4.4 4.4 0 0 1-8.9 0Z\"/>","layers":"<path d=\"M12 4.3l8.6 5l-8.6 5l-8.6-5Z\"/><path class=\"ln\" d=\"M3.9 13.9l8.1 4.5l8.1-4.5\"/>","leaf":"<path fill-rule=\"evenodd\" d=\"M5.4 18.6c0-8.9 5.2-14.2 15.7-15.7c-1 10.4-6.3 15.7-15.7 15.7Zm3.1-2L16.8 7.1l-9.5 8.5Z\"/><path class=\"ln\" d=\"M3.7 20.3L6 18\"/>","lifebuoy":"<path fill-rule=\"evenodd\" d=\"M13.1 2.7a9.4 9.4 0 1 0 0 18.7a9.4 9.4 0 0 0 0-18.7Zm0 5.3a4.1 4.1 0 1 1 0 8.2a4.1 4.1 0 0 1 0-8.2ZM15.5 16.2L18.4 19.1L20.2 17.3L17.2 14.3ZM10.8 16.2L7.9 19.1L6 17.3L9 14.3ZM15.5 7.8L18.4 4.9L20.2 6.7L17.2 9.7ZM10.8 7.8L7.9 4.9L6 6.7L9 9.7Z\"/>","live":"<circle cx=\"12\" cy=\"12\" r=\"2.5\"/><path class=\"ln\" d=\"M8.5 8.5a4.9 4.9 0 0 0 0 6.9M15.5 8.5a4.9 4.9 0 0 1 0 6.9M6.2 6.2a8.2 8.2 0 0 0 0 11.5M17.8 6.2a8.2 8.2 0 0 1 0 11.5\"/>","lock":"<path d=\"M4.6 11.1a1.6 1.6 0 0 1 1.6-1.6h11.5a1.6 1.6 0 0 1 1.6 1.6v8.4a1.6 1.6 0 0 1-1.6 1.6H6.2A1.6 1.6 0 0 1 4.6 19.6Z\"/><path class=\"ln\" d=\"M7.9 9.4V6.8a4.1 4.1 0 0 1 8 0V9.4\"/>","mail":"<path d=\"M2.5 7.3v-0.4A2.3 2.3 0 0 1 4.8 4.6h14.4A2.3 2.3 0 0 1 21.5 6.9v0.4l-8.7 5.6a1.6 1.6 0 0 1-1.7 0ZM2.5 9.4v7.6A2.3 2.3 0 0 0 4.8 19.4h14.4a2.3 2.3 0 0 0 2.3-2.3V9.4l-8 5.2a2.7 2.7 0 0 1-3 0Z\"/>","map":"<path d=\"M2.6 6.1 8.3 4.2v14l-5.7 1.9ZM9.7 4.2l4.6 1.6v14l-4.6-1.6ZM15.7 5.8l5.7-1.9v14l-5.7 1.9Z\"/>","mask":"<path fill-rule=\"evenodd\" d=\"M2.7 6.1c3.3 1.4 6.4 2.1 9.4 2.1s6.1-0.6 9.4-2.1c0 7.1-2.9 11.7-5.8 11.7c-1.5 0-2.2-1.5-3.6-1.5s-2.1 1.5-3.6 1.5c-2.9 0-5.8-4.6-5.8-11.7ZM6.8 11.6a1.6 1.1 0 1 0 3.3 0a1.6 1.1 0 1 0-3.3 0ZM14 11.6a1.6 1.1 0 1 0 3.3 0a1.6 1.1 0 1 0-3.3 0Z\"/>","medical":"<path fill-rule=\"evenodd\" d=\"M5.3 6.3h13.5a2.7 2.7 0 0 1 2.7 2.7v8.7a2.7 2.7 0 0 1-2.7 2.7H5.3a2.7 2.7 0 0 1-2.7-2.7V9a2.7 2.7 0 0 1 2.7-2.7ZM10.9 10h2.3v2.2h2.2v2.3h-2.2V16.7h-2.3v-2.2H8.7v-2.3h2.2Z\"/><path class=\"ln\" d=\"M9.2 6.3V5.3a0.9 0.9 0 0 1 0.9-0.9h3.8a0.9 0.9 0 0 1 0.9 0.9v1\"/>","megaphone":"<path d=\"M2.7 9.1a1.1 1.1 0 0 1 1.1-1.1h2.7l9.7-4.8v16.1l-9.7-4.8H3.8A1.1 1.1 0 0 1 2.7 13.2Z\"/><path class=\"ln\" d=\"M6.6 14.5l1.2 5M18.8 8.5a3.4 3.4 0 0 1 0 5.4\"/>","members":"<circle cx=\"9.1\" cy=\"7.1\" r=\"3.5\"/><path d=\"M2.6 19.6c0-3.7 2.9-6.3 6.4-6.3s6.4 2.6 6.4 6.3Z\"/><circle cx=\"16.5\" cy=\"6.6\" r=\"2.8\"/><path d=\"M16.7 12.4c2.8 0.4 4.7 2.7 4.7 6.2V19.6h-4.3c0-2.8-1-5.1-2.7-6.5Z\"/>","mic":"<path d=\"M8.9 5.8a3.1 3.1 0 0 1 6.2 0V10.8a3.1 3.1 0 0 1-6.2 0Z\"/><path class=\"ln\" d=\"M5.9 10.8a6.1 6.1 0 0 0 12.3 0M12 16.9V20.2\"/>","mic-off":"<path d=\"M9 5.4A3.1 3.1 0 0 1 15.2 5.8V10.7a3.1 3.1 0 0 1-0.1 0.8Z\"/><path class=\"ln\" d=\"M5.4 10.7a6.6 6.6 0 0 0 11 5M18.7 10.7c0 0.8-0.1 1.5-0.4 2.2M12.1 17.3v2.8M4.5 4.1l15.1 15.1\"/>","minus-circle":"<path fill-rule=\"evenodd\" d=\"M12.9 2.7a9.4 9.4 0 1 0 0 18.7a9.4 9.4 0 0 0 0-18.7ZM7.9 10.9v2.5h10.2v-2.5Z\"/>","money":"<path fill-rule=\"evenodd\" d=\"M4.8 5.7h14.4a2.1 2.1 0 0 1 2.1 2.1v8.4a2.1 2.1 0 0 1-2.1 2.1H4.8a2.1 2.1 0 0 1-2.1-2.1V7.8a2.1 2.1 0 0 1 2.1-2.1ZM12 8.9a3.1 3.1 0 1 0 0 6.3a3.1 3.1 0 0 0 0-6.3ZM6.3 8.7a1 1 0 1 0 0 2.1a1 1 0 0 0 0-2.1Zm11.4 4.6a1 1 0 1 0 0 2.1a1 1 0 0 0 0-2.1Z\"/><circle cx=\"12\" cy=\"12\" r=\"1.4\"/>","moon":"<path d=\"M21.7 14.6A9.7 9.7 0 1 1 9.4 2.3a8 8 0 0 0 12.3 12.3Z\"/>","more":"<circle cx=\"4.8\" cy=\"12\" r=\"2.2\"/><circle cx=\"12\" cy=\"12\" r=\"2.2\"/><circle cx=\"19.2\" cy=\"12\" r=\"2.2\"/>","mountain":"<path d=\"M2.8 18.1L9.2 5.3l4.1 7.1L16.1 8.9L21.5 18.1Z\"/>","mug":"<path d=\"M3.8 8.9h12.2v7.6a4.6 4.6 0 0 1-4.6 4.6h-3a4.6 4.6 0 0 1-4.6-4.6Z\"/><path class=\"ln\" d=\"M16 11H16.9a2.5 2.5 0 0 1 0 5h-0.9M7.9 3.7c-0.8 1 0.8 2 0 3M11.4 3.7c-0.8 1 0.8 2 0 3\"/>","music":"<path d=\"M7.6 5.4l13.3-2.5v3.5l-13.3 2.5Z\"/><path class=\"ln\" d=\"M8.8 17.9V6.5M19.7 15.6V4.2\"/><circle cx=\"5.5\" cy=\"17.9\" r=\"3.2\"/><circle cx=\"16.7\" cy=\"15.6\" r=\"3.2\"/>","mute":"<path d=\"M2.9 8.8h4l5.5-4.2v14.8l-5.5-4.2H2.9Z\"/><path class=\"ln\" d=\"M15.5 9.6l4.7 4.7M20.3 9.6l-4.7 4.7\"/>","news":"<path fill-rule=\"evenodd\" d=\"M2.8 2.6h14.2v15.2a2.2 2.2 0 0 0 2.2 2.2H5.1A2.2 2.2 0 0 1 2.8 18.9ZM6.6 6.6h6.8v1.7H6.6Zm0 3.7h6.8v1.7H6.6Zm0 3.7h4v1.7H6.6Z\"/><path class=\"ln\" d=\"M17.1 7.7H19.8V17.9a2.1 2.1 0 0 1-2.1 2.1\"/>","nodes":"<circle cx=\"12\" cy=\"5.2\" r=\"3\"/><circle cx=\"5.6\" cy=\"18\" r=\"3\"/><circle cx=\"18.4\" cy=\"18\" r=\"3\"/><path class=\"ln\" d=\"M10.9 7.6L6.8 15.5M13.1 7.6l4.1 7.9M8.4 18h7.1\"/>","palette":"<path fill-rule=\"evenodd\" d=\"M12.1 2.7a9.4 9.4 0 1 0 0 18.8c1.3 0 2-0.9 2-1.9c0-1.2-1-1.5-1-2.5s0.8-1.6 2-1.6H17.3a4.4 4.4 0 0 0 4.4-4.4C21.7 6.7 17.4 2.7 12.1 2.7ZM7.8 9.7a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3Zm2.5-3.7a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3Zm4.6 0a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3Z\"/>","parking":"<path fill-rule=\"evenodd\" d=\"M6.2 2.6h11.6a3.6 3.6 0 0 1 3.6 3.6v11.6a3.6 3.6 0 0 1-3.6 3.6H6.2a3.6 3.6 0 0 1-3.6-3.6V6.2a3.6 3.6 0 0 1 3.6-3.6ZM8.4 18V6H13a4 4 0 0 1 0 8h-2.4v4ZM10.6 8.2v3.6H13a1.8 1.8 0 0 0 0-3.6Z\"/>","pause":"<rect x=\"4.8\" y=\"2.6\" width=\"5.1\" height=\"18.8\" rx=\"1.5\"/><rect x=\"14.2\" y=\"2.6\" width=\"5.1\" height=\"18.8\" rx=\"1.5\"/>","paw":"<path d=\"M6.9 18.5c0-3.5 2.2-6.3 5.1-6.3s5.1 2.8 5.1 6.3c0 1.8-1.3 2.9-2.9 2.9c-0.9 0-1.5-0.6-2.2-0.6s-1.3 0.6-2.2 0.6c-1.6 0-2.9-1.1-2.9-2.9Z\"/><circle cx=\"5.1\" cy=\"10.2\" r=\"2.6\"/><circle cx=\"9.1\" cy=\"5.1\" r=\"2.6\"/><circle cx=\"14.9\" cy=\"5.1\" r=\"2.6\"/><circle cx=\"18.9\" cy=\"10.2\" r=\"2.6\"/>","pencil":"<path d=\"M15.6 3.1a2.2 2.2 0 0 1 3.2 3.2L6.9 18.1l-4.5 1.2l1.2-4.5Z\"/><path class=\"ln\" d=\"M11.8 19.7H20.1\"/>","phone":"<path d=\"M5.1 2.4h4.9l2.4 6l-2.7 1.6a10.7 10.7 0 0 0 4.2 4.2l1.6-2.7l6 2.4v4.9a2.3 2.3 0 0 1-2.3 2.3A17.3 17.3 0 0 1 2.8 4.6a2.3 2.3 0 0 1 2.3-2.3Z\"/>","piggy":"<path fill-rule=\"evenodd\" d=\"M2.6 11.4c0-3.6 3.7-6.5 8.7-6.5c1.4 0 2.7 0.2 3.9 0.7L18.3 3.8v3.7c0.9 0.8 1.5 1.7 1.7 2.8h1.5v3.8H20c-0.5 1.1-1.4 2-2.6 2.6V20.1h-3.3v-2.2c-0.9 0.2-1.8 0.3-2.8 0.3c-0.9 0-1.7-0.1-2.6-0.3v2.2h-3.3v-3.3C3.7 15.5 2.6 13.6 2.6 11.4ZM14.5 9.8a1.1 1.1 0 1 0 2.2 0a1.1 1.1 0 1 0-2.2 0ZM8.3 6.8v1.5h3.7V6.8Z\"/>","pin":"<path fill-rule=\"evenodd\" d=\"M12 2.8a7.1 7.1 0 0 0-7.1 7.1c0 5.5 7.1 11.8 7.1 11.8s7.1-6.3 7.1-11.8A7.1 7.1 0 0 0 12 2.8Zm0 4.5a2.5 2.5 0 1 1 0 5.1a2.5 2.5 0 0 1 0-5.1Z\"/>","plane":"<path d=\"M21.2 2.9L2.4 10.7l6.9 3.2ZM21.2 2.9l-11 12l3.2 6.9Z\"/>","play":"<path d=\"M5.1 3.5v17a0.9 0.9 0 0 0 1.3 0.8l12.8-8.5a0.9 0.9 0 0 0 0-1.6L6.5 2.7a0.9 0.9 0 0 0-1.3 0.8Z\"/>","plug":"<path d=\"M6.5 8h10.9v3.3a5.4 5.4 0 0 1-10.9 0Z\"/><path class=\"ln\" d=\"M9.2 4.2v3.8M14.6 4.2v3.8M11.9 16.8v3.5\"/>","plus-circle":"<path fill-rule=\"evenodd\" d=\"M12.9 2.7a9.4 9.4 0 1 0 0 18.7a9.4 9.4 0 0 0 0-18.7Zm-1.2 4.2h2.5v3.8H18v2.5h-3.8V17.1h-2.5v-3.8H7.9v-2.5h3.8Z\"/>","print":"<path d=\"M6.2 2.6h11.5v4.4H6.2ZM2.6 11a2.7 2.7 0 0 1 2.7-2.7h13.4a2.7 2.7 0 0 1 2.7 2.7v4.1a2.7 2.7 0 0 1-2.7 2.7h-0.3v-4.5H5.6v4.5h-0.3A2.7 2.7 0 0 1 2.6 15.1ZM7.1 14.7h9.9v6.7H7.1Z\"/>","puzzle":"<path d=\"M2.8 5.4h5a2.9 2.9 0 1 1 5.8 0h5v5a2.9 2.9 0 1 1 0 5.8v5h-5a2.9 2.9 0 1 0-5.8 0H2.8v-5a2.9 2.9 0 1 0 0-5.8Z\"/>","radio":"<path fill-rule=\"evenodd\" d=\"M5.2 7.8h13.6a2.5 2.5 0 0 1 2.5 2.5v7.8a2.5 2.5 0 0 1-2.5 2.5H5.2A2.5 2.5 0 0 1 2.6 18.1v-7.8A2.5 2.5 0 0 1 5.2 7.8ZM12.9 14.2a2.5 2.5 0 1 0 5.1 0a2.5 2.5 0 1 0-5.1 0ZM6.1 11.6v2h4.7v-2ZM6.1 15v2h4.7v-2Z\"/><path class=\"ln\" d=\"M7.1 7.8L16.9 4\"/>","ranking":"<path d=\"M3.5 13.7h4.9V9.9h7.1v5.7h4.9v5.4H3.5ZM12 2.2l1 2l2.2 0.3l-1.6 1.6l0.4 2.2L12 7.5l-2 1l0.4-2.2l-1.6-1.6l2.2-0.3Z\"/>","record":"<path fill-rule=\"evenodd\" d=\"M13.2 2.7a9.3 9.3 0 1 0 0 18.5a9.3 9.3 0 0 0 0-18.5Zm0 3.3a6 6 0 1 1 0 11.9a6 6 0 0 1 0-11.9Z\"/><circle cx=\"13.2\" cy=\"12\" r=\"3.7\"/>","restroom":"<circle cx=\"7.2\" cy=\"5.1\" r=\"1.8\"/><circle cx=\"16.2\" cy=\"5.1\" r=\"1.8\"/><path d=\"M6 9h1.8a1.5 1.5 0 0 1 1.5 1.5v4.7H8.6v5H5.5v-5H4.7v-4.7A1.5 1.5 0 0 1 6 9Z\"/><path d=\"M15.7 9.4h1.1a0.9 0.9 0 0 1 0.9 0.7l1.9 6.3H17.4v3.6h-2.3v-3.6h-2.1l1.9-6.3a0.9 0.9 0 0 1 0.9-0.7Z\"/><path class=\"ln\" d=\"M11.6 3.7v16.3\"/>","retry":"<path class=\"ln\" d=\"M4 12a8 8 0 0 1 13.4-5.9M20 12a8 8 0 0 1-13.4 5.9\"/><path d=\"M21.3 2.8v7h-7ZM2.7 21.2v-7h7Z\"/>","rewind":"<path d=\"M21 5.5v13.1L12 12ZM11.3 5.5v13.1L2.2 12Z\"/>","robot":"<path fill-rule=\"evenodd\" d=\"M8.5 8.7h6.9a3.1 3.1 0 0 1 3.1 3.1v4.3A3.1 3.1 0 0 1 15.5 19.3H8.5A3.1 3.1 0 0 1 5.4 16.2v-4.3A3.1 3.1 0 0 1 8.5 8.7Zm0.9 3.8a1.2 1.2 0 1 0 0 2.4a1.2 1.2 0 0 0 0-2.4Zm5.2 0a1.2 1.2 0 1 0 0 2.4a1.2 1.2 0 0 0 0-2.4ZM9.7 16v1.3h4.5v-1.3Z\"/><path class=\"ln\" d=\"M12 5.3v3.5M3.9 13.1v2.8M20.2 13.1v2.8\"/>","rocket":"<path fill-rule=\"evenodd\" d=\"M11.9 16.6L7.4 12.2C9.1 6.1 13.6 2.9 21.1 2.9c0 7.5-3.2 12-9.2 13.7ZM13.6 8.7a1.7 1.7 0 1 0 3.5 0a1.7 1.7 0 1 0-3.5 0Z\"/><path d=\"M3.7 12.2L6.8 7.5h3.5L6.6 12.6ZM11.4 17.4L11.9 20.3l4.6-3.3v-3.5ZM6.3 17.7c-2 0-3.9 2-3.9 3.9c2 0 3.9-2 3.9-3.9Z\"/>","save":"<path fill-rule=\"evenodd\" d=\"M4.4 2.7h12.4l3.5 3.5V20.8a0.7 0.7 0 0 1-0.7 0.7H4.4a0.7 0.7 0 0 1-0.7-0.7V3.4a0.7 0.7 0 0 1 0.7-0.7Zm3.4 1.4v4.9h7.4V4.1ZM7.2 13.6v6.4h9.6v-6.4Z\"/>","scissors":"<path fill-rule=\"evenodd\" d=\"M6.2 3.8a3.2 3.2 0 1 1 0 6.5a3.2 3.2 0 0 1 0-6.5Zm0 2a1.2 1.2 0 1 0 0 2.4a1.2 1.2 0 0 0 0-2.4ZM6.2 13.7a3.2 3.2 0 1 1 0 6.5a3.2 3.2 0 0 1 0-6.5Zm0 2a1.2 1.2 0 1 0 0 2.4a1.2 1.2 0 0 0 0-2.4Z\"/><path class=\"ln\" d=\"M8.7 8.8L20.4 18.1M8.7 15.2L20.4 5.9\"/>","search":"<path fill-rule=\"evenodd\" d=\"M10.6 3a7.7 7.7 0 1 1 0 15.4a7.7 7.7 0 0 1 0-15.4Zm0 3.2a4.5 4.5 0 1 0 0 9.2a4.5 4.5 0 0 0 0-9.2Z\"/><path class=\"ln\" d=\"M16.3 16.4l3.8 3.8\"/>","seedling":"<path d=\"M12.4 10.8C12.4 6.1 9.5 2.9 4 2.9c0 4.9 3 7.8 8.4 7.8ZM11.6 13.4c0-4.7 3-7.8 8.4-7.8c0 4.9-3 7.8-8.4 7.8Z\"/><path class=\"ln\" d=\"M12 20.4V12.5\"/>","server":"<path fill-rule=\"evenodd\" d=\"M5.2 2.6h13.4a2.7 2.7 0 0 1 2.7 2.7v3.4a2.7 2.7 0 0 1-2.7 2.7H5.2A2.7 2.7 0 0 1 2.5 8.6V5.3A2.7 2.7 0 0 1 5.2 2.6Zm2.2 2.9a1.5 1.5 0 1 0 0 2.9a1.5 1.5 0 0 0 0-2.9ZM5.2 12.7h13.4a2.7 2.7 0 0 1 2.7 2.7v3.4a2.7 2.7 0 0 1-2.7 2.7H5.2A2.7 2.7 0 0 1 2.5 18.7v-3.4A2.7 2.7 0 0 1 5.2 12.7Zm2.2 2.9a1.5 1.5 0 1 0 0 2.9a1.5 1.5 0 0 0 0-2.9Z\"/>","shapes":"<circle cx=\"7.3\" cy=\"7.6\" r=\"4.3\"/><path d=\"M16.5 2.7l5 8.6h-9.8ZM13.4 13.7h7.4v7.4h-7.4ZM2.5 21.1L6.8 13.7l4.3 7.4Z\"/>","shelter":"<path fill-rule=\"evenodd\" d=\"M12 2.7L2.5 10.2l1.1 1.5l1-0.8v10h14.8V10.9l1 0.8l1.1-1.5ZM9.5 20.9v-4.8a2.5 2.5 0 0 1 4.9 0v4.8ZM12 9.1a1.7 1.7 0 1 1 0 3.4a1.7 1.7 0 0 1 0-3.4Z\"/>","shield":"<path d=\"M12 2.6 4.6 5.8V12c0 4.6 3.1 8 7.4 9.5 4.3-1.5 7.4-4.9 7.4-9.5V5.8Z\"/>","shirt":"<path d=\"M8 2.7L2.5 5.9l2.5 4.9l1.5-0.8v11.2h11V10.1l1.5 0.8L21.5 5.9l-5.5-3.2c-0.6 1.5-1.9 2.5-4 2.5S8.6 4.2 8 2.7Z\"/>","skip":"<path d=\"M3.3 2.6v18.9L15.8 12Z\"/><rect x=\"17.8\" y=\"2.7\" width=\"3.2\" height=\"18.7\" rx=\"1.5\"/>","skull":"<path fill-rule=\"evenodd\" d=\"M12 2.6a7.9 7.9 0 0 0-7.9 7.9c0 2.4 1.1 4.2 2.5 5.4V19.5a2 2 0 0 0 2 2h6.8a2 2 0 0 0 2-2v-3.6c1.4-1.2 2.5-3 2.5-5.4A7.9 7.9 0 0 0 12 2.6ZM9.2 9.4a1.7 1.7 0 1 1 0 3.4a1.7 1.7 0 0 1 0-3.4ZM14.8 9.4a1.7 1.7 0 1 1 0 3.4a1.7 1.7 0 0 1 0-3.4ZM12 13.3l1 1.8h-2ZM10.1 18.1h0.9v2.4h-0.9ZM13 18.1h0.9v2.4H13Z\"/>","sliders":"<path class=\"ln\" d=\"M4 7h8.8M19.2 7H20M4 17h2.8M13.2 17H20\"/><circle cx=\"16\" cy=\"7\" r=\"3.2\"/><circle cx=\"10\" cy=\"17\" r=\"3.2\"/>","smile":"<path fill-rule=\"evenodd\" d=\"M12.9 2.7a9.4 9.4 0 1 0 0 18.7a9.4 9.4 0 0 0 0-18.7ZM9.9 8.6a1.4 1.4 0 1 1 0 2.8a1.4 1.4 0 0 1 0-2.8ZM16 8.6a1.4 1.4 0 1 1 0 2.8a1.4 1.4 0 0 1 0-2.8ZM8.8 13.5h8.3a4.1 4.1 0 0 1-8.3 0Z\"/>","spark":"<path d=\"M10.7 2.6l2 5.6l5.6 2l-5.6 2L10.7 17.8l-2-5.6l-5.6-2l5.6-2ZM17.6 14.7l0.9 2.5l2.5 0.9l-2.5 0.9l-0.9 2.5l-0.9-2.5l-2.5-0.9l2.5-0.9Z\"/>","square":"<rect x=\"2.7\" y=\"2.7\" width=\"18.7\" height=\"18.7\" rx=\"3.3\"/>","star":"<path d=\"M12 2.8l2.9 6l6.6 0.8l-4.9 4.5l1.3 6.6L12 17.4l-5.9 3.2l1.3-6.6l-4.9-4.5l6.6-0.8Z\"/>","steps":"<path fill-rule=\"evenodd\" d=\"M2.8 4h4.9v4.9H2.8Zm1 2.5l0.8-0.9l0.9 0.9l1.4-1.4l0.9 0.9l-2.3 2.3ZM2.8 9.4h4.9v4.9H2.8Zm1 2.5l0.8-0.9l0.9 0.9l1.4-1.4l0.9 0.9l-2.3 2.3ZM2.8 15h4.9v4.9H2.8Zm1 2.5l0.8-0.9l0.9 0.9l1.4-1.4l0.9 0.9l-2.3 2.3Z\"/><path class=\"ln\" d=\"M10.6 6.4H20.3M10.6 11.9H20.3M10.6 17.4H20.3\"/>","store":"<path d=\"M4.4 3h15l2 6.2v1.4a3.2 3.2 0 0 1-6.3 0a3.2 3.2 0 0 1-6.3 0a3.2 3.2 0 0 1-6.3 0V9.2ZM4 14.9h15.9v5.6a1.1 1.1 0 0 1-1.1 1.1H14.1v-5.1h-4.4v5.1H5.1a1.1 1.1 0 0 1-1.1-1.1Z\"/>","sun":"<circle cx=\"12\" cy=\"12\" r=\"3.8\"/><path class=\"ln\" d=\"M12 3.9v1.3M12 18.7v1.3M6.3 6.2l0.9 0.9M16.8 16.8L17.8 17.8M4 12h1.3M18.7 12h1.3M6.3 17.8l0.9-0.9M16.8 7.2L17.8 6.2\"/>","sword":"<path d=\"M21.2 2.8v4.7l-10.8 10.8l-4.7-4.7L16.5 2.8Z\"/><path class=\"ln\" d=\"M5.2 11.9L12.1 18.8M8.4 15.6L3.7 20.3\"/>","table":"<path d=\"M2.7 6.2a2.5 2.5 0 0 1 2.5-2.5h13.6a2.5 2.5 0 0 1 2.5 2.5v2.3H2.7ZM2.7 10.5h5.3v9.9H5.2a2.5 2.5 0 0 1-2.5-2.5ZM9.8 10.5h4.5v9.9H9.8ZM16.1 10.5h5.3V17.9a2.5 2.5 0 0 1-2.5 2.5h-2.7Z\"/>","tack":"<path d=\"M8.5 3h7.2a0.8 0.8 0 0 1 0.8 1l-0.8 4l2.8 2.8a0.7 0.7 0 0 1-0.6 1.4H6.1a0.7 0.7 0 0 1-0.6-1.4l2.8-2.8l-0.8-4a0.8 0.8 0 0 1 0.8-1Z\"/><path class=\"ln\" d=\"M12 12.2V20.5\"/>","tag":"<path fill-rule=\"evenodd\" d=\"M2.9 3.4A1.5 1.5 0 0 1 4.4 2.9h7.2l10 10l-8.7 8.7L2.9 10.6Zm5 2.5a1.9 1.9 0 1 0 0 3.7a1.9 1.9 0 0 0 0-3.7Z\"/>","target":"<path fill-rule=\"evenodd\" d=\"M12 2.6a9.4 9.4 0 1 1 0 18.8a9.4 9.4 0 0 1 0-18.8Zm0 3a6.4 6.4 0 1 0 0 12.9a6.4 6.4 0 0 0 0-12.9Zm0 2.7a3.7 3.7 0 1 1 0 7.4a3.7 3.7 0 0 1 0-7.4Z\"/>","tent":"<path fill-rule=\"evenodd\" d=\"M12 4.2L19.3 17.4H4.7ZM12 11.6L9.5 17.4h5.1Z\"/><path class=\"ln\" d=\"M3.9 17.7h16.2\"/>","terminal":"<path fill-rule=\"evenodd\" d=\"M5 3.6h14a2.4 2.4 0 0 1 2.4 2.4v12a2.4 2.4 0 0 1-2.4 2.4H5A2.4 2.4 0 0 1 2.6 18V6A2.4 2.4 0 0 1 5 3.6Zm1.4 6 1.3-1.3 3.6 3.7-3.6 3.7-1.3-1.3 2.4-2.4Zm6.4 4.6h4.4v1.7h-4.4Z\"/>","text":"<path fill-rule=\"evenodd\" d=\"M10.2 2.4h3.7l6.8 18.7h-3.4l-1.6-4.7H8.3l-1.6 4.7H3.3ZM9.3 13.6h5.4L12 5.7Z\"/>","thumbs-up":"<path d=\"M6.5 10.1v10.3H3.6a1.1 1.1 0 0 1-1.1-1.1v-8a1.1 1.1 0 0 1 1.1-1.1ZM7.9 10.1l3.6-7.3a2.4 2.4 0 0 1 2.7 2.8l-0.6 3.5h5.6a2.2 2.2 0 0 1 2.2 2.6l-1.4 7a2.2 2.2 0 0 1-2.2 1.7H7.9Z\"/>","ticket":"<path fill-rule=\"evenodd\" d=\"M2.6 6.4h18.8v3.4a2.2 2.2 0 0 0 0 4.4v3.4H2.6v-3.4a2.2 2.2 0 0 0 0-4.4ZM13.3 8.4v1.8h1.4V8.4Zm0 2.7v1.8h1.4v-1.8Zm0 2.7v1.8h1.4v-1.8Z\"/>","toolbox":"<path d=\"M2.7 10.1a2.5 2.5 0 0 1 2.5-2.5h13.6a2.5 2.5 0 0 1 2.5 2.5v2.2H2.7ZM2.7 13.7h4.7v1.5h1.6v-1.5h6.2v1.5h1.6v-1.5h4.7V17.8a2.5 2.5 0 0 1-2.5 2.5H5.2A2.5 2.5 0 0 1 2.7 17.8Z\"/><path class=\"ln\" d=\"M9.1 7.5V5.7A1.5 1.5 0 0 1 10.5 4.2h2.9A1.5 1.5 0 0 1 14.9 5.7v1.9\"/>","train":"<path fill-rule=\"evenodd\" d=\"M9.1 2.7h5.8a2.9 2.9 0 0 1 2.9 2.9v6.8a2.9 2.9 0 0 1-2.9 2.9H9.1a2.9 2.9 0 0 1-2.9-2.9V5.6a2.9 2.9 0 0 1 2.9-2.9Zm-1.2 2.5v4.1h8.2V5.2ZM9.1 11.4a1.1 1.1 0 1 0 0 2.1A1.1 1.1 0 0 0 9.1 11.4Zm5.8 0a1.1 1.1 0 1 0 0 2.1a1.1 1.1 0 0 0 0-2.1Z\"/><path class=\"ln\" d=\"M8.6 20.2l1.9-3.4M15.4 20.2l-1.9-3.4\"/>","trash":"<path d=\"M5.8 7.6h12.4l-1 12a2 2 0 0 1-2 1.8H8.8a2 2 0 0 1-2-1.8Z\"/><path class=\"ln\" d=\"M3.8 6.6h16.4M9 6.6V4h6v2.6\"/>","tree":"<path d=\"M12 2.5L5.9 10.7h3L4.4 16.9h15.1l-4.5-6.2h3Z\"/><path class=\"ln\" d=\"M12 16.2v4\"/>","trophy":"<path d=\"M7.3 3h9.6V8.6a4.7 4.7 0 0 1-9.6 0Z\"/><path class=\"ln\" d=\"M7.3 5.6H5a3.1 3.1 0 0 0 3.1 4.1M16.7 5.6h2.3a3.1 3.1 0 0 1-3.1 4.1M12 13.7V16.9M8 20.4h8.1\"/>","tv":"<rect x=\"2.7\" y=\"7.4\" width=\"18.6\" height=\"13.3\" rx=\"2.3\"/><path class=\"ln\" d=\"M8.2 3.8l3.8 3.4l3.8-3.4\"/>","umbrella":"<path d=\"M2.6 12.7a9.4 9.4 0 0 1 18.8 0Z\"/><path class=\"ln\" d=\"M12 12.7v5.6a1.9 1.9 0 0 1-3.8 0\"/>","upload":"<path d=\"M10.6 21.7h2.5v-7h4.7L11.9 8.6l-5.9 5.9h4.7Z\"/><path class=\"ln\" d=\"M4.5 4.3h14.9\"/>","user":"<circle cx=\"12\" cy=\"6.7\" r=\"4.5\"/><path d=\"M3.5 21c0-4.7 3.8-7.8 8.5-7.8s8.5 3.2 8.5 7.8Z\"/>","user-check":"<circle cx=\"8.9\" cy=\"7.5\" r=\"3.2\"/><path d=\"M2.9 19.3c0-3.4 2.7-5.9 5.9-5.9s5.9 2.4 5.9 5.9Z\"/><path class=\"ln\" d=\"M14.8 10.6l1.9 1.9l3.8-3.8\"/>","user-circle":"<path fill-rule=\"evenodd\" d=\"M13 2.9a9.4 9.4 0 1 0 0 18.7a9.4 9.4 0 0 0 0-18.7Zm0 3.8a3.3 3.3 0 1 1 0 6.5a3.3 3.3 0 0 1 0-6.5ZM7.2 17.9a6.7 6.7 0 0 1 11.5 0a8.1 8.1 0 0 1-11.5 0Z\"/>","user-lock":"<circle cx=\"9.9\" cy=\"7.2\" r=\"4.2\"/><path d=\"M2.7 20c0-4.2 3.2-7 7.2-7c1.2 0 2.3 0.3 3.2 0.8v6.2Z\"/><rect x=\"14.5\" y=\"14.5\" width=\"6.9\" height=\"5.7\" rx=\"1.3\"/><path class=\"ln\" d=\"M16.4 14.5v-1a1.5 1.5 0 0 1 3 0v1\"/>","user-minus":"<circle cx=\"9.6\" cy=\"7.6\" r=\"3.9\"/><path d=\"M2.8 19.7c0-3.9 3.1-6.6 6.8-6.6c1.2 0 2.2 0.3 3.1 0.8v5.9Z\"/><path class=\"ln\" d=\"M15.1 16.7h5.2\"/>","user-off":"<path d=\"M9.1 4.7A4.2 4.2 0 1 1 15 10.6ZM4.2 20.8c0-4.3 2.8-6.9 6.3-7.2L17.5 20.8Z\"/><path class=\"ln\" d=\"M4 3.5l16.2 16.2\"/>","user-pen":"<circle cx=\"9.8\" cy=\"7.6\" r=\"4\"/><path d=\"M2.9 19.9c0-4 3.1-6.7 6.9-6.7c1.2 0 2.2 0.3 3.1 0.8v5.9Z\"/><path d=\"M19.5 11.4l2.1 2.1l-5.2 5.2l-2.8 0.7l0.7-2.8Z\"/>","user-tag":"<circle cx=\"10\" cy=\"7.4\" r=\"4\"/><path d=\"M2.9 19.9c0-4 3.2-6.8 7-6.8c1.2 0 2.3 0.3 3.2 0.8v6Z\"/><path fill-rule=\"evenodd\" d=\"M14 13.7h4.5l3.1 3.1l-3.1 3.1h-4.5ZM15.2 16.8a1.1 1.1 0 1 0 2.2 0a1.1 1.1 0 1 0-2.2 0Z\"/>","video":"<path d=\"M3 7.7a2 2 0 0 1 2-2H14.4a2 2 0 0 1 2 2v8.7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2ZM17.5 10.1l4.2-2.6v8.9l-4.2-2.6Z\"/>","volume":"<path d=\"M2.5 8.9h4l5.4-4.2v14.6l-5.4-4.2H2.5Z\"/><path class=\"ln\" d=\"M15.2 9.3a3.8 3.8 0 0 1 0 5.5M17.7 6.5a7.5 7.5 0 0 1 0 11\"/>","walk":"<circle cx=\"12.4\" cy=\"4.6\" r=\"2.2\"/><path class=\"ln\" d=\"M8.7 19.8l2.4-5.5l1.9 2.2V19.8M11.1 14.3l0.9-5.2l-2.8 1.5l-1.4 2.8M12 9.1l1.8 2.8l2.8 0.9\"/>","wallet":"<path fill-rule=\"evenodd\" d=\"M5.7 2.8H19v4.6h1.6a0.9 0.9 0 0 1 0.9 0.9v12.4H5.1a2.5 2.5 0 0 1-2.5-2.5V5.9a3.1 3.1 0 0 1 3.1-3.1Zm0 2.1a1 1 0 0 0 0 2.1h11.2V4.9ZM16.7 12.9a1.4 1.4 0 1 0 0 2.8a1.4 1.4 0 0 0 0-2.8Z\"/>","water":"<path d=\"M2.7 7.9c2.4 0 2.4-2 4.7-2s2.4 2 4.6 2s2.4-2 4.7-2s2.4 2 4.6 2v3.2c-2.3 0-2.3-2-4.6-2s-2.4 2-4.7 2s-2.3-2-4.6-2s-2.4 2-4.7 2ZM2.7 14.8c2.4 0 2.4-2 4.7-2s2.4 2 4.6 2s2.4-2 4.7-2s2.4 2 4.6 2v3.2c-2.3 0-2.3-2-4.6-2s-2.4 2-4.7 2s-2.3-2-4.6-2s-2.4 2-4.7 2Z\"/>","weight":"<circle cx=\"12\" cy=\"4.8\" r=\"2.6\"/><path d=\"M6.5 8.7h10.9l3.3 12.3H3.2Z\"/>","window":"<path d=\"M2.6 6.2a2.7 2.7 0 0 1 2.7-2.7h13.4a2.7 2.7 0 0 1 2.7 2.7v1.5H2.6ZM2.6 9.8h18.7v7.7a2.7 2.7 0 0 1-2.7 2.7h-13.4a2.7 2.7 0 0 1-2.7-2.7Z\"/>","window-restore":"<path d=\"M2.7 10.4a2.7 2.7 0 0 1 2.7-2.7h8.2a2.7 2.7 0 0 1 2.7 2.7v7.2a2.7 2.7 0 0 1-2.7 2.7h-8.2a2.7 2.7 0 0 1-2.7-2.7ZM7.9 6.4V6.3a2.7 2.7 0 0 1 2.7-2.7h8.2a2.7 2.7 0 0 1 2.7 2.7v7.2a2.7 2.7 0 0 1-2.7 2.7h-1.1v-5.8a4 4 0 0 0-4-4Z\"/>","wrench":"<path d=\"M14.9 2.5a6 6 0 0 0-5.5 8.3L2.7 17.5V21.3H6.5l6.7-6.7a6 6 0 0 0 8.3-5.5l-3.5 1.3l-2.9-2.9l1.3-3.5Z\"/>","x-circle":"<path fill-rule=\"evenodd\" d=\"M12.9 2.7a9.4 9.4 0 1 0 0 18.7a9.4 9.4 0 0 0 0-18.7ZM10.1 7.9L12.9 10.7l2.9-2.9l1.4 1.4l-2.9 2.9l2.9 2.9l-1.4 1.4l-2.9-2.9l-2.9 2.9l-1.4-1.4l2.9-2.9l-2.9-2.9Z\"/>","zap":"<path d=\"M13.9 2.5L5.2 13.3h6.1l-1.2 8.1l8.7-10.8h-6.1Z\"/>"};
++ const FA = {"address-card":"id","adjust":"contrast","align-left":"align-left","angle-down":"down","angle-right":"chevron","angles-left":"chevrons-left","angles-right":"chevrons-right","arrow-down":"arrow-down","arrow-down-short-wide":"sort-asc","arrow-down-wide-short":"sort","arrow-left":"arrow-left","arrow-right":"arrow-right","arrow-right-arrow-left":"swap","arrow-right-from-bracket":"leave","arrow-right-to-bracket":"enter","arrow-rotate-right":"retry","arrow-trend-down":"trend-down","arrow-trend-up":"trend-up","arrow-up":"arrow-up","arrow-up-from-bracket":"share","arrow-up-right-from-square":"open","arrows-left-right":"arrows-h","arrows-rotate":"retry","arrows-to-dot":"focus","arrows-up-down-left-right":"move","at":"at","backward":"rewind","backward-fast":"rewind","balance-scale":"scale","ban":"ban","bars":"menu","basket-shopping":"cart","bell":"bell","bolt":"zap","bolt-lightning":"zap","book":"book","book-open":"book-open","box":"box","box-archive":"archive","box-open":"box","brain":"brain","bridge":"bridge","briefcase":"briefcase","briefcase-medical":"medical","broadcast-tower":"live","bug":"bug","bullhorn":"megaphone","bullseye":"target","burst":"burst","calendar":"calendar","calendar-day":"calendar","calendar-days":"calendar","camera":"camera","camera-cctv":"camera","camera-retro":"camera","camera-rotate":"camera-flip","camera-slash":"camera-off","campground":"tent","car":"car","caret-down":"down","cart":"cart","cart-plus":"cart","cart-shopping":"cart","chart-area":"chart","chart-bar":"chart","chart-column":"chart","chart-line":"chart","chart-simple":"chart","check":"check","check-circle":"check-circle","check-double":"check-double","chevron-down":"down","chevron-left":"back","chevron-right":"chevron","chevron-up":"up","child":"child","circle":"circle","circle-check":"check-circle","circle-dollar-to-slot":"donate","circle-dot":"record","circle-exclamation":"alert-circle","circle-half-stroke":"contrast","circle-info":"info","circle-minus":"minus-circle","circle-nodes":"nodes","circle-notch":"busy","circle-play":"play","circle-plus":"plus-circle","circle-question":"help","circle-user":"user-circle","circle-xmark":"x-circle","city":"city","clapperboard":"clapper","clipboard":"clipboard","clipboard-check":"clipboard-check","clipboard-list":"clipboard","clock":"clock","clock-rotate-left":"history","clone":"copy","closed-captioning":"cc","cloud":"cloud","cloud-arrow-up":"upload","cloud-bolt":"storm","cloud-moon":"cloud","cloud-rain":"cloud","cloud-showers-heavy":"cloud","cloud-slash":"cloud-off","cloud-sun":"cloud","code":"code","code-branch":"branch","code-commit":"commit","code-fork":"branch","coin":"coin","coins":"coin","comment":"chat","comment-dots":"chat","comment-slash":"chat-off","comments":"chat","compass":"compass","compress":"compress","cookie":"cookie","cookie-bite":"cookie","copy":"copy","couch":"couch","credit-card":"card","crown":"crown","cube":"box","cubes":"cubes","cut":"scissors","database":"database","desktop":"computer","diagram-project":"nodes","dice":"dice","dice-five":"dice","dice-six":"dice","display":"computer","dollar-sign":"dollar","door-closed":"door","door-open":"door","down-left-and-up-right-to-center":"compress","download":"download","droplet":"drop","ear-listen":"ear","earth-americas":"globe","earth-europe":"globe","ellipsis":"more","ellipsis-vertical":"more","envelope":"mail","eraser":"eraser","exchange-alt":"swap","expand":"expand","external-link":"open","eye":"eye","eye-slash":"eye-off","face-grin":"laugh","face-grin-squint":"laugh","face-grin-squint-tears":"laugh","face-grin-stars":"smile","face-laugh":"laugh","face-smile":"smile","faucet":"drop","faucet-drip":"drop","file":"file","file-audio":"file","file-code":"file-code","file-image":"image","file-lines":"file","file-pdf":"file","file-pen":"pencil","film":"film","filter":"filter","fire":"fire","fish":"fish","fist-raised":"fist","flag":"flag","flag-checkered":"finish","flask":"flask","flask-vial":"flask","floppy-disk":"save","folder":"folder","folder-open":"folder","folder-tree":"folder-tree","font":"text","forward":"fast-forward","forward-fast":"fast-forward","forward-step":"skip","gamepad":"game","gauge":"gauge","gauge-high":"gauge","gauge-simple":"gauge","gauge-simple-high":"gauge","gauge-simple-low":"gauge","gavel":"gavel","gear":"gear","gears":"gear","gem":"gem","ghost":"ghost","gift":"gift","globe":"globe","grid":"grid","grip":"grip","grip-vertical":"grip","hammer":"hammer","hand":"hand","hand-fist":"fist","hand-holding-dollar":"coin","hand-holding-heart":"care","hand-point-right":"hand","hand-pointer":"hand","handshake":"handshake","handshake-angle":"handshake","hard-drive":"drive","hashtag":"hash","hat-wizard":"wizard","hdd":"drive","heading":"heading","headphones":"headphones","headphones-simple":"headphones","headset":"headphones","heart":"heart","heart-crack":"heart-broken","heart-pulse":"heart-pulse","heartbeat":"heart-pulse","hiking":"mountain","hot-tub":"hot-tub","hot-tub-person":"hot-tub","hourglass":"hourglass","hourglass-end":"hourglass","hourglass-half":"hourglass","hourglass-start":"hourglass","house":"home","house-chimney":"home","house-signal":"home","icicles":"snow","icons":"shapes","id-card":"id","image":"image","images":"image","inbox":"inbox","infinity":"infinity","info-circle":"info","key":"key","keyboard":"keyboard","kit-medical":"medical","language":"language","laptop":"computer","layer-group":"layers","leaf":"leaf","left-right":"arrows-h","life-ring":"lifebuoy","lightbulb":"idea","link":"link","link-slash":"link-off","list":"list","list-check":"steps","list-ol":"list-ordered","list-ul":"list","location-dot":"pin","location-pin":"pin","lock":"lock","lungs":"lungs","magnifying-glass":"search","map":"map","map-location-dot":"map","map-marker-alt":"pin","map-pin":"pin","mask":"mask","message":"chat","microchip":"chip","microphone":"mic","microphone-lines":"mic","microphone-slash":"mic-off","minus":"minus","minus-circle":"minus-circle","mobile-screen":"phone","mobile-screen-button":"phone","money-bill":"money","money-bill-1":"money","money-bill-transfer":"money","money-bill-wave":"money","money-check":"money","moon":"moon","mountain":"mountain","mountain-sun":"mountain","mug-hot":"mug","mug-saucer":"mug","music":"music","network-wired":"nodes","newspaper":"news","object-group":"group","paint-brush":"palette","paintbrush":"palette","palette":"palette","paper-plane":"plane","parking":"parking","paste":"clipboard","pause":"pause","paw":"paw","pen":"pencil","pen-nib":"pencil","pen-to-square":"pencil","pencil":"pencil","people-arrows":"members","people-group":"members","person-hiking":"mountain","person-shelter":"shelter","person-swimming":"water","person-walking":"walk","phone":"phone","phone-slash":"phone-off","photo-film":"film","piggy-bank":"piggy","plane":"airplane","plane-departure":"airplane","play":"play","plug":"plug","plug-circle-bolt":"plug","plug-circle-check":"plug","plug-circle-xmark":"plug","plus":"plus","plus-circle":"plus-circle","print":"print","puzzle":"puzzle","puzzle-piece":"puzzle","puzzle-piece-simple":"puzzle","question":"help","quote-left":"quote","quote-right":"quote","radio":"radio","ranking-star":"ranking","recycle":"recycle","reply":"reply","restroom":"restroom","right-from-bracket":"leave","right-left":"swap","right-to-bracket":"enter","road":"road","robot":"robot","rocket":"rocket","rotate":"retry","rotate-back":"undo","rotate-left":"retry","rotate-right":"retry","route":"route","rss":"rss","satellite-dish":"dish","save":"save","scale-balanced":"scale","scissors":"scissors","screwdriver-wrench":"wrench","scroll":"scroll","search":"search","seedling":"seedling","server":"server","shapes":"shapes","share-nodes":"nodes","shield":"shield","shield-halved":"shield","shirt":"shirt","signal":"wifi","signature":"signature","sitemap":"nodes","skull":"skull","skull-crossbones":"skull","slash":"slash","sliders":"sliders","sliders-h":"sliders","smog":"cloud","snowflake":"snow","sparkles":"spark","spinner":"busy","square":"square","square-parking":"parking","star":"star","star-of-life":"spark","step-forward":"skip","stop":"stop","store":"store","suitcase":"briefcase","suitcase-medical":"medical","sun":"sun","sword":"sword","table":"table","table-cells":"grid","table-columns":"columns","table-list":"table","tag":"tag","tags":"tag","television":"tv","terminal":"terminal","text-height":"text","th":"grid","thumbs-up":"thumbs-up","thumbtack":"tack","ticket":"ticket","timeline":"timeline","times":"close","toolbox":"toolbox","tower-broadcast":"live","tower-cell":"live","train":"train","trash":"trash","trash-can":"trash","tree":"tree","triangle-exclamation":"alert","trophy":"trophy","tv":"tv","umbrella":"umbrella","umbrella-beach":"umbrella","undo":"undo","unlock":"lock","up-right-and-down-left-from-center":"expand","up-right-from-square":"open","upload":"upload","user":"user","user-astronaut":"user","user-check":"user-check","user-circle":"user-circle","user-edit":"user-pen","user-group":"members","user-lock":"user-lock","user-minus":"user-minus","user-pen":"user-pen","user-plus":"invite","user-secret":"user","user-shield":"shield","user-slash":"user-off","user-tag":"user-tag","users":"members","utensils":"food","video":"video","video-slash":"video","volume-high":"volume","volume-low":"volume","volume-off":"mute","volume-xmark":"mute","wallet":"wallet","wand-magic":"spark","wand-magic-sparkles":"spark","water":"water","wave-square":"wave","weight-hanging":"weight","wifi":"wifi","wind":"wind","window-maximize":"window","window-restore":"window-restore","wrench":"wrench","xmark":"close","xmark-circle":"x-circle"};
++ const FB = {"youtube":"youtube","square-youtube":"youtube","twitch":"twitch","discord":"discord","github":"github","square-github":"github","x-twitter":"x","square-x-twitter":"x","twitter":"x","kick":"kick","kickstarter-k":"kick","kickstarter":"kickstarter","google":"google","tiktok":"tiktok","threads":"threads","spotify":"spotify","soundcloud":"soundcloud","reddit":"reddit","reddit-alien":"reddit","tumblr":"tumblr","hacker-news":"ycombinator","line":"line","paypal":"paypal","patreon":"patreon","mastodon":"mastodon","linux":"linux","instagram":"instagram","facebook":"facebook","facebook-f":"facebook","cloudflare":"cloudflare","brave":"brave","bluesky":"bluesky","bitcoin":"bitcoin","apple":"apple","telegram":"telegram","whatsapp":"whatsapp","steam":"steam","snapchat":"snapchat","pinterest":"pinterest","vimeo":"vimeo","vimeo-v":"vimeo","npm":"npm","node":"nodedotjs","node-js":"nodedotjs","docker":"docker","python":"python","android":"android","chrome":"googlechrome","firefox":"firefoxbrowser","firefox-browser":"firefoxbrowser","gitlab":"gitlab","ubuntu":"ubuntu","raspberry":"raspberrypi","raspberry-pi":"raspberrypi","stripe":"stripe","cc-stripe":"stripe","signal":"signal","matrix":"matrix","medium":"medium","wikipedia":"wikipedia","wikipedia-w":"wikipedia","ethereum":"ethereum"};
++ // Font Awesome's style, size, layout and animation classes: they say how, not what.
++ const MOD = /^fa-(solid|regular|light|thin|duotone|sharp|sharp-duotone|classic|brands|fw|lg|xl|xs|sm|2xs|2xl|[0-9]+x|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|border|inverse|li|ul|stack|stack-1x|stack-2x|layers|layers-text|layers-counter|pull-left|pull-right|rotate-[0-9]+|rotate-by|flip-horizontal|flip-vertical|flip-both|swap-opacity|width-auto|width-fixed)$/;
++ const BUSY = '<circle cx="6" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="18" cy="12" r="1.8"/>';
++ const CSS = ".ovg::before,.ovg::after{content:none!important;display:none!important}.ovg{display:inline-block;width:1.15em;height:1.15em;line-height:1;vertical-align:-.18em;font-family:inherit!important;font-style:normal!important}.ovg>svg{display:block;width:100%;height:100%;fill:none;stroke:currentColor;stroke-width:calc(var(--ovg-stroke,2) * 1px + max(0px, (18.4px - 1.15em) * .25));stroke-linecap:round;stroke-linejoin:round;overflow:visible}html[data-ov-icons=solid]{--ovg-pref:99999px}html[data-ov-icons=line]{--ovg-pref:0px}.ovg>svg.has-s{stroke-width:min(var(--ovg-stroke,2) * 1px + max(0px, (18.4px - 1.15em) * .25), max(0px, (1.15em - var(--ovg-pref, var(--ovg-solid-below, 18.4px))) * 999))}.ovg>svg>.ovg-s{fill:currentColor;stroke:none;clip-path:inset(max(0px, (1.15em - var(--ovg-pref, var(--ovg-solid-below, 18.4px))) * 999))}.ovg>svg>.ovg-s .ln{fill:none;stroke:currentColor;stroke-width:2.6px}.ovg>svg>.ovg-s,.ovg>svg>.ovg-s *{stroke-dasharray:none!important;animation:none!important}.ovg.ovg-solid>svg.has-s{stroke-width:0}.ovg.ovg-solid>svg>.ovg-s{clip-path:none}.ovg.ovg-line>svg.has-s{stroke-width:calc(var(--ovg-stroke,2) * 1px + max(0px, (18.4px - 1.15em) * .25))}.ovg.ovg-line>svg>.ovg-s{clip-path:inset(50%)}.ovg.ovg-thin{--ovg-stroke:1}.ovg.ovg-light{--ovg-stroke:1.5}.ovg.ovg-regular{--ovg-stroke:2}.ovg.ovg-bold{--ovg-stroke:2.5}.ovg.ovg-duotone>svg>.ovg-s{clip-path:none;opacity:.2;fill:var(--ovg-duotone-color,currentColor)}.ovg.ovg-duotone>svg.has-s{stroke-width:var(--ovg-stroke,2)}:where([aria-current=page],[aria-current=true],[aria-pressed=true],[aria-selected=true],.is-here)>.ovg:not(.ovg-line)>svg.has-s{stroke-width:0}:where([aria-current=page],[aria-current=true],[aria-pressed=true],[aria-selected=true],.is-here)>.ovg:not(.ovg-line)>svg>.ovg-s{clip-path:none}.ovg.ovg-brand>svg{fill:currentColor;stroke:none}.ovg.ovg-brand.ovg-brand-color{color:var(--ovg-brand)}.ovg.fa-thin{--ovg-stroke:1}.ovg.fa-light{--ovg-stroke:1.5}.ovg.fa-solid{--ovg-solid-below:99999px}.ovg.fa-duotone{--ovg-duotone-color:var(--fa-secondary-color,currentColor)}.ovg.fa-duotone>svg{stroke:var(--fa-primary-color,currentColor)}.ovg.fa-2xs{font-size:.625em}.ovg.fa-xs{font-size:.75em}.ovg.fa-sm{font-size:.875em}.ovg.fa-lg{font-size:1.25em}.ovg.fa-xl{font-size:1.5em}.ovg.fa-2xl{font-size:2em}.ovg.fa-1x{font-size:1em}.ovg.fa-2x{font-size:2em}.ovg.fa-3x{font-size:3em}.ovg.fa-4x{font-size:4em}.ovg.fa-5x{font-size:5em}.ovg.fa-6x{font-size:6em}.ovg.fa-7x{font-size:7em}.ovg.fa-8x{font-size:8em}.ovg.fa-9x{font-size:9em}.ovg.fa-10x{font-size:10em}.ovg.fa-fw{width:1.25em;text-align:center}.ovg.fa-border{box-sizing:content-box;border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);border-radius:var(--fa-border-radius,.1em);padding:var(--fa-border-padding,.2em .25em .15em)}.ovg.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.ovg.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.ovg.fa-rotate-90>svg{transform:rotate(90deg)}.ovg.fa-rotate-180>svg{transform:rotate(180deg)}.ovg.fa-rotate-270>svg{transform:rotate(270deg)}.ovg.fa-rotate-by>svg{transform:rotate(var(--fa-rotate-angle,0))}.ovg.fa-flip-horizontal>svg{transform:scale(-1,1)}.ovg.fa-flip-vertical>svg{transform:scale(1,-1)}.ovg.fa-flip-both>svg{transform:scale(-1,-1)}.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{position:absolute;left:calc(var(--fa-li-width,2em)*-1);width:var(--fa-li-width,2em);text-align:center;line-height:inherit}.fa-stack{display:inline-block;position:relative;width:2.5em;height:2em;line-height:2em;vertical-align:middle}.fa-stack>.ovg{position:absolute;inset:0;margin:auto;width:1em;height:1em}.fa-stack>.ovg.fa-stack-2x{font-size:2em}.ovg.fa-inverse{color:var(--fa-inverse,#fff)}.fa-layers{display:inline-block;position:relative;width:1em;height:1em;text-align:center;vertical-align:-.125em}.fa-layers>.ovg{position:absolute;inset:0;margin:auto;width:1em;height:1em}.fa-layers-text{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:inline-block}.fa-layers-counter{position:absolute;top:var(--fa-top,0);right:var(--fa-right,0);display:inline-block;box-sizing:border-box;min-width:var(--fa-counter-min-width,1.5em);max-width:var(--fa-counter-max-width,5em);padding:var(--fa-counter-padding,.25em .5em);border-radius:var(--fa-counter-border-radius,1em);background:var(--fa-counter-background-color,#ff253a);color:var(--fa-inverse,#fff);line-height:var(--fa-counter-line-height,1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transform:scale(var(--fa-counter-scale,.25));transform-origin:top right}.ovg.fa-beat{animation:ovgBeat var(--fa-animation-duration,1s) var(--fa-animation-timing,ease-in-out) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-bounce{animation:ovgBounce var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-fade{animation:ovgFade var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.4,0,.6,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-beat-fade{animation:ovgBeatFade var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.4,0,.6,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-flip{animation:ovgFlip var(--fa-animation-duration,1s) var(--fa-animation-timing,ease-in-out) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-shake{animation:ovgShake var(--fa-animation-duration,1s) var(--fa-animation-timing,linear) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-spin,.ovg.fa-pulse,.ovg.fa-spin-pulse{animation:ovgBreathe var(--fa-animation-duration,1.6s) ease-in-out var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite)!important}.ovg[data-ovg=busy].fa-spin,.ovg[data-ovg=busy].fa-pulse,.ovg[data-ovg=busy].fa-spin-pulse{animation:none!important}@keyframes ovgBeat{0%,90%{transform:scale(1)}45%{transform:scale(var(--fa-beat-scale,1.25))}}@keyframes ovgBounce{0%{transform:scale(1,1) translateY(0)}10%{transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{transform:scale(1,1) translateY(var(--fa-bounce-rebound,-.125em))}64%,100%{transform:scale(1,1) translateY(0)}}@keyframes ovgFade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes ovgBeatFade{0%,100%{opacity:var(--fa-beat-fade-opacity,.4);transform:scale(1)}50%{opacity:1;transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes ovgFlip{50%{transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes ovgShake{0%{transform:rotate(-15deg)}4%{transform:rotate(15deg)}8%,24%{transform:rotate(-18deg)}12%,28%{transform:rotate(18deg)}16%{transform:rotate(-22deg)}20%{transform:rotate(22deg)}32%{transform:rotate(-12deg)}36%{transform:rotate(12deg)}40%,100%{transform:rotate(0)}}@keyframes ovgBreathe{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.55;transform:scale(.9)}}.ovg.ovg-draw>svg>*{stroke-dasharray:1;stroke-dashoffset:1;animation:ovgDraw var(--ovg-draw-duration,.6s) cubic-bezier(.4,0,.2,1) var(--ovg-draw-delay,0s) forwards}.ovg.ovg-draw>svg>*:nth-child(2){animation-delay:calc(var(--ovg-draw-delay,0s) + .12s)}.ovg.ovg-draw>svg>*:nth-child(n+3){animation-delay:calc(var(--ovg-draw-delay,0s) + .24s)}@keyframes ovgDraw{to{stroke-dashoffset:0}}.ovg.ovg-once{--fa-animation-iteration-count:1}.ovg[data-ovg=busy]>svg{fill:currentColor;stroke:none}.ovg[data-ovg=busy] circle{animation:ovgHop 1s ease-in-out infinite;transform-box:fill-box;transform-origin:center}.ovg[data-ovg=busy] circle:nth-child(2){animation-delay:.15s}.ovg[data-ovg=busy] circle:nth-child(3){animation-delay:.3s}@keyframes ovgHop{0%,60%,100%{transform:translateY(0);opacity:.55}30%{transform:translateY(-3px);opacity:1}}html[data-ov-motion=off] .ovg,html[data-ov-motion=off] .ovg[data-ovg=busy] circle,html[data-ov-motion=off] .ovg.ovg-draw>svg>*{animation:none!important;stroke-dashoffset:0!important}";
++ const nameOf = (el) => { for (const c of el.classList) if (c.startsWith('fa-') && !MOD.test(c)) return c.slice(3); return null; };
++ const fromFa = (fa) => { const n = String(fa || '').replace(/^fa-/, ''); return Object.prototype.hasOwnProperty.call(FA, n) ? FA[n] : null; };
++ // data-fa-transform, in Font Awesome's units (a sixteenth of the icon).
++ const TF = /^(grow|shrink|up|down|left|right|rotate)-(-?[0-9.]+)$|^flip-(h|v)$/;
++ function transformOf(spec) {
++ let s = 1, x = 0, y = 0, r = 0, fx = 1, fy = 1, any = false;
++ for (const p of String(spec || '').toLowerCase().split(/\s+/)) {
++ const m = TF.exec(p);
++ if (!m) continue;
++ any = true;
++ if (m[3]) { if (m[3] === 'h') fx = -1; else fy = -1; continue; }
++ const n = Number(m[2]);
++ if (m[1] === 'grow') s += n / 16; else if (m[1] === 'shrink') s -= n / 16;
++ else if (m[1] === 'up') y -= n / 16; else if (m[1] === 'down') y += n / 16;
++ else if (m[1] === 'left') x -= n / 16; else if (m[1] === 'right') x += n / 16; else r += n;
++ }
++ return any ? 'translate(' + x + 'em,' + y + 'em) rotate(' + r + 'deg) scale(' + (fx * s) + ',' + (fy * s) + ')' : '';
++ }
++ // Brand logos (4.72.0): brands.js from beside this script, once, then every waiting element is drawn.
++ const SRC = (document.currentScript && document.currentScript.src) || '';
++ let brandState = 0; // 0 idle, 1 loading, 2 ready, 3 unavailable
++ const waiting = new Set();
++ const BRANDS_REV = '0da501c80e60';
++ // A logo far below the fold (a footer's social links) waits until it comes near the screen (4.138.0): most visits
++ // never get there, and brands.js is 26 KB. One that is in view, or hidden in a closed fold that opens, loads then.
++ let brandSeen = null;
++ function wantBrands(el) {
++ if (brandState || typeof IntersectionObserver !== 'function' || !el.getBoundingClientRect) { loadBrands(); return; }
++ const r = el.getBoundingClientRect();
++ if ((r.width || r.height) && r.bottom > -800 && r.top < (window.innerHeight || 0) + 800) { loadBrands(); return; }
++ brandSeen = brandSeen || new IntersectionObserver((es) => { if (es.some((e) => e.isIntersecting)) { brandSeen.disconnect(); brandSeen = null; loadBrands(); } }, { rootMargin: '800px 0px' });
++ brandSeen.observe(el);
++ }
++ function loadBrands() {
++ if (brandState) return;
++ if (window.OVBrands) { brandState = 2; return; }
++ brandState = 1;
++ // No backslashes here: this text is inside a template literal.
++ const base = SRC ? SRC.split(/[?#]/)[0] : '';
++ const dir = base && /ov-glyphs[.]js$/.test(base) ? base.slice(0, base.lastIndexOf('/') + 1) : 'https://openvibe.network/shared/';
++ const sc = document.createElement('script');
++ sc.src = dir + 'brands.js' + (BRANDS_REV ? '?v=' + BRANDS_REV : '');
++ sc.async = true;
++ sc.onload = () => { brandState = window.OVBrands ? 2 : 3; waiting.forEach((el) => drawBrand(el)); waiting.clear(); };
++ sc.onerror = () => { brandState = 3; waiting.clear(); };
++ document.head.appendChild(sc);
++ }
++ function drawBrand(el) {
++ let fa = null;
++ for (const c of el.classList) if (c.startsWith('fa-') && c !== 'fa-brands' && !MOD.test(c) && Object.prototype.hasOwnProperty.call(FB, c.slice(3))) { fa = c.slice(3); break; }
++ if (!fa) return;
++ const slug = FB[fa];
++ if (el.dataset.ovg === 'brand:' + slug) return;
++ if (!window.OVBrands) { if (brandState !== 3) { waiting.add(el); wantBrands(el); } return; }
++ const b = window.OVBrands.get(slug);
++ if (!b || (!el.dataset.ovg && el.textContent.trim())) return;
++ el.classList.add('ovg', 'ovg-brand');
++ el.dataset.ovg = 'brand:' + slug; el.dataset.ovgFa = fa;
++ // A near-black brand (GitHub, X, Apple…) would vanish on a dark page: it wears the text's colour instead.
++ const n = parseInt(b.hex.slice(1), 16), lum = (0.2126 * (n >> 16) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255;
++ el.style.setProperty('--ovg-brand', lum < 0.12 ? 'currentColor' : b.hex);
++ const title = el.getAttribute('title');
++ if (title && !el.hasAttribute('aria-label')) { el.setAttribute('role', 'img'); el.setAttribute('aria-label', title); }
++ else if (!el.hasAttribute('aria-label') && !title) el.setAttribute('aria-hidden', 'true');
++ el.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="' + b.path + '"/></svg>';
++ }
++ function draw(el) {
++ if (el.classList.contains('fa-brands') || el.classList.contains('fab')) return drawBrand(el);
++ const fa = nameOf(el);
++ const g = fa && fromFa(fa);
++ if (!g) { if (el.dataset.ovg) { el.classList.remove('ovg'); delete el.dataset.ovg; delete el.dataset.ovgFa; delete el.dataset.ovgTf; el.textContent = ''; } return; }
++ const tf = el.getAttribute('data-fa-transform') || '';
++ if (el.dataset.ovgFa === fa && (el.dataset.ovgTf || '') === tf) { if (!el.classList.contains('ovg')) el.classList.add('ovg'); return; } // adding a class it has would observe itself forever
++ if (!el.dataset.ovg && el.textContent.trim()) return; // an icon element with text of its own is not an icon
++ el.classList.add('ovg');
++ el.dataset.ovg = g; el.dataset.ovgFa = fa;
++ if (tf) el.dataset.ovgTf = tf; else delete el.dataset.ovgTf;
++ const title = el.getAttribute('title');
++ if (title && !el.hasAttribute('aria-label')) { el.setAttribute('role', 'img'); el.setAttribute('aria-label', title); }
++ else if (!el.hasAttribute('aria-label') && !title) el.setAttribute('aria-hidden', 'true');
++ const t = transformOf(tf);
++ el.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"' + (S[g] ? ' class="has-s"' : '') + (t ? ' style="transform:' + t + '"' : '') + '>' + (g === 'busy' ? BUSY : G[g] + (S[g] ? '<g class="ovg-s">' + S[g] + '</g>' : '')) + '</svg>';
++ // ovg-draw draws each line in: every shape measures 1 along its length.
++ if (el.classList.contains('ovg-draw')) el.firstChild.childNodes.forEach((n) => { if (n.setAttribute) n.setAttribute('pathLength', '1'); });
++ }
++ const SEL = 'i[class*="fa-"],span[class*="fa-"]';
++ function swap(root) {
++ const r = root || document;
++ if (r.matches && r.matches(SEL)) draw(r);
++ if (r.querySelectorAll) r.querySelectorAll(SEL).forEach(draw);
++ }
++ function start() {
++ if (!document.getElementById('ov-glyphs-css')) { const s = document.createElement('style'); s.id = 'ov-glyphs-css'; s.textContent = CSS; document.head.appendChild(s); }
++ swap(document);
++ try {
++ new MutationObserver((records) => {
++ for (const r of records) {
++ if (r.type === 'attributes') { if (r.target.matches && r.target.matches(SEL)) draw(r.target); continue; }
++ r.addedNodes.forEach((n) => { if (n.nodeType === 1) swap(n); });
++ }
++ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'data-fa-transform'] });
++ } catch (e) { /* no observer: what was there is swapped */ }
++ }
++ window.OVGlyphs = { swap, fromFa };
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start();
++})();
ddiff --git a/browser/shared/ov-mark.js b/browser/shared/ov-mark.js
iindex f41dc56..aca4222 100644
---- a/browser/shared/ov-mark.js
++++ b/browser/shared/ov-mark.js
@@@ -26,7 +26,11 @@
* - a page on its way (navbar): the ring empties and draws itself round again as the next page loads, paced by how
* long this site's pages usually take in this browser (learned per site, boost moves and full loads apart); the
* V nods while it waits and hops when the page is here (window.OVMark.navStart/navEnd for single-page apps);
-- * - busy: the V bobs and the O breathes, in turn; ok: the V becomes a tick, green, and hops (the tick only ever
++ * - busy (working): the O breaks into eight dots and three bright ones go round it, dot to dot, while the V bobs
++ * (the owner, 2026-11-10: "make the O turn into a bunch of little circles… a spinner that has little dots around
++ * it… that actually looks like it's loading"); think (your agent is thinking or writing): three dots rise in turn
++ * inside the O and the V's two strokes glimmer one after the other, the one sign used wherever tokens are on
++ * their way; ok: the V becomes a tick, green, and hops (the tick only ever
* means "done"); error: the V becomes "!", red, and the mark shakes; no: the V becomes "×", grey, and the O
* shakes its head; notify: a dot pops up by the V and the O bounces; live: the O is the record light, red, and
* ripples go out from it; sleep: the O closes like an eye and the V sinks.
@@@ -36,19 +40,25 @@
* visit, from the first time a mark is on screen in this tab; later pages of a multi-page site open with the mark at
* rest), while it or its link is hovered or focused (and LINGER_MS after), and while it shows a state, a page load or
* progress. Otherwise its CSS animations are paused where they are. It never moves while the page is hidden, while it
-- * is off-screen (IntersectionObserver), with data-static, under prefers-reduced-motion or under the network's own
-- * reduced-motion setting (html[data-ov-motion=reduced], theme-loader.js); its colours and shapes still change.
++ * is off-screen (IntersectionObserver), with data-static, or with motion off (html[data-ov-motion=off],
++ * theme-loader.js); its colours and shapes still change. Calm (html[data-ov-motion=reduced]: the person's choice, or
++ * the device asking for less motion) keeps the intro, the hover and every state, drops the linger after a hover and
++ * lets fewer leaves fall a shorter way.
*
* The season (theme-loader.js sets <html data-ov-season-mark="halloween|winter"> for anyone who has not turned seasonal
* themes off, on any theme): the mark wears it, the O a pumpkin with its stem and leaf in October, caps of snow in
* December. Now and then (every 4 to 8 minutes) a mark on screen flickers for a moment, and on the first visit of the
-- * day a few leaves (snowflakes in December) drift down from the first mark seen. Never under either reduced-motion setting.
++ * day a few leaves (snowflakes in December) drift down from the first mark seen: seven in full, three under Calm, none
++ * with motion off.
*/
(function () {
'use strict';
if (window.__ovMark) return; window.__ovMark = true;
let n = 0;
const f = (v) => +v.toFixed(2);
++ // The ground around the V where it crosses the O: the wide gap (the owner's pick, 2026-11-10, the Arena's P07:
++ // "more ground around the V where it crosses"); it was 7.
++ const HALO = 14;
// The Pair in a 100-unit box, centred on (50, 50) (openvibe-shared/mark: the same numbers; test/mark.test.js).
function pair(r, sw, vsw, a = 0.86, t = 1.5, b = 2.16) {
const R = r + sw / 2, right = b * R + vsw / 2, cx = 50 - (right - R) / 2, cy = 50;
@@@ -66,7 +76,7 @@
err: [mx, A[1], mx, T[1] - vsw * 1.28, mx, T[1], mx, T[1] + 0.01],
};
for (const k in shapes) shapes[k] = shapes[k].map(f);
-- return { cx: f(cx), cy, r, sw, R: f(R), vsw, halo: vsw + 7, A, T, B, ring, box, wideBox, shapes };
++ return { cx: f(cx), cy, r, sw, R: f(R), vsw, halo: vsw + HALO, A, T, B, ring, box, wideBox, shapes };
}
const FORMS = { pair: pair(20, 13, 13), heavy: pair(18, 18, 17) };
const formOf = (v) => (v === 'heavy' || v === 'solid' ? 'heavy' : 'pair');
@@@ -131,7 +141,19 @@ html[data-ov-season-mark] .ov-mark[data-ovm-intro] .sl{animation:ovmLeaf .9s cub
.ov-mark[data-ovm-arrive] .og{animation:ovmPulse .5s ease-out 1}
/* The states. */
.ov-mark[data-state=busy] .vg{animation:ovmBob .9s ease-in-out infinite}
--.ov-mark[data-state=busy] .og{animation:ovmBreathe .9s ease-in-out .45s infinite}
++/* Working: the O as eight dots (a dash of no length with a round cap is a dot), three bright ones hopping round. */
++.ov-mark[data-state=busy] :is(.o,.ot){stroke-linecap:round;stroke-width:var(--ovm-dot-w)}
++.ov-mark[data-state=busy] .ot{opacity:.3;stroke-dasharray:0 12.5}
++.ov-mark[data-state=busy] .o{stroke-dasharray:0 12.5 0 12.5 0 75;animation:ovmDots .96s steps(8,end) infinite}
++.ov-mark[data-state=busy] .og{animation:ovmSettle .4s cubic-bezier(.2,1.3,.3,1) 1}
++/* Thinking: three dots inside the O rise in turn, the ring quiets, the V's strokes glimmer one after the other. */
++.ov-mark .td{display:none;fill:var(--ovm-o);transform-box:fill-box;transform-origin:center}
++.ov-mark[data-state=think] .td{display:inline;animation:ovmThink 1.2s ease-in-out infinite}
++.ov-mark[data-state=think] .td2{animation-delay:.16s}
++.ov-mark[data-state=think] .td3{animation-delay:.32s}
++.ov-mark[data-state=think] .o{stroke-opacity:.5;transition:stroke-opacity .3s}
++.ov-mark[data-state=think] .va{animation:ovmSpeak 1.2s ease-in-out infinite}
++.ov-mark[data-state=think] .vb{animation:ovmSpeak 1.2s ease-in-out .6s infinite}
.ov-mark[data-state=ok]{--ovm-o:var(--success,#22c55e);--ovm-v:var(--success,#22c55e)}
.ov-mark[data-state=ok] .vg{animation:ovmHop .56s ease-out .14s 1 both}
.ov-mark[data-state=error]{--ovm-o:var(--danger,#ef4444);--ovm-v:var(--danger,#ef4444)}
@@@ -168,6 +190,10 @@ html[data-ov-season-mark] .ov-mark[data-ovm-intro] .sl{animation:ovmLeaf .9s cub
@keyframes ovmHop{0%,100%{transform:translateY(0) scaleY(1)}35%{transform:translateY(-9px) scaleY(1.04)}70%{transform:translateY(0) scaleY(.96)}}
@keyframes ovmBob{0%,100%{transform:translateY(0)}50%{transform:translateY(6px)}}
@keyframes ovmBreathe{0%,100%{transform:scale(1)}50%{transform:scale(.88)}}
++@keyframes ovmDots{to{stroke-dashoffset:-100}}
++@keyframes ovmSettle{from{transform:scale(.86)}}
++@keyframes ovmThink{0%,55%,100%{transform:translateY(0);opacity:.4}28%{transform:translateY(-2.6px);opacity:1}}
++@keyframes ovmSpeak{0%,100%{opacity:1}50%{opacity:.5}}
@keyframes ovmPulse{0%,100%{transform:scale(1)}50%{transform:scale(1.06)}}
@keyframes ovmShake{0%,100%{transform:translateX(0)}15%{transform:translateX(-7px)}30%{transform:translateX(7px)}45%{transform:translateX(-6px)}60%{transform:translateX(6px)}80%{transform:translateX(-3px)}}
@keyframes ovmNo{0%,100%{transform:translateX(0)}20%{transform:translateX(-4px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)}}
@@@ -186,8 +212,7 @@ html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:
@keyframes ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}}
@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}
@keyframes ovmFall{0%{opacity:0;transform:none}12%{opacity:1}72%{opacity:1;transform:translate(var(--x),var(--y)) rotate(var(--r))}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}
--@media (prefers-reduced-motion:reduce){.ov-mark *{animation:none!important}.ov-mark,.ov-mark *,.ov-mark::before{transition:none!important}.ovm-fall{display:none}}
--html[data-ov-motion=reduced] .ov-mark *{animation:none!important}
++html[data-ov-motion=off] .ov-mark:not([data-fx-demo] *) *{animation-iteration-count:1!important}html[data-ov-motion=off] .ovm-fall{display:none}
.ov-busy{cursor:progress;pointer-events:none}
.ov-busy-mark{display:inline-block;vertical-align:-.2em;margin-right:.45em;flex:none}
`;
@@@ -206,7 +231,7 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}
<linearGradient id="${id}gg"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".8"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>
<mask id="${id}s" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><g mask="url(#${id}h)"><g class="og" style="${oc}"><path d="${G.ring}" fill="none" stroke="#fff" stroke-width="${G.sw}"/></g></g><g class="vg" style="${vc}"><path class="ma" d="${seg(p, 0)}" fill="none" stroke="#fff" stroke-linecap="round" stroke-width="${G.vsw}"/><path class="mb" d="${seg(p, 4)}" fill="none" stroke="#fff" stroke-linecap="round" stroke-width="${G.vsw}"/></g></mask></defs>
<g class="m"><circle class="rp" cx="${G.cx}" cy="${G.cy}" r="${G.R}"/><circle class="rp rp2" cx="${G.cx}" cy="${G.cy}" r="${G.R}"/>
--<g mask="url(#${id}h)"><g class="og" style="${oc}"><path class="ot" d="${G.ring}" stroke-width="${G.sw}"/><path class="o" d="${G.ring}" pathLength="100" stroke-width="${G.sw}" style="stroke:url(#${id}go)"/><circle class="cd" cx="${G.cx}" cy="${G.cy}" r="${f(ri * 0.62)}"/>
++<g mask="url(#${id}h)"><g class="og" style="${oc};--ovm-dot-w:${f(G.sw * 0.62)}"><path class="ot" d="${G.ring}" pathLength="100" stroke-width="${G.sw}"/><path class="o" d="${G.ring}" pathLength="100" stroke-width="${G.sw}" style="stroke:url(#${id}go)"/><circle class="cd" cx="${G.cx}" cy="${G.cy}" r="${f(ri * 0.62)}"/>${[-1, 0, 1].map((k, n) => `<circle class="td${n ? ` td${n + 1}` : ''}" cx="${f(G.cx + k * ri * 0.52)}" cy="${G.cy}" r="${f(ri * 0.21)}"/>`).join('')}
<g class="sw2"><path class="sc" d="M${c0[0]} ${c0[1]}A${capR} ${capR} 0 0 1 ${c1[0]} ${c1[1]}"/></g></g></g>
<g class="vg" style="${vc}"><path class="va" d="${seg(p, 0)}" stroke-width="${G.vsw}" style="stroke:url(#${id}gv)"/><path class="vb" d="${seg(p, 4)}" stroke-width="${G.vsw}" style="stroke:url(#${id}gv)"/>
<g class="sw2"><circle class="sd" cx="${G.A[0]}" cy="${f(G.A[1] - G.vsw * 0.18)}" r="${f(G.vsw * 0.36)}"/><circle class="sd" cx="${G.B[0]}" cy="${f(G.B[1] - G.vsw * 0.18)}" r="${f(G.vsw * 0.36)}"/></g></g>
@@@ -251,21 +276,29 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}
if (!end || end > now + INTRO_MS) { end = now + INTRO_MS; try { sessionStorage.setItem(INTRO_KEY, String(end)); } catch { /* */ } }
return end;
}
-- function reduced() {
-- return !!(rm && rm.matches) || document.documentElement.getAttribute('data-ov-motion') === 'reduced';
++ // Inside [data-fx-demo] (ov-fx.js: a motion page's own container, set when the person asks) a mark plays in full.
++ // 'full' | 'calm' | 'off': <html data-ov-motion> is the signal (theme-loader.js, the device's setting counted in);
++ // a page without the loader asks the device.
++ function motion(el) {
++ if (el && el.closest && el.closest('[data-fx-demo]')) return 'full';
++ const m = document.documentElement.getAttribute('data-ov-motion');
++ if (m === 'off') return 'off';
++ return m === 'reduced' || (!m && rm && rm.matches) ? 'calm' : 'full';
}
++ const off = (el) => motion(el) === 'off';
function update(el) {
const st = state.get(el); if (!st) return;
const svgEl = el.querySelector('svg');
if (!el.isConnected || !svgEl) { forget(el); return; }
const now = Date.now();
-- const may = !reduced() && !el.hasAttribute('data-static') && !document.hidden && st.onScreen === true;
-- if (may && !st.introEnd) { st.introEnd = introDeadline(now); if (season()) setTimeout(() => fallOnce(el), 900); }
++ const may = !el.hasAttribute('data-static') && !document.hidden && st.onScreen === true;
++ if (may && !off(el) && !st.introEnd) { st.introEnd = introDeadline(now); if (season()) setTimeout(() => fallOnce(el), 900); }
const s = el.getAttribute('data-state');
// Anything the mark is showing keeps it moving: a state, a page on its way, a known progress.
const showing = (s && s !== 'idle') || el.hasAttribute('data-nav') || el.hasAttribute('data-progress');
const intro = now < st.introEnd;
-- const run = may && (intro || st.hover || st.focus || now < st.lingerEnd || showing);
++ // Calm plays the intro and answers a hover or focus, and rests as soon as that ends (no linger).
++ const run = may && (showing || (!off(el) && (intro || st.hover || st.focus || (motion(el) === 'full' && now < st.lingerEnd))));
if (run !== el.hasAttribute('data-ovm-run')) { if (run) el.setAttribute('data-ovm-run', ''); else el.removeAttribute('data-ovm-run'); }
// The intro's pop and growth are on only inside the intro (paused with the rest while the mark cannot move).
if ((may && intro) !== el.hasAttribute('data-ovm-intro') && (may || !intro)) { if (may && intro) el.setAttribute('data-ovm-intro', ''); else el.removeAttribute('data-ovm-intro'); }
@@@ -292,7 +325,7 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}
clearTimeout(flourishTimer); flourishTimer = 0;
if (!season()) return;
flourishTimer = setTimeout(() => {
-- if (season() && !reduced() && !document.hidden) {
++ if (season() && !off() && !document.hidden) {
document.querySelectorAll('.ov-mark[data-ov]:not([data-static])').forEach((el) => {
const st = state.get(el); if (!st || st.onScreen === false) return;
el.setAttribute('data-ovm-flourish', ''); setTimeout(() => el.removeAttribute('data-ovm-flourish'), 1700);
@@@ -303,7 +336,8 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}
}
const FALL_KEY = 'ov-season-fall';
function fallOnce(el) {
-- const id = season(); if (!id || reduced() || document.hidden || !el.isConnected) return;
++ const id = season(); if (!id || off() || document.hidden || !el.isConnected) return;
++ const calm = motion() === 'calm';
const day = id + ':' + new Date().toDateString();
try { const ls = window.localStorage; if (ls.getItem(FALL_KEY) === day) return; ls.setItem(FALL_KEY, day); } catch { return; }
const r = el.getBoundingClientRect(); if (!r.width || !document.body) return;
@@@ -316,11 +350,11 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}
const rb = bar && bar.getBoundingClientRect();
const floor = rb && rb.bottom > y0 + 8 ? rb.bottom - y0 - 4 : 0;
const colors = id === 'winter' ? ['#f4fbff', '#d8f3ff', '#ffffff'] : ['#ff7a1a', '#f5a524', '#d9481f', '#7fd04e'];
-- for (let i = 0; i < 7; i++) {
++ for (let i = 0; i < (calm ? 3 : 7); i++) {
const s = document.createElement('i');
-- const dx = Math.max(8 - x0, Math.min((window.innerWidth || 0) - 8 - x0, Math.round((Math.random() - 0.3) * 200)));
++ const dx = Math.max(8 - x0, Math.min((window.innerWidth || 0) - 8 - x0, Math.round((Math.random() - 0.3) * (calm ? 70 : 200))));
const dy = floor ? Math.round(floor - Math.random() * 3) : Math.round(36 + Math.random() * 28);
-- s.style.cssText = `--x:${dx}px;--y:${dy}px;--r:${Math.round(Math.random() * 540 - 270)}deg;animation-delay:${(i * 0.18 + Math.random() * 0.2).toFixed(2)}s;animation-duration:${(2.4 + Math.random() * 1.2).toFixed(2)}s;background:${colors[i % colors.length]}`;
++ s.style.cssText = `--x:${dx}px;--y:${dy}px;--r:${Math.round((Math.random() * 540 - 270) * (calm ? 0.35 : 1))}deg;animation-delay:${(i * 0.18 + Math.random() * 0.2).toFixed(2)}s;animation-duration:${(2.4 + Math.random() * 1.2).toFixed(2)}s;background:${colors[i % colors.length]}`;
box.appendChild(s);
}
document.body.appendChild(box);
@@@ -380,7 +414,7 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}
new MutationObserver((records) => {
let added = false;
for (const r of records) {
-- if (r.type === 'attributes') { if (r.target === document.documentElement) { updateAll(); if (r.attributeName === 'data-ov-season-mark') scheduleFlourish(); } else if (state.has(r.target)) update(r.target); continue; }
++ if (r.type === 'attributes') { if (r.attributeName === 'data-fx-demo') { updateAll(); continue; } if (r.target === document.documentElement) { updateAll(); if (r.attributeName === 'data-ov-season-mark') scheduleFlourish(); } else if (state.has(r.target)) update(r.target); continue; }
if (r.addedNodes.length) added = true;
r.removedNodes.forEach((node) => {
if (node.nodeType !== 1) return;
@@@ -389,7 +423,7 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}
});
}
if (added) mount();
-- }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-nav', 'data-progress', 'data-ov-motion', 'data-ov-season-mark'] });
++ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-nav', 'data-progress', 'data-ov-motion', 'data-ov-season-mark', 'data-fx-demo'] });
} catch { /* */ }
}
// ── A page on its way: the navbar's mark draws its ring round as the next page loads ──
@@@ -457,9 +491,12 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}
window.addEventListener('pageshow', (e) => { if (e.persisted) { navStop(); clearNav(); } });
// This page came from a click on the last one: learn how long it took, and finish the ring here.
let t = 0; try { t = Number(sessionStorage.getItem(NAV_FLAG)) || 0; sessionStorage.removeItem(NAV_FLAG); } catch { /* */ }
++ // soft.js ends the ring itself at the arriving page's first paint (it starts at the press, not the click); the
++ // pace is still learned here, from the click's marker, before soft.js's ending takes over.
if (t && Date.now() - t < 20000) {
learn('page', Date.now() - t);
-- const finish = () => { const marks = navMarks(); if (!marks.length) return false; setFill(marks, 0.9); setTimeout(() => navEnd(null), 60); return true; };
++ if (window.__ovSoftNavEnded) return;
++ const finish = () => { if (window.__ovSoftNavEnded) return true; const marks = navMarks(); if (!marks.length) return false; setFill(marks, 0.9); setTimeout(() => navEnd(null), 60); return true; };
let tries = 0; const again = () => { if (!finish() && ++tries <= 20) setTimeout(again, 100); }; again();
}
}
ddiff --git a/browser/shared/ov-window.js b/browser/shared/ov-window.js
nnew file mode 100644
iindex 0000000..ef1dcef
---- /dev/null
++++ b/browser/shared/ov-window.js
@@@ -0,0 +1,319 @@
++/**
++ * ov-window.js (openvibe-shared 4.202.0): one window for every little interface. Asks 304 and 318, the owner: "a system
++ * that's used for all the windows [in] harmony … people can make their own or … custom … everything … feels connected".
++ * One frame (a head with the title and ×, a body, a foot) in three kinds, the same motion, the same keys:
++ * dialog: centred on a computer; a sheet from the bottom on a phone
++ * sheet: a sheet from the bottom on every screen
++ * panel: floats beside what opened it on a computer, draggable by its head (where you leave it is kept per id);
++ * a sheet on a phone
++ * It grows out of the button that opened it (the navbar's pop motion), closes with ×, Escape, a press outside (a dialog
++ * or a sheet; a panel stays until closed, and the page under it stays usable) or a swipe down on a phone, keeps the focus inside while open, and gives it back to that button. Windows stack: Escape closes the
++ * top one. The look follows the person's theme and their window style, <html data-ov-window="glass|solid|outline">
++ * (OVWindow.setStyle), on every window at once.
++ *
++ * const w = OVWindow.open({ id, title, body: node | text, foot: node, kind: 'dialog', size: 'md', from: button,
++ * label, onClose, bare, width }); bare: the content draws its own head (the title is still
++ * read); width: px, for content that wants its own
++ * w.el, w.body, w.foot, w.setTitle(text), w.close()
++ * A panel can be resizable (4.204.0, the owner: a robot's controls "can be popped out … and resized"): { resizable: true,
++ * minWidth, minHeight, onResize({ width, height }) } adds a grip at its corner that a mouse, a finger or a pen drags and
++ * the arrow keys move (Shift for bigger steps, Enter for the first size); the size is kept with its place per id.
++ * A site's content goes in as nodes (or text, set as text): the window never parses markup it is given.
++ */
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OVWindow) return;
++ const HERE = document.currentScript && document.currentScript.src;
++ const WINDOW_REV = { 'ov-float.js': '5c8251ac3738' };
++
++ const STYLES = ['glass', 'solid', 'outline'];
++ const CSS = `.ovw-scrim { position: fixed; inset: 0; z-index: var(--ovw-z, 10040); background: rgba(3, 7, 15, .5); animation: ovw-fade .2s ease-out both; }
++.ovw-scrim.is-clear { background: transparent; }
++.ovw { position: fixed; z-index: var(--ovw-z, 10040); box-sizing: border-box; display: flex; flex-direction: column; max-height: min(86vh, 760px); color: var(--text-primary, #e6edf7);
++ font: 500 15px/1.45 var(--font, Inter, system-ui, -apple-system, 'Segoe UI', sans-serif); border-radius: 20px; overflow: hidden;
++ background: var(--ovw-bg); border: 1px solid var(--ovw-line); box-shadow: var(--ovw-shadow); -webkit-backdrop-filter: var(--ovw-blur); backdrop-filter: var(--ovw-blur);
++ transform-origin: var(--ov-pop-x, 50%) var(--ov-pop-y, 0); animation: ovw-in .28s cubic-bezier(.2, 1.2, .3, 1) both; }
++:root { --ovw-bg: color-mix(in srgb, var(--bg-elevated, var(--bg-secondary, var(--bg-primary, #111826))) 92%, transparent); --ovw-line: var(--border, rgba(255, 255, 255, .12));
++ --ovw-shadow: 0 28px 70px rgba(0, 0, 0, .45), 0 2px 8px rgba(0, 0, 0, .2); --ovw-blur: blur(16px) saturate(1.3); }
++html[data-ov-window="solid"] { --ovw-bg: var(--bg-elevated, var(--bg-secondary, var(--bg-primary, #111826))); --ovw-blur: none; }
++html[data-ov-window="outline"] { --ovw-bg: var(--bg-primary, #0b1220); --ovw-blur: none; --ovw-line: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); --ovw-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 20%, transparent), 0 18px 40px rgba(0, 0, 0, .35); }
++.ovw.is-out { animation: ovw-out .16s ease-in both; }
++.ovw-h { flex: none; display: flex; align-items: center; gap: 10px; min-height: 56px; padding: 6px 8px 6px 18px; border-bottom: 1px solid color-mix(in srgb, var(--ovw-line) 70%, transparent); }
++.ovw-t { flex: 1; min-width: 0; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 16.5px; font-weight: 700; letter-spacing: -.01em; }
++.ovw-x { flex: none; display: grid; place-items: center; width: 40px; height: 40px; padding: 0; border: 0; border-radius: 12px; background: none; color: var(--text-secondary, #a8b3c4); cursor: pointer; }
++.ovw-x:hover, .ovw-x:focus-visible { background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); color: var(--text-primary, #e6edf7); }
++.ovw-x:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
++.ovw-b { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 16px 18px 18px; }
++.ovw-f { flex: none; display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding: 12px 18px calc(12px + env(safe-area-inset-bottom, 0px)); border-top: 1px solid color-mix(in srgb, var(--ovw-line) 70%, transparent); }
++.ovw-f:empty { display: none; }
++.ovw.is-dialog { left: 50%; top: 50%; width: min(var(--ovw-w), calc(100vw - 32px)); transform: translate(-50%, -50%); animation-name: ovw-in-c; }
++.ovw.is-dialog.is-out { animation-name: ovw-out-c; }
++.ovw.is-panel { width: min(var(--ovw-w), calc(100vw - 24px)); }
++.ovw.is-panel .ovw-h { cursor: grab; touch-action: none; }
++.ovw.is-panel.is-dragging { animation: none; transition: none; box-shadow: 0 34px 90px rgba(0, 0, 0, .55); }
++.ovw.is-panel.is-dragging .ovw-h { cursor: grabbing; }
++.ovw.is-sheet { left: 0; right: 0; bottom: 0; width: auto; max-height: 92vh; margin: 0 auto; max-width: 720px; border-radius: 22px 22px 0 0; border-bottom: 0; animation: ovw-up .32s cubic-bezier(.2, 1, .3, 1) both; }
++.ovw.is-sheet.is-out { animation: ovw-down .2s ease-in both; }
++.ovw.is-sheet .ovw-h { position: relative; padding-top: 16px; touch-action: none; }
++.ovw.is-sheet .ovw-h::before { content: ""; position: absolute; left: 50%; top: 6px; width: 38px; height: 4px; margin-left: -19px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 28%, transparent); }
++.ovw.is-sheet.is-dragging { animation: none; }
++.ovw.is-resizable { max-height: none; }
++.ovw-grip { position: absolute; right: 2px; bottom: 2px; z-index: 2; width: 26px; height: 26px; padding: 0; border: 0; border-radius: 8px; background: none; cursor: nwse-resize; touch-action: none; color: var(--text-secondary, #a8b3c4); }
++.ovw-grip::before { content: ""; position: absolute; right: 6px; bottom: 6px; width: 10px; height: 10px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; border-radius: 0 0 3px 0; opacity: .55; }
++.ovw-grip:hover::before, .ovw-grip:focus-visible::before, .ovw.is-resizing .ovw-grip::before { opacity: 1; color: var(--accent, #3b82f6); }
++.ovw-grip:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
++.ovw.is-resizing { animation: none; transition: none; user-select: none; }
++.ovw { --ovw-w: 520px; }
++.ovw.is-sm { --ovw-w: 380px; }
++.ovw.is-lg { --ovw-w: 760px; }
++.ovw.is-full { --ovw-w: 1100px; }
++@keyframes ovw-in { from { opacity: 0; transform: translateY(calc(-6px * var(--ov-move, 1))) scale(.94); } }
++@keyframes ovw-out { to { opacity: 0; transform: scale(.96); } }
++@keyframes ovw-in-c { from { opacity: 0; transform: translate(-50%, calc(-50% + 10px * var(--ov-move, 1))) scale(.95); } }
++@keyframes ovw-out-c { to { opacity: 0; transform: translate(-50%, -50%) scale(.97); } }
++@keyframes ovw-up { from { transform: translateY(100%); } }
++@keyframes ovw-down { to { transform: translateY(100%); } }
++@keyframes ovw-fade { from { opacity: 0; } }
++html[data-ov-motion=off] .ovw, html[data-ov-motion=off] .ovw-scrim { animation: none !important; }
++html.ovw-locked { overflow: hidden; }
++/* bare: the content carries its own head (a card's banner); the title is for screen readers, × floats on top, and
++ the top strip is still the handle (drag a panel, swipe a sheet). */
++.ovw.is-bare .ovw-h { position: absolute; left: 0; right: 0; top: 0; z-index: 3; min-height: 52px; padding: 6px; justify-content: flex-end; border: 0; background: none; }
++.ovw.is-bare .ovw-t { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
++.ovw.is-bare .ovw-x { width: 36px; height: 36px; border-radius: 50%; color: #fff; background: rgba(4, 8, 16, .42); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
++.ovw.is-bare .ovw-x:hover, .ovw.is-bare .ovw-x:focus-visible { background: rgba(4, 8, 16, .62); color: #fff; }
++.ovw.is-bare .ovw-b { padding: 0; }
++.ovw.is-bare.is-sheet .ovw-h { padding-top: 14px; }`;
++
++ const stack = [];
++ let uid = 0;
++ function styles() { if (document.getElementById('ovw-styles')) return; const st = document.createElement('style'); st.id = 'ovw-styles'; st.textContent = CSS; document.head.appendChild(st); }
++
++ // The panel's move, tuck and place are OVFloat's (ov-float.js, loaded from beside this file the first time a panel
++ // opens). A page that already loaded it uses that.
++ let floatLoading = null;
++ const floatWait = [];
++ function withFloat(cb) {
++ if (root.OVFloat) { cb(root.OVFloat); return; }
++ if (!HERE || floatLoading === false) return;
++ if (floatLoading) { floatWait.push(cb); return; }
++ floatWait.push(cb);
++ const s = document.createElement('script');
++ const rev = WINDOW_REV['ov-float.js'];
++ s.src = HERE.replace(/ov-window\.js(?:\?.*)?$/, 'ov-float.js') + (rev ? `?v=${rev}` : '');
++ s.onload = () => {
++ floatLoading = null;
++ for (const fn of floatWait.splice(0)) { try { fn(root.OVFloat); } catch (e) { console.error(e); } }
++ };
++ s.onerror = () => { floatLoading = false; floatWait.length = 0; };
++ floatLoading = s;
++ (document.head || document.documentElement).appendChild(s);
++ }
++ const phone = () => !!(root.matchMedia && root.matchMedia('(max-width: 640px)').matches);
++ const focusables = (el) => [...el.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')].filter((x) => x.offsetParent !== null || x === document.activeElement);
++ const place = { get(id) { try { return JSON.parse(root.localStorage.getItem(`ov:window:${id}`) || 'null'); } catch { return null; } }, set(id, v) { try { root.localStorage.setItem(`ov:window:${id}`, JSON.stringify(v)); } catch { /* private mode */ } } };
++ function put(parent, v) { if (v == null) return; if (typeof v === 'string') parent.textContent = v; else parent.appendChild(v); }
++
++ function open(o = {}) {
++ styles();
++ const kind0 = ['dialog', 'sheet', 'panel'].includes(o.kind) ? o.kind : 'dialog';
++ const kind = phone() ? 'sheet' : kind0;
++ const id = o.id || `ovw-${++uid}`;
++ const opener = o.from || document.activeElement;
++ const modal = o.modal !== false && kind !== 'panel';
++ const z = 10040 + stack.length * 2;
++ const scrim = document.createElement('div');
++ scrim.className = `ovw-scrim${modal ? '' : ' is-clear'}`;
++ scrim.style.setProperty('--ovw-z', String(z));
++ const el = document.createElement('section');
++ el.className = `ovw is-${kind} is-${['sm', 'md', 'lg', 'full'].includes(o.size) ? o.size : 'md'}${o.bare ? ' is-bare' : ''}`;
++ el.style.setProperty('--ovw-z', String(z + 1));
++ if (Number(o.width) > 0) el.style.setProperty('--ovw-w', `${Math.round(Number(o.width))}px`);
++ el.setAttribute('role', 'dialog');
++ if (modal) el.setAttribute('aria-modal', 'true');
++ el.dataset.ovWindow = id;
++ const head = document.createElement('header'); head.className = 'ovw-h';
++ const title = document.createElement('h2'); title.className = 'ovw-t'; title.id = `${id}-t`; title.textContent = o.title || '';
++ const x = document.createElement('button'); x.type = 'button'; x.className = 'ovw-x'; x.setAttribute('aria-label', 'Close');
++ x.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
++ head.append(title, x);
++ const body = document.createElement('div'); body.className = 'ovw-b'; put(body, o.body);
++ const foot = document.createElement('footer'); foot.className = 'ovw-f'; put(foot, o.foot);
++ el.append(head, body, foot);
++ if (o.title) el.setAttribute('aria-labelledby', title.id); else el.setAttribute('aria-label', o.label || 'Window');
++ if (modal) document.body.append(scrim, el); else document.body.append(el);
++ if (modal) document.documentElement.classList.add('ovw-locked');
++
++ // Where it opens: a panel beside its opener (or where it was left), a dialog centred, a sheet at the bottom.
++ let panelLeft = 0, panelTop = 0;
++ if (kind === 'panel') {
++ const saved = o.id ? place.get(o.id) : null;
++ const cs = document.documentElement;
++ const r = el.getBoundingClientRect(), vw = cs.clientWidth || root.innerWidth, vh = cs.clientHeight || root.innerHeight;
++ let left, top;
++ if (saved) { left = saved.x; top = saved.y; }
++ else if (opener && opener.getBoundingClientRect) { const f = opener.getBoundingClientRect(); left = f.left; top = f.bottom + 8; if (top + r.height > vh - 12) top = Math.max(12, f.top - r.height - 8); }
++ else { left = vw - r.width - 24; top = 72; }
++ left = Math.max(8, Math.min(left, vw - r.width - 8));
++ top = Math.max(8, Math.min(top, vh - 60));
++ panelLeft = left; panelTop = top;
++ el.style.left = `${left}px`;
++ el.style.top = `${top}px`;
++ }
++ if (opener && opener.getBoundingClientRect && kind !== 'sheet') {
++ const r = el.getBoundingClientRect(), f = opener.getBoundingClientRect();
++ el.style.setProperty('--ov-pop-x', `${Math.round(f.left + f.width / 2 - r.left)}px`);
++ el.style.setProperty('--ov-pop-y', `${Math.round(f.top + f.height / 2 - r.top)}px`);
++ }
++
++ let closed = false;
++ const w = {
++ id, el, body, foot,
++ setTitle(t) { title.textContent = t || ''; },
++ close(why) {
++ if (closed) return; closed = true;
++ if (w.float) { try { w.float.destroy(); } catch { /* */ } w.float = null; }
++ const i = stack.indexOf(w); if (i >= 0) stack.splice(i, 1);
++ if (!stack.some((s) => s.modal)) document.documentElement.classList.remove('ovw-locked');
++ const gone = () => { el.remove(); scrim.remove(); };
++ if (document.documentElement.getAttribute('data-ov-motion') === 'off') gone();
++ else { el.classList.add('is-out'); scrim.style.opacity = '0'; scrim.style.transition = 'opacity .16s'; let done = false; const end = () => { if (!done) { done = true; gone(); } }; el.addEventListener('animationend', end, { once: true }); setTimeout(end, 260); }
++ if (opener && opener.focus && document.contains(opener)) { try { opener.focus({ preventScroll: true }); } catch { /* */ } }
++ if (typeof o.onClose === 'function') { try { o.onClose(why || 'close'); } catch (e) { console.error(e); } }
++ },
++ modal,
++ float: null,
++ };
++ stack.push(w);
++ if (kind === 'panel') {
++ const panelId = o.id ? String(o.id) : '';
++ withFloat((OVFloat) => {
++ if (closed) return;
++ w.float = OVFloat.attach(el, {
++ frame: null, key: panelId ? `ov:window:float:${panelId}` : undefined,
++ defaults: {
++ x: Math.max(0, Math.min(1, panelLeft / Math.max(1, (document.documentElement.clientWidth || root.innerWidth)))),
++ y: Math.max(0, Math.min(1, panelTop / Math.max(1, (document.documentElement.clientHeight || root.innerHeight)))),
++ mode: 'float',
++ },
++ aspect: null, size: false, resize: false, doubleTap: false, activeBar: false,
++ viewport: 'client', handle: head, store: null, hintClass: 'ovw-dock-hint',
++ onChange(geom) {
++ if (!panelId) return;
++ // The fractions convert back to the same pixels without reading a rect mid-landing. (The old
++ // key stays as the absolute place older releases wrote, and for the size the grip keeps there.)
++ const vw = document.documentElement.clientWidth || root.innerWidth;
++ const vh = document.documentElement.clientHeight || root.innerHeight;
++ place.set(panelId, Object.assign(place.get(panelId) || {}, { x: Math.round(geom.x * vw), y: Math.round(geom.y * vh) }));
++ },
++ });
++ });
++ }
++ x.addEventListener('click', () => w.close('x'));
++ if (modal) scrim.addEventListener('pointerdown', () => w.close('outside'));
++ el.addEventListener('keydown', (e) => {
++ if (e.key === 'Tab') {
++ const f = focusables(el); if (!f.length) return;
++ const first = f[0], last = f[f.length - 1];
++ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
++ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
++ }
++ });
++
++ // A sheet goes down when swiped down on a phone; a panel's head is OVFloat's handle (with its tuck and place).
++ if (kind === 'sheet') {
++ head.addEventListener('pointerdown', (e) => {
++ if (e.target.closest('.ovw-x') || (e.button !== undefined && e.button !== 0)) return;
++ const r = el.getBoundingClientRect(), sx = e.clientX, sy = e.clientY;
++ let moved = false;
++ el.classList.add('is-dragging');
++ try { head.setPointerCapture(e.pointerId); } catch { /* */ }
++ const move = (ev) => {
++ const dx = ev.clientX - sx, dy = ev.clientY - sy;
++ if (!moved && Math.hypot(dx, dy) < 4) return;
++ moved = true;
++ if (kind === 'panel') {
++ el.style.left = `${Math.max(8, Math.min(r.left + dx, root.innerWidth - r.width - 8))}px`;
++ el.style.top = `${Math.max(8, Math.min(r.top + dy, root.innerHeight - 56))}px`;
++ } else el.style.transform = `translateY(${Math.max(0, dy)}px)`;
++ };
++ const up = (ev) => {
++ head.removeEventListener('pointermove', move); head.removeEventListener('pointerup', up); head.removeEventListener('pointercancel', up);
++ el.classList.remove('is-dragging');
++ if (kind === 'panel') { if (moved && o.id) { const b = el.getBoundingClientRect(); place.set(o.id, Object.assign(place.get(o.id) || {}, { x: Math.round(b.left), y: Math.round(b.top) })); } return; }
++ const dy = ev.clientY - sy;
++ if (moved && dy > Math.min(140, r.height * 0.28)) w.close('swipe');
++ else { el.style.transition = 'transform .2s cubic-bezier(.2, 1, .3, 1)'; el.style.transform = ''; setTimeout(() => { el.style.transition = ''; }, 220); }
++ };
++ head.addEventListener('pointermove', move); head.addEventListener('pointerup', up); head.addEventListener('pointercancel', up);
++ });
++ }
++
++ if (kind === 'panel' && o.resizable) resizable(el, o);
++
++ const first = o.focus ? body.querySelector(o.focus) : focusables(body)[0];
++ (first || x).focus({ preventScroll: true });
++ return w;
++ }
++
++ /** A panel's corner grip: pointer drag (mouse, finger, pen) and the arrow keys; the size is kept with its place. */
++ function resizable(el, o) {
++ el.classList.add('is-resizable');
++ const minW = Math.max(200, Number(o.minWidth) || 260), minH = Math.max(120, Number(o.minHeight) || 160);
++ const grip = document.createElement('button');
++ grip.type = 'button'; grip.className = 'ovw-grip';
++ grip.setAttribute('aria-label', 'Resize: drag, or use the arrow keys');
++ el.append(grip);
++ const first = { w: el.offsetWidth, h: el.offsetHeight };
++ const set = (w, h, save) => {
++ const r = el.getBoundingClientRect();
++ const W = Math.round(Math.max(minW, Math.min(w, root.innerWidth - r.left - 8)));
++ const H = Math.round(Math.max(minH, Math.min(h, root.innerHeight - r.top - 8)));
++ el.style.width = `${W}px`; el.style.height = `${H}px`;
++ if (save && o.id) place.set(o.id, Object.assign(place.get(o.id) || {}, { x: Math.round(r.left), y: Math.round(r.top), w: W, h: H }));
++ if (typeof o.onResize === 'function') { try { o.onResize({ width: W, height: H }); } catch { /* the page's own */ } }
++ };
++ const saved = o.id ? place.get(o.id) : null;
++ if (saved && saved.w && saved.h) set(saved.w, saved.h, false);
++ grip.addEventListener('pointerdown', (e) => {
++ if (e.button !== undefined && e.button !== 0) return;
++ e.preventDefault();
++ const sx = e.clientX, sy = e.clientY, w0 = el.offsetWidth, h0 = el.offsetHeight;
++ el.classList.add('is-resizing');
++ try { grip.setPointerCapture(e.pointerId); } catch { /* */ }
++ const move = (ev) => set(w0 + ev.clientX - sx, h0 + ev.clientY - sy, false);
++ const up = () => {
++ grip.removeEventListener('pointermove', move); grip.removeEventListener('pointerup', up); grip.removeEventListener('pointercancel', up);
++ el.classList.remove('is-resizing');
++ set(el.offsetWidth, el.offsetHeight, true);
++ };
++ grip.addEventListener('pointermove', move); grip.addEventListener('pointerup', up); grip.addEventListener('pointercancel', up);
++ });
++ grip.addEventListener('keydown', (e) => {
++ const step = e.shiftKey ? 80 : 24;
++ const d = { ArrowRight: [step, 0], ArrowLeft: [-step, 0], ArrowDown: [0, step], ArrowUp: [0, -step] }[e.key];
++ if (e.key === 'Enter' || e.key === 'Home') { e.preventDefault(); set(first.w, first.h, true); return; }
++ if (!d) return;
++ e.preventDefault();
++ set(el.offsetWidth + d[0], el.offsetHeight + d[1], true);
++ });
++ }
++
++ document.addEventListener('keydown', (e) => {
++ if (e.key !== 'Escape' || !stack.length || e.defaultPrevented) return;
++ e.preventDefault();
++ stack[stack.length - 1].close('escape');
++ });
++
++ // The style is the person's display setting (theme-loader: saved, synced to their account, painted at boot as
++ // <html data-ov-window>); setStyle saves through it, or straight onto the page where the theme loader is absent.
++ function setStyle(name) {
++ const v = STYLES.includes(name) ? name : 'glass';
++ const T = root.OpenVibeThemeLoader;
++ if (T && T.display && typeof T.display.set === 'function') { try { T.display.set({ window: v }); } catch { /* */ } }
++ if (v === 'glass') document.documentElement.removeAttribute('data-ov-window'); else document.documentElement.setAttribute('data-ov-window', v);
++ return v;
++ }
++
++ root.OVWindow = { open, setStyle, styles: STYLES.slice(), top: () => stack[stack.length - 1] || null, closeAll: () => [...stack].reverse().forEach((w) => w.close('all')) };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/browser/shared/theme-loader.js b/browser/shared/theme-loader.js
nnew file mode 100644
iindex 0000000..51696ab
---- /dev/null
++++ b/browser/shared/theme-loader.js
@@@ -0,0 +1,738 @@
++// OpenVibe — Theme Loader (browser): the person's look on <html> before first paint. Load it before navbar.js.
++
++(function (root) {
++ 'use strict';
++
++ const COOKIE_NAME = 'ov_theme_id';
++ const STORAGE_KEY = 'ov_theme';
++ // The account's look lives on openvibe.network for every site (init({ apiBase }) overrides it).
++ const DEFAULT_API_BASE = 'https://openvibe.network';
++ let _config = { apiBase: DEFAULT_API_BASE };
++
++ // ── Condensed built-in theme variable maps ───────────────
++ // Kept in sync with packages/openvibe-shared/builtin-themes.js
++ // THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand
++ const THEME_KEYS = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-card','--bg-hover','--bg-input','--text-primary','--text-secondary','--text-muted','--accent','--accent-light','--accent-dark','--border','--border-light','--live-red','--success','--warning','--danger','--info','--shadow','--shadow-lg','--success-text','--warning-text','--danger-text','--info-text','--live-red-text','--accent-strong','--on-accent-strong','--on-accent','--accent-rgb','--accent-glow','--color-scheme'];
++ const THEME_ROWS = {
++ 'vibe': ['#0a0f1c','#101828','#172238','#131c2e','#1c2a44','#0d1424','#e6edf7','#96a7c2','#8192ac','#3b82f6','#60a5fa','#1d4ed8','#1f2d47','#2c3d5c','#ef4444','#22c55e','#f59e0b','#ef4444','#38bdf8','0 2px 12px rgba(0,0,0,0.45)','0 8px 32px rgba(0,0,0,0.6)','#22c55e','#f59e0b','#f26262','#38bdf8','#f26262','#3472d8','#ffffff','#ffffff','59,130,246','rgba(59,130,246,0.35)','dark'],
++ 'violet-hour': ['#0d0d0f','#16161a','#1e1e24','#1a1a20','#242430','#12121a','#e8e6e3','#9a9a9a','#8d8c8c','#8b5cf6','#a78bfa','#6d28d9','#2a2a32','#3a3a44','#e74c3c','#2ecc71','#f39c12','#e74c3c','#3498db','0 2px 12px rgba(0,0,0,0.4)','','#2ecc71','#f39c12','#e95e50','#3498db','#e95e50','#8558ec','','','139,92,246','rgba(139,92,246,0.35)'],
++ 'midnight': ['#0a0e17','#111827','#1a2235','#151d2e','#1e2a42','#0d1220','#e2e8f0','#94a3b8','#8693a5','#6366f1','#818cf8','#4f46e5','#1e293b','#334155','','','','','','','','','','','','','#6164ec','','','99,102,241','rgba(99,102,241,0.35)'],
++ 'forest': ['#0a120a','#121e12','#1a2a1a','#152015','#1e3420','#0d160d','#d4e8d4','#8aaa8a','#849e84','#4ade80','#86efac','#22c55e','#1a3a1a','#2a4a2a','','','','','','','','','','#f36d6d','','#f36d6d','#4ade80','#0b0d10','#0b0d10','74,222,128','rgba(74,222,128,0.35)'],
++ 'neon-tokyo': ['#0a0a0f','#12101a','#1a1625','#15111f','#221a32','#0d0b14','#f0e6ff','#b4a0d0','#8e81a2','#ff006e','#ff4d94','#c9005a','#2a1a3a','#3a2a4a','','','','','','','','','','#ef4848','','#ef4848','#e60063','','','255,0,110','rgba(255,0,110,0.35)'],
++ 'dracula': ['#282a36','#1e1f29','#343746','#2c2e3c','#3a3d50','#21222c','#f8f8f2','#bfbfb2','#9ea8c5','#bd93f9','#d4b8ff','#9b6ddb','#44475a','#565970','#ff5555','#50fa7b','#f1fa8c','#ff5555','#8be9fd','','','#50fa7b','#f1fa8c','#ff8585','#8be9fd','#ff8585','#bd93f9','#0b0d10','#0b0d10','189,147,249','rgba(189,147,249,0.35)'],
++ 'monokai': ['#272822','#1e1f1c','#3e3d32','#2d2e27','#49483e','#22231e','#f8f8f2','#cfcfc2','#b8b6ab','#a6e22e','#c4ff50','#7dac1c','#49483e','#5b5a50','','','#e6db74','#f92672','#66d9ef','','','#45ce78','#e6db74','#fc97bb','#66d9ef','#f79e9e','#a6e22e','#0b0d10','#0b0d10','166,226,46','rgba(166,226,46,0.35)'],
++ 'ocean-deep': ['#0a1215','#0f1a1f','#162228','#121c22','#1a2a32','#0c1418','#d0e8ef','#7aa8ba','#7494a1','#00bcd4','#4dd0e1','#0097a7','#1a3040','#2a4050','','','','','','','','','','#f15e5e','','#f15e5e','#00bcd4','#0b0d10','#0b0d10','0,188,212','rgba(0,188,212,0.35)'],
++ 'sunset': ['#120a0a','#1a1010','#241616','#1e1212','#2e1a1a','#140c0c','#ffe8e0','#c49080','#9d8179','#ff6b35','#ff8f5e','#d04a1a','#3a2020','#4a3030','','','','','','','','','','#f04b4b','','#f04b4b','#ff6b35','#0b0d10','#0b0d10','255,107,53','rgba(255,107,53,0.35)'],
++ 'arctic': ['#0b1620','#0f1e2b','#163040','#122534','#1a3a4c','#0c1a26','#eaf6ff','#9ec3d8','#81a5bb','#22d3ee','#a5f3fc','#0891b2','#1e3f52','#2a5268','','#34d399','','','#7dd3fc','0 2px 12px rgba(2,12,20,0.5)','0 8px 32px rgba(2,12,20,0.65)','#34d399','','#f47c7c','#7dd3fc','#f47c7c','#22d3ee','#0b0d10','#0b0d10','34,211,238','rgba(34,211,238,0.35)'],
++ 'ember': ['#120808','#1a0e0e','#241414','#1e1010','#2e1818','#140a0a','#f0dada','#b0808a','#9b8084','#ef4444','#f87171','#b91c1c','#3a1a20','#4a2a30','','','','','','','','','','#ef4848','','#ef4848','#d73d3d','','','239,68,68','rgba(239,68,68,0.35)'],
++ 'vapor': ['#0e0a14','#14101c','#1c1628','#181220','#241a34','#100c16','#f0e0ff','#c090e0','#967bbb','#e040fb','#ea80fc','#aa00d4','#2a1a40','#3a2a50','','#69f0ae','','','#00e5ff','','','#69f0ae','','#f04b4b','#00e5ff','#f04b4b','#bc36d3','','','224,64,251','rgba(224,64,251,0.35)'],
++ 'slate': ['#0f1118','#161820','#1e2028','#1a1c24','#24262e','#12141c','#e4e6ea','#a0a4b0','#8a8d97','#a78bfa','#c4b5fd','#7c3aed','#2a2c38','#3a3c48','','','','','','','','','','#f15a5a','','#f15a5a','#a78bfa','#0b0d10','#0b0d10','167,139,250','rgba(167,139,250,0.35)'],
++ 'matrix': ['#000000','#050a05','#0a140a','#071007','#0e1e0e','#030803','#00ff41','#00b030','#149836','#00ff41','#66ff8a','#00aa2a','#0a2a0a','#144014','','','','','','','','','','#ef4444','','#ef4444','#00ff41','#0b0d10','#0b0d10','0,255,65','rgba(0,255,65,0.35)'],
++ 'nord': ['#2e3440','#272c36','#3b4252','#333a48','#434c5e','#2a303c','#eceff4','#d8dee9','#b5bcca','#88c0d0','#8fbcbb','#5e81ac','#3b4252','#4c566a','','#a3be8c','#ebcb8b','#bf616a','#81a1c1','','','#acc598','#ebcb8b','#dfb0b5','#a9bfd5','#f8a9a9','#88c0d0','#0b0d10','#0b0d10','136,192,208','rgba(136,192,208,0.35)'],
++ 'gruvbox-dark': ['#1d2021','#282828','#3c3836','#32302f','#504945','#242424','#ebdbb2','#d5c4a1','#c2b8aa','#fabd2f','#fce566','#d79921','#3c3836','#504945','','#b8bb26','#fe8019','#fb4934','#83a598','','','#bec037','#fea65e','#fda49a','#a8c0b7','#f7a5a5','#fabd2f','#0b0d10','#0b0d10','250,189,47','rgba(250,189,47,0.35)'],
++ 'abyss': ['#000000','#080808','#111111','#0a0a0a','#181818','#050505','#cccccc','#888888','#828282','#ffffff','#ffffff','#aaaaaa','#1a1a1a','#252525','','','','','','','','','','#ef4444','','#ef4444','#ffffff','#0b0d10','#0b0d10','255,255,255','rgba(255,255,255,0.35)'],
++ 'copper': ['#110d09','#1a140e','#231c14','#1e1710','#2c2218','#13100b','#e8ddd0','#b49a80','#978976','#cd7f32','#daa060','#a06020','#2a2016','#3a3020','','','','','','','','','','#f15757','','#f15757','#a46628','','','205,127,50','rgba(205,127,50,0.35)'],
++ 'sakura-night': ['#0e0a14','#150f1c','#1c1526','#181020','#221830','#100c16','#f0e0f0','#c0a0c0','#997d99','#f472b6','#f9a8d4','#db2777','#2a1a30','#3a2a40','','','','','','','','','','#ef4848','','#ef4848','#f472b6','#0b0d10','#0b0d10','244,114,182','rgba(244,114,182,0.35)'],
++ 'hacker': ['#000000','#0a0800','#141000','#0e0c00','#1c1800','#060400','#ffb000','#cc8d00','#a97a0f','#ffb000','#ffd050','#cc8d00','#1a1400','#2a2200','','','','','','','','','','#ef4444','','#ef4444','#ffb000','#0b0d10','#0b0d10','255,176,0','rgba(255,176,0,0.35)'],
++ 'daylight': ['#ffffff','#f7f7f8','#eeeef0','#ffffff','#f0f0f2','#f5f5f7','#1a1a2e','#4a4a6a','#68687b','#7a4ddb','#725faa','#6d28d9','#e0e0e6','#d0d0d8','','','','','','0 2px 12px rgba(0,0,0,0.08)','0 8px 32px rgba(0,0,0,0.12)','#157a3a','#935f07','#c43838','#227195','#c43838','#7a4ddb','','','122,77,219','rgba(122,77,219,0.35)','light'],
++ 'paper': ['#faf8f5','#f0ece6','#e8e2d8','#faf8f5','#f0ece6','#f5f2ed','#2c2416','#5a4e3e','#6c6355','#7d5f12','#7b6122','#6a5010','#ddd6c8','#ccc4b0','','','','','','0 2px 12px rgba(60,40,0,0.06)','0 8px 32px rgba(60,40,0,0.10)','#147237','#895806','#b63434','#1f6a8b','#b63434','#7d5f12','','','125,95,18','rgba(125,95,18,0.35)','light'],
++ 'cloud': ['#f0f5ff','#e8eef8','#dde5f0','#f5f8ff','#e4ecf8','#edf2fa','#1a2040','#4a5070','#5e637b','#245dd5','#3c669b','#2563eb','#d0d8ea','#bcc8e0','','','','','','0 2px 12px rgba(0,20,60,0.06)','0 8px 32px rgba(0,20,60,0.10)','#147237','#895806','#ba3535','#1f6a8b','#ba3535','#245dd5','','','36,93,213','rgba(36,93,213,0.35)','light'],
++ 'meadow': ['#f2f8f0','#e8f0e4','#dce8d6','#f5faf2','#e4f0de','#edf5ea','#1a2e16','#3a5a32','#526b4a','#137538','#147638','#15803d','#c8dcc0','#b0ccaa','','','','','','0 2px 12px rgba(0,40,0,0.06)','0 8px 32px rgba(0,40,0,0.10)','#147638','#895806','#ba3535','#1f6a8b','#ba3535','#137538','','','19,117,56','rgba(19,117,56,0.35)','light'],
++ 'peach': ['#fff5f0','#fbeae2','#f5ddd2','#fff7f2','#fce8dc','#fdf0ea','#2e1a14','#6a4438','#7e5d51','#ad4107','#925523','#ea580c','#f0d0c0','#e4bfab','','','','','','0 2px 12px rgba(60,20,0,0.06)','0 8px 32px rgba(60,20,0,0.10)','#147237','#895806','#b63434','#1f6a8b','#b63434','#ad4107','','','173,65,7','rgba(173,65,7,0.35)','light'],
++ 'lavender': ['#f8f5ff','#f0eaf8','#e6ddf0','#faf7ff','#eee6f8','#f4f0fa','#1e1430','#4a3a60','#6a5d80','#7443d5','#68569b','#7c3aed','#ddd0ee','#ccc0dd','','','','','','0 2px 12px rgba(40,0,60,0.06)','0 8px 32px rgba(40,0,60,0.10)','#147237','#895806','#b63434','#1f6a8b','#b63434','#7443d5','','','116,67,213','rgba(116,67,213,0.35)','light'],
++ 'gruvbox-light': ['#fbf1c7','#f2e5bc','#ebdbb2','#fbf1c7','#f2e5bc','#f5ecc4','#3c3836','#504945','#685e54','#a73e06','#934a0e','#af3a03','#d5c4a1','#bdae93','','#98971a','#d79921','#cc241d','#458588','0 2px 12px rgba(40,30,0,0.08)','0 8px 32px rgba(40,30,0,0.12)','#646411','#7d5913','#bc211b','#36686a','#b13232','#a73e06','','','167,62,6','rgba(167,62,6,0.35)','light'],
++ 'snow': ['#ffffff','#f8fafc','#f1f5f9','#ffffff','#f1f5f9','#f8fafc','#0f172a','#334155','#657185','#0275b0','#23759a','#0284c7','#e2e8f0','#cbd5e1','','','','','','0 2px 12px rgba(0,0,0,0.05)','0 8px 32px rgba(0,0,0,0.08)','#167e3c','#986207','#c93939','#23759a','#c93939','#0275b0','','','2,117,176','rgba(2,117,176,0.35)','light'],
++ 'sand': ['#f8f4ef','#f0e8de','#e8ddd0','#faf6f0','#f0e8de','#f5f0e8','#2e2418','#5a4e3e','#6b6154','#7e5d06','#7a5c12','#8b6508','#ddd0be','#ccc0aa','','','','','','0 2px 12px rgba(60,40,0,0.06)','0 8px 32px rgba(60,40,0,0.10)','#136e35','#895806','#b13232','#1e6686','#b13232','#7e5d06','','','126,93,6','rgba(126,93,6,0.35)','light'],
++ 'rose': ['#fff5f7','#fce7ec','#f5d5de','#fff7f9','#fce0e8','#fdeef2','#2e1420','#6a3850','#81536c','#bf183d','#b02d44','#be123c','#f0c8d4','#e4b0c0','','','','','','0 2px 12px rgba(60,0,20,0.06)','0 8px 32px rgba(60,0,20,0.10)','#136e35','#845506','#b13232','#1e6686','#b13232','#bf183d','','','191,24,61','rgba(191,24,61,0.35)','light'],
++ 'solarized-dark': ['#002b36','#073642','#0a3f4e','#053542','#0d4a58','#01303d','#fdf6e3','#a6b2b2','#a8b2b0','#b58900','#d4a017','#8a6a00','#0a4050','#105060','','#859900','#cb4b16','#dc322f','#268bd2','','','#aab84d','#e4a186','#ee9997','#78b7e3','#f69696','#947000','','','181,137,0','rgba(181,137,0,0.35)'],
++ 'solarized-light': ['#fdf6e3','#eee8d5','#e4ddc6','#fdf6e3','#eee8d5','#f5eedb','#002b36','#51656c','#4e6467','#795d00','#7b5d0d','#8a6a00','#d6ceb5','#c8c0a5','','#859900','#cb4b16','#dc322f','#268bd2','0 2px 12px rgba(0,30,40,0.08)','0 8px 32px rgba(0,30,40,0.12)','#5a6800','#a63e12','#b92a27','#1b6497','#b13232','#795d00','','','121,93,0','rgba(121,93,0,0.35)','light'],
++ 'catppuccin-mocha': ['#1e1e2e','#181825','#313244','#1e1e2e','#313244','#1a1a2c','#cdd6f4','#bac2de','#989caf','#cba6f7','#dcc0ff','#b07de0','#313244','#45475a','#f38ba8','#a6e3a1','#fab387','#f38ba8','#89b4fa','','','#a6e3a1','#fab387','#f38ba8','#89b4fa','#f38ba8','#cba6f7','#0b0d10','#0b0d10','203,166,247','rgba(203,166,247,0.35)'],
++ 'catppuccin-latte': ['#eff1f5','#e6e9ef','#dce0e8','#eff1f5','#dce0e8','#e8ebf0','#4c4f69','#5c5f77','#5f6176','#7d34dc','#7a46c2','#7028d4','#ccd0da','#bcc0cc','#d20f39','#40a02b','#fe640b','#d20f39','#1e66f5','0 2px 12px rgba(0,0,0,0.06)','0 8px 32px rgba(0,0,0,0.10)','#2d701e','#a84207','#c50e36','#1a5ad8','#c50e36','#7d34dc','','','125,52,220','rgba(125,52,220,0.35)','light'],
++ 'high-contrast': ['#000000','#0a0a0a','#1a1a1a','#0f0f0f','#222222','#0a0a0a','#ffffff','#f0f0f0','#aaaaaa','#ffd700','#ffed4a','#ccac00','#444444','#666666','#ff0000','#00ff00','#ffff00','#ff0000','#00ffff','','','#00ff00','#ffff00','#ff3d3d','#00ffff','#ff3d3d','#ffd700','#0b0d10','#0b0d10','255,215,0','rgba(255,215,0,0.35)'],
++ 'halloween': ['','#0f1626','#151e33','#111a2b','#1a2540','#0c1322','#eef1f6','#a9b4c8','#8c99b0','#ff7a1a','#ffa14f','#d65d00','#1c2840','#2a3858','','#4ade80','#fbbf24','#f87171','#60a5fa','0 2px 12px rgba(0,4,14,0.5)','0 8px 32px rgba(0,4,14,0.65)','#4ade80','#fbbf24','#f87171','#60a5fa','#f15757','#ff7a1a','#0b0d10','#0b0d10','255,122,26','rgba(255,122,26,0.35)'],
++ 'frost': ['#08111b','#0d1926','#132334','#0f1c2b','#18293d','#0a1520','#eaf4fb','#a3b9cc','#7f97ad','#2dd4bf','#7ee8da','#0f9e8c','#1b2d41','#294057','','#4ade80','#fbbf24','#f87171','#93c5fd','0 2px 12px rgba(0,6,14,0.5)','0 8px 32px rgba(0,6,14,0.65)','#4ade80','#fbbf24','#f87171','#93c5fd','#f15e5e','#2dd4bf','#0b0d10','#0b0d10','45,212,191','rgba(45,212,191,0.35)'],
++ 'dawn': ['#f5f6fc','#eceefa','#e1e4f5','#fafbff','#e6e9f8','#f0f2fb','#161a33','#474d6b','#5e647f','#4f46e5','#5558cf','#3730a3','#d6daef','#c3c8e6','','','','','','0 2px 12px rgba(10,14,40,0.07)','0 8px 32px rgba(10,14,40,0.12)','#147638','#8e5c06','#ba3535','#1f6a8b','#ba3535','#4f46e5','','','79,70,229','rgba(79,70,229,0.35)','light'],
++ 'tokyo-day': ['#fff6fa','#ffedf4','#fde1ec','#fffafc','#fde7f0','#fff0f6','#24101a','#5c3a4a','#7a5566','#cd0058','#c22261','#9c0043','#f3d3e0','#ebbfd1','','','','','','0 2px 12px rgba(40,0,16,0.07)','0 8px 32px rgba(40,0,16,0.12)','#147638','#8e5c06','#bf3636','#206e90','#bf3636','#cd0058','','','205,0,88','rgba(205,0,88,0.35)','light'],
++ 'alucard': ['#fffbeb','#f6f0d8','#ece4c6','#fffdf3','#f2ead0','#fbf6e2','#1f1f1f','#4f4b38','#6c664b','#644ac9','#6b53bc','#4a33a3','#e3dab8','#d6cba3','','','','','','0 2px 12px rgba(40,30,0,0.07)','0 8px 32px rgba(40,30,0,0.12)','#147237','#895806','#ba3535','#1f6a8b','#ba3535','#644ac9','','','100,74,201','rgba(100,74,201,0.35)','light'],
++ 'monokai-light': ['#fafaf6','#f1f1ea','#e7e7de','#fdfdfa','#ecece3','#f6f6f0','#272822','#4e4f44','#67685c','#4b7100','#537112','#436600','#dcdcd0','#cdcdbf','','','','','','0 2px 12px rgba(20,20,10,0.07)','0 8px 32px rgba(20,20,10,0.12)','#147638','#8e5c06','#ba3535','#206e90','#ba3535','#4b7100','','','75,113,0','rgba(75,113,0,0.35)','light'],
++ 'shallows': ['#f0fafb','#e4f4f6','#d5ecef','#f7fcfd','#dcf0f2','#ebf7f8','#0c2226','#3a5458','#516b70','#007384','#00737f','#005662','#c9e3e7','#b4d6db','','','','','','0 2px 12px rgba(0,30,36,0.07)','0 8px 32px rgba(0,30,36,0.12)','#147638','#8e5c06','#ba3535','#206e90','#ba3535','#007384','','','0,115,132','rgba(0,115,132,0.35)','light'],
++ 'ash': ['#faf7f6','#f3eeec','#eae2df','#fdfbfa','#efe7e4','#f7f3f1','#231716','#54403d','#725b57','#c22727','#bc2f2b','#8e1b1b','#e2d6d2','#d4c4bf','','','','','','0 2px 12px rgba(40,10,8,0.07)','0 8px 32px rgba(40,10,8,0.12)','#147237','#895806','#ba3535','#1f6a8b','#ba3535','#c22727','','','194,39,39','rgba(194,39,39,0.35)','light'],
++ 'vapor-day': ['#fcf6ff','#f6ecfc','#eedff8','#fefaff','#f2e5fa','#f9f1fe','#22122c','#533f5f','#715c7e','#a21caf','#a921ba','#701a75','#e6d3f1','#dbc0ea','','','','','','0 2px 12px rgba(30,0,40,0.07)','0 8px 32px rgba(30,0,40,0.12)','#147237','#895806','#ba3535','#1f6a8b','#ba3535','#a21caf','','','162,28,175','rgba(162,28,175,0.35)','light'],
++ 'greenbar': ['#f4faf3','#e9f4e7','#dcecd9','#f9fdf8','#e2f0df','#eff7ed','#0d1f10','#3b5240','#556e5a','#13782b','#18772c','#0b5a1f','#cfe3cb','#bcd7b7','','','','','','0 2px 12px rgba(0,30,6,0.07)','0 8px 32px rgba(0,30,6,0.12)','#147638','#8e5c06','#ba3535','#206e90','#ba3535','#13782b','','','19,120,43','rgba(19,120,43,0.35)','light'],
++ 'nord-light': ['#eceff4','#e5e9f0','#d8dee9','#f2f4f8','#dfe4ec','#e8ecf2','#2e3440','#434c5e','#4c566a','#476283','#506478','#4c6a91','#d0d7e2','#c2cad8','','','','','','0 2px 12px rgba(20,26,40,0.07)','0 8px 32px rgba(20,26,40,0.12)','#136e35','#845506','#b13232','#1e6686','#b13232','#476283','','','71,98,131','rgba(71,98,131,0.35)','light'],
++ 'bright': ['#ffffff','#f6f6f6','#ececec','#ffffff','#f0f0f0','#fafafa','#0a0a0a','#3d3d3d','#5c5c5c','#111111','#333333','#000000','#e2e2e2','#d0d0d0','','','','','','0 2px 12px rgba(0,0,0,0.07)','0 8px 32px rgba(0,0,0,0.12)','#157a3a','#935f07','#bf3636','#227195','#bf3636','#111111','','','17,17,17','rgba(17,17,17,0.35)','light'],
++ 'high-contrast-light': ['#ffffff','#ffffff','#f2f2f2','#ffffff','#e8e8e8','#ffffff','#000000','#1a1a1a','#2b2b2b','#0000c8','#1a1aff','#00008b','#000000','#333333','','','','','','0 2px 12px rgba(0,0,0,0.07)','0 8px 32px rgba(0,0,0,0.12)','#147638','#8e5c06','#bf3636','#206e90','#bf3636','#0000c8','','','0,0,200','rgba(0,0,200,0.35)','light'],
++ 'hayride': ['#fbf6ee','#f6eee2','#efe3d1','#ffffff','#f3e9da','#fffaf2','#23180f','#5a4838','#6b5a4a','#b63d0b','#ad4109','#9a3412','#eadfcd','#e0cfb6','','','','','','0 2px 12px rgba(40,24,8,0.07)','0 8px 32px rgba(40,24,8,0.12)','#147638','#8e5c06','#ba3535','#1f6a8b','#ba3535','#b63d0b','','','182,61,11','rgba(182,61,11,0.35)','light'],
++ 'snowfall': ['#f2f8fb','#e8f2f7','#dbeaf1','#f8fbfd','#e1eef4','#edf5f9','#0e1d26','#3b5260','#526976','#0f746c','#0c7267','#115e59','#cfe2ea','#bcd5df','','','','','','0 2px 12px rgba(0,20,30,0.07)','0 8px 32px rgba(0,20,30,0.12)','#147638','#8e5c06','#ba3535','#206e90','#ba3535','#0f746c','','','15,116,108','rgba(15,116,108,0.35)','light'],
++ };
++ const THEMES = (function () {
++ const out = {}, base = THEME_ROWS.vibe;
++ for (const id in THEME_ROWS) {
++ const row = THEME_ROWS[id], vars = {};
++ for (let i = 0; i < THEME_KEYS.length; i++) { const v = row[i] || base[i]; if (v) vars[THEME_KEYS[i]] = v; }
++ out[id] = vars;
++ }
++ return out;
++ })();
++ const SEASONS = [
++ { id: 'halloween', name: 'Halloween', theme: 'halloween', light: 'hayride', from: '10-01', to: '10-31' },
++ { id: 'winter', name: 'Winter', theme: 'frost', light: 'snowfall', from: '12-01', to: '12-31' },
++ ];
++ const PAIRS = { 'vibe':'cloud','violet-hour':'daylight','midnight':'dawn','forest':'meadow','neon-tokyo':'tokyo-day','dracula':'alucard','monokai':'monokai-light','ocean-deep':'shallows','sunset':'peach','arctic':'snow','ember':'ash','vapor':'vapor-day','slate':'lavender','matrix':'greenbar','nord':'nord-light','gruvbox-dark':'gruvbox-light','abyss':'bright','copper':'sand','sakura-night':'rose','hacker':'paper','daylight':'violet-hour','paper':'hacker','cloud':'vibe','meadow':'forest','peach':'sunset','lavender':'slate','gruvbox-light':'gruvbox-dark','snow':'arctic','sand':'copper','rose':'sakura-night','solarized-dark':'solarized-light','solarized-light':'solarized-dark','catppuccin-mocha':'catppuccin-latte','catppuccin-latte':'catppuccin-mocha','high-contrast':'high-contrast-light','halloween':'hayride','frost':'snowfall','dawn':'midnight','tokyo-day':'neon-tokyo','alucard':'dracula','monokai-light':'monokai','shallows':'ocean-deep','ash':'ember','vapor-day':'vapor','greenbar':'matrix','nord-light':'nord','bright':'abyss','high-contrast-light':'high-contrast','hayride':'halloween','snowfall':'frost' };
++ const MODES = { 'vibe':'dark','violet-hour':'dark','midnight':'dark','forest':'dark','neon-tokyo':'dark','dracula':'dark','monokai':'dark','ocean-deep':'dark','sunset':'dark','arctic':'dark','ember':'dark','vapor':'dark','slate':'dark','matrix':'dark','nord':'dark','gruvbox-dark':'dark','abyss':'dark','copper':'dark','sakura-night':'dark','hacker':'dark','daylight':'light','paper':'light','cloud':'light','meadow':'light','peach':'light','lavender':'light','gruvbox-light':'light','snow':'light','sand':'light','rose':'light','solarized-dark':'dark','solarized-light':'light','catppuccin-mocha':'dark','catppuccin-latte':'light','high-contrast':'dark','halloween':'dark','frost':'dark','dawn':'light','tokyo-day':'light','alucard':'light','monokai-light':'light','shallows':'light','ash':'light','vapor-day':'light','greenbar':'light','nord-light':'light','bright':'light','high-contrast-light':'light','hayride':'light','snowfall':'light' };
++ // THEMES:END
++
++ // ── Seasons: in its dates a season's theme stands in for the default theme unless display `seasonal` is off (a picked
++ // theme stays). <html data-ov-season>, window 'ov:season'; the mark wears it on any theme (data-ov-season-mark).
++ const DEFAULT_ID = 'vibe';
++ function seasonNow(date) {
++ const d = date || new Date();
++ const md = String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
++ for (let i = 0; i < SEASONS.length; i++) {
++ const s = SEASONS[i];
++ if (s.from <= s.to ? (md >= s.from && md <= s.to) : (md >= s.from || md <= s.to)) return s;
++ }
++ return null;
++ }
++ let _season = null;
++ let _shown = { id: DEFAULT_ID, vars: null, custom: false };
++ let _trying = null; // { id, name, vars } while a theme is tried on this tab (?ov-try=, 4.41.0)
++ /** Paint the theme `themeId` (its variables `vars`), or the season's theme in its place. → the id painted. */
++ // ── Light, dark and Auto: every built-in has two halves (PAIRS); display `scheme` 'auto' paints the half the device
++ // asks for (a theme without one gives way to the default's half), 'manual' the picked theme as it is.
++ let _mq = null;
++ function deviceMode() {
++ try { _mq = _mq || window.matchMedia('(prefers-color-scheme: dark)'); return _mq.matches ? 'dark' : 'light'; } catch (e) { return 'dark'; }
++ }
++ function modeOf(id, vars) { return MODES[id] || ((vars && vars['--color-scheme']) === 'light' ? 'light' : 'dark'); }
++ function otherHalf(id, mode) { return PAIRS[id] && MODES[PAIRS[id]] === mode ? PAIRS[id] : (mode === 'light' ? PAIRS[DEFAULT_ID] : DEFAULT_ID); }
++ function halfFor(themeId, vars, custom) {
++ if (readDisplay().scheme !== 'auto') return { id: themeId, vars: vars, custom: custom };
++ const want = deviceMode();
++ if (modeOf(themeId, vars) === want) return { id: themeId, vars: vars, custom: custom };
++ const other = otherHalf(themeId, want);
++ return { id: other, vars: THEMES[other], custom: false };
++ }
++ // The default theme in either mode: Vibe, and its light half. A season stands in for both, in the same mode.
++ function isDefault(id) { return id === DEFAULT_ID || id === PAIRS[DEFAULT_ID]; }
++
++ function show(themeId, vars, custom) {
++ _shown = { id: themeId, vars: vars, custom: !!custom };
++ // A theme being tried (?ov-try=) wins every repaint; _shown keeps the real one for Stop.
++ if (_trying && _trying.vars) return paintTry();
++ const half = halfFor(themeId, vars, !!custom);
++ const s = isDefault(half.id) && !half.custom && readDisplay().seasonal !== 'off' ? seasonNow() : null;
++ const seasonTheme = s ? (modeOf(half.id) === 'light' && s.light ? s.light : s.theme) : null;
++ const seasonal = seasonTheme && THEMES[seasonTheme] ? s : null;
++ const painted = seasonal ? seasonTheme : half.id;
++ applyVars(seasonal ? THEMES[seasonTheme] : half.vars);
++ const changed = (_season && _season.id) !== (seasonal && seasonal.id);
++ _season = seasonal;
++ try {
++ const el = document.documentElement;
++ if (seasonal) el.setAttribute('data-ov-season', seasonal.id); else el.removeAttribute('data-ov-season');
++ const marked = readDisplay().seasonal !== 'off' ? seasonNow() : null;
++ if (marked) el.setAttribute('data-ov-season-mark', marked.id); else el.removeAttribute('data-ov-season-mark');
++ el.setAttribute('data-theme', painted);
++ } catch { /* no DOM */ }
++ if (changed) { try { window.dispatchEvent(new CustomEvent('ov:season', { detail: seasonal })); } catch { /* */ } }
++ return painted;
++ }
++
++ /** The Light / Dark / Auto switch's state: 'auto', else the mode of the theme on screen. */
++ function getScheme() { return readDisplay().scheme === 'auto' ? 'auto' : modeOf(_shown.id, _shown.vars); }
++ /** Light or Dark saves the picked theme's other half (Auto off); Auto follows the device. → the new state. */
++ function setScheme(mode) {
++ if (mode === 'auto') { setDisplay({ scheme: 'auto' }); return 'auto'; }
++ if (mode !== 'light' && mode !== 'dark') return getScheme();
++ const cur = _shown;
++ if (readDisplay().scheme === 'auto') setDisplay({ scheme: 'manual' }, { noRepaint: true });
++ if (modeOf(cur.id, cur.vars) !== mode) save(otherHalf(cur.id, mode), null, { keepSeasonal: true });
++ else show(cur.id, cur.vars || THEMES[cur.id] || THEMES[DEFAULT_ID], cur.custom);
++ return mode;
++ }
++
++ function getCookie(name) {
++ const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'));
++ return match ? decodeURIComponent(match[1]) : null;
++ }
++
++ function setCookie(name, value, days) {
++ const maxAge = days * 24 * 60 * 60;
++ const encoded = encodeURIComponent(value);
++ const host = (typeof location !== 'undefined' && location.hostname) || '';
++ // Domain=.openvibe.tools only on openvibe.tools and its subdomains (CONTRACTS.md); host-only everywhere else.
++ if (host && (host.endsWith('.openvibe.tools') || host === 'openvibe.tools')) {
++ document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};domain=.openvibe.tools;SameSite=Lax;Secure`;
++ } else {
++ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
++ document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};SameSite=Lax${secure}`;
++ }
++ }
++
++ /**
++ * Apply a theme's CSS variables to document.documentElement.
++ * If themeId is not found in the built-in map, does nothing.
++ */
++ function applyById(themeId) {
++ const vars = THEMES[themeId];
++ if (!vars) return false;
++ applyVars(vars);
++ return true;
++ }
++
++ // Every token any built-in sets: applying a theme clears the ones it does not set (no inherited --success).
++ const KNOWN_PROPS = (function () { const out = {}; for (const id in THEMES) for (const k in THEMES[id]) out[k] = true; return Object.keys(out); })();
++ /** Readable text on a colour: near-black or white, whichever contrasts more (WCAG relative luminance). */
++ function onColor(c) {
++ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(c || '').trim());
++ if (!m) return null;
++ const h = m[1].length === 3 ? m[1].split('').map((x) => x + x).join('') : m[1];
++ const lin = (i) => { const v = parseInt(h.slice(i, i + 2), 16) / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
++ const L = 0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4);
++ return (L + 0.05) / 0.05 >= 1.05 / (L + 0.05) ? '#0b0d10' : '#ffffff';
++ }
++ function applyVars(vars) {
++ if (!vars || typeof vars !== 'object') return;
++ // A theme without its own text-on-accent colours (custom and older server themes) gets readable ones derived.
++ if (vars['--accent'] && !vars['--on-accent']) {
++ const on = onColor(vars['--accent']);
++ if (on) vars = Object.assign({}, vars, { '--on-accent': on, '--on-accent-strong': vars['--on-accent-strong'] || onColor(vars['--accent-strong'] || vars['--accent']) || on });
++ }
++ const el = document.documentElement;
++ for (let i = 0; i < KNOWN_PROPS.length; i++) if (!(KNOWN_PROPS[i] in vars)) el.style.removeProperty(KNOWN_PROPS[i]);
++ for (const [prop, val] of Object.entries(vars)) {
++ if (prop.startsWith('--')) el.style.setProperty(prop, val);
++ }
++ // Native controls, scrollbars and form fields follow the theme's light/dark mode.
++ const scheme = vars['--color-scheme'];
++ if (scheme === 'light' || scheme === 'dark') { el.style.colorScheme = scheme; el.setAttribute('data-theme-mode', scheme); }
++ // The canvas follows the variables (value as fallback), so a page that sets --bg-primary later repaints it too.
++ if (vars['--bg-primary']) el.style.background = `var(--bg-primary, ${vars['--bg-primary']})`;
++ if (vars['--text-primary']) el.style.color = `var(--text-primary, ${vars['--text-primary']})`;
++ syncBrowserUi();
++ }
++
++ // ── A correction fades in ─────────────────────────────────
++ // A theme that changes under someone (the account's answer, another tab) crossfades for 0.4 s instead of jumping;
++ // first paint and their own picks stay instant. A colour fade moves nothing, so Calm keeps it; only Off stops it.
++ let _fadeTimer = 0;
++ function fadeNext() {
++ if (typeof document === 'undefined' || !document.head || motionOf() === 'off') return;
++ const el = document.documentElement;
++ if (!document.getElementById('ov-theme-fade-css')) {
++ const st = document.createElement('style'); st.id = 'ov-theme-fade-css';
++ st.textContent = 'html.ov-theme-fade,html.ov-theme-fade *,html.ov-theme-fade *::before,html.ov-theme-fade *::after{transition:background-color .4s ease,color .4s ease,border-color .4s ease,fill .4s ease,stroke .4s ease!important}';
++ document.head.appendChild(st);
++ }
++ el.classList.add('ov-theme-fade');
++ clearTimeout(_fadeTimer); _fadeTimer = setTimeout(function () { el.classList.remove('ov-theme-fade'); }, 600);
++ }
++
++ // ── The browser's UI follows the theme on every apply: theme-color, the favicon (<link rel="icon" data-ov-icon>),
++ // and window 'ov:theme' { accent, accentLight, bg, mode }.
++ // FAVICON:BEGIN — generated by scripts/build-theme-loader.js from mark.js and app-icon.js; do not edit by hand
++ const FAV_DEFS = '<mask id="ovh" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="#000" stroke-width="31" stroke-linecap="round"/></mask><linearGradient id="ovgo" gradientUnits="userSpaceOnUse" x1="3.09" y1="23" x2="57.09" y2="77"><stop offset="0" stop-color="#87a6ff"/><stop offset="1" stop-color="#4268d6"/></linearGradient><linearGradient id="ovgv" gradientUnits="userSpaceOnUse" x1="0" y1="31.5" x2="0" y2="68.5"><stop offset="0" stop-color="#ae8df9"/><stop offset="1" stop-color="#784fd4"/></linearGradient>';
++ const FAV_BODY = '<circle cx="30.09" cy="50" r="18" fill="none" stroke="url(#ovgo)" stroke-width="18" mask="url(#ovh)"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="url(#ovgv)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>';
++ const FAV_LIGHT = { cx: 30.09, cy: 50, r: 5.58 };
++ const FAV_SITES = { network: ['#60a5fa', ''], live: ['#60a5fa', '#ef4444'], tools: ['#38bdf8', ''], community: ['#34d399', ''], games: ['#a78bfa', '#facc15'], media: ['#fbbf24', ''], space: ['#818cf8', ''], ai: ['#22d3ee', ''] };
++ // FAVICON:END
++ const COLOR_RE = /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/deg]+\))$/i;
++ function readColor(name, fallback) {
++ try { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return COLOR_RE.test(v) ? v : fallback; } catch { return fallback; }
++ }
++ /** The tab icon: app-icon.js's favicon() (the Pair, heavy form, the site's glow and light) on the theme's ground. */
++ function faviconSvg(site, bg) {
++ const s = FAV_SITES[site] || FAV_SITES.network, a = s[0];
++ const light = s[1] ? '<circle cx="' + FAV_LIGHT.cx + '" cy="' + FAV_LIGHT.cy + '" r="' + FAV_LIGHT.r + '" fill="' + s[1] + '"/>' : '';
++ return 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64"><defs>' + FAV_DEFS
++ + '\n<radialGradient id="glow"><stop offset="0" stop-color="' + a + '" stop-opacity=".3"/><stop offset="1" stop-color="' + a + '" stop-opacity="0"/></radialGradient></defs>'
++ + '<rect x="-32" y="-32" width="64" height="64" rx="15" fill="' + bg + '"/><g transform="scale(0.58) translate(-50 -50)"><ellipse cx="50" cy="50" rx="52" ry="40" fill="url(#glow)"/>'
++ + FAV_BODY + light + '</g></svg>');
++ }
++ let _browserUiKey = '';
++ function syncBrowserUi() {
++ if (typeof document === 'undefined' || !document.head) return;
++ const accent = readColor('--accent', '#3b82f6'), light = readColor('--accent-light', accent), bg = readColor('--bg-secondary', readColor('--bg-primary', '#0a0f1c'));
++ const mode = document.documentElement.getAttribute('data-theme-mode') || 'dark';
++ const key = [accent, light, bg, mode].join('|'); if (key === _browserUiKey) return; _browserUiKey = key;
++ try {
++ let meta = document.querySelector('meta[name="theme-color"]');
++ if (!meta) { meta = document.createElement('meta'); meta.name = 'theme-color'; document.head.appendChild(meta); }
++ meta.setAttribute('content', bg);
++ const link = document.querySelector('link[rel~="icon"][data-ov-icon]');
++ if (link) { link.type = 'image/svg+xml'; link.href = faviconSvg(link.getAttribute('data-ov-icon'), readColor('--bg-primary', '#0a0f1c')); }
++ } catch { /* cosmetic */ }
++ try { window.dispatchEvent(new CustomEvent('ov:theme', { detail: { accent: accent, accentLight: light, bg: bg, mode: mode } })); } catch { /* */ }
++ }
++
++ /** The theme from localStorage 'ov_theme' (id and variables), else the 'ov_theme_id' cookie, else the default. */
++ function resolveAndApply() {
++ // 1. Try localStorage (same-origin cache with full variables)
++ try {
++ const raw = localStorage.getItem(STORAGE_KEY);
++ if (raw) {
++ const saved = JSON.parse(raw);
++ if (saved && saved.variables && typeof saved.variables === 'object') {
++ return show(saved.id || saved.slug || DEFAULT_ID, saved.variables, saved.custom);
++ }
++ if (saved && (saved.id || saved.slug)) {
++ const id = saved.id || saved.slug;
++ if (THEMES[id]) return show(id, THEMES[id]);
++ }
++ }
++ } catch { /* corrupt localStorage */ }
++
++ // 2. Try cross-domain cookie
++ const cookieId = getCookie(COOKIE_NAME);
++ if (cookieId && THEMES[cookieId]) {
++ // Cache the full variable set so the next load paints from localStorage instantly.
++ cacheLocally(cookieId, THEMES[cookieId]);
++ return show(cookieId, THEMES[cookieId]);
++ }
++
++ // 3. Default — vibe (already the CSS fallback values, but apply explicitly), or the season's theme
++ return show(DEFAULT_ID, THEMES[DEFAULT_ID]);
++ }
++
++ function authToken() {
++ return getCookie('ov_token') ||
++ (typeof localStorage !== 'undefined' && (localStorage.getItem('ov_token') || localStorage.getItem('token'))) ||
++ null;
++ }
++
++ /** Cache the theme's FULL variables (custom ones too) and the date it was chosen, so the next load paints at once. */
++ function cacheLocally(themeId, vars, custom, at) {
++ try {
++ localStorage.setItem(STORAGE_KEY, JSON.stringify({
++ id: themeId, slug: themeId, variables: vars, at: at || Date.now(), ...(custom ? { custom: true } : {}),
++ }));
++ } catch { /* quota */ }
++ setCookie(COOKIE_NAME, themeId, 365);
++ }
++
++ /** Save a theme here and to the account (opts.custom: variable overrides; opts.sync === false: this device only). */
++ function save(themeId, variables, opts) {
++ const custom = (opts && opts.custom) || null;
++ const base = variables || THEMES[themeId] || {};
++ const vars = custom ? Object.assign({}, base, custom) : base;
++ if (!vars || !Object.keys(vars).length) return;
++ // Picking the default theme while a season's stands in for it means "my normal theme": seasonal themes go off.
++ if (isDefault(themeId) && !custom && _season && !(opts && opts.keepSeasonal)) setDisplay({ seasonal: 'off' }, { noRepaint: true });
++ cacheLocally(themeId, vars, !!custom);
++ show(themeId, vars, !!custom); // saving a theme shows it: every token, the canvas and the browser UI follow at once
++ if (opts && opts.sync === false) return;
++ if (typeof fetch === 'undefined') return;
++ const token = authToken();
++ const headers = { 'Content-Type': 'application/json' };
++ if (token) headers['Authorization'] = 'Bearer ' + token;
++ fetch(_config.apiBase + '/api/themes/me', {
++ method: 'PUT', headers: headers, credentials: 'include', keepalive: true,
++ body: JSON.stringify({ theme_id: themeId, custom_variables: custom, at: themeAt() }),
++ }).catch(function () { /* offline / signed out — local cache still holds */ });
++ }
++
++ /** When the theme in this browser was chosen (ms), or 0 for a copy older than 4.64.0. */
++ function themeAt() {
++ try { const t = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); return t && Number(t.at) > 0 ? Number(t.at) : 0; } catch { return 0; }
++ }
++
++ /**
++ * Get the current theme ID (from cookie or localStorage).
++ */
++ function getCurrent() {
++ try {
++ const raw = localStorage.getItem(STORAGE_KEY);
++ if (raw) {
++ const saved = JSON.parse(raw);
++ if (saved && (saved.id || saved.slug)) return saved.id || saved.slug;
++ }
++ } catch {}
++ return getCookie(COOKIE_NAME) || 'vibe';
++ }
++
++ // ── 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'] };
++ // 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).
++ // A control nobody styled wears the theme, not the browser's grey (4.186.0): only one with no class, only its face,
++ // at no specificity, so any rule a site wrote still wins and no layout moves. Its shape is the kit's job.
++ const BARE = ':where(:not([class]))', FACE = '{font-family:inherit;border:1px solid var(--border-light,var(--border,color-mix(in srgb,currentColor 30%,transparent)));border-radius:10px;color:var(--text-primary,inherit);background:';
++ const BARE_CSS = ':where(button,input:is([type=submit],[type=button],[type=reset]))' + BARE + FACE + 'var(--bg-hover,var(--bg-card,color-mix(in srgb,currentColor 8%,transparent)))}'
++ + ':where(input:not([type=checkbox],[type=radio],[type=range],[type=color],[type=file],[type=image],[type=submit],[type=button],[type=reset],[type=hidden]),select,textarea)' + BARE + FACE + 'var(--bg-input,var(--bg-primary,transparent))}'
++ + ':where(input:is([type=checkbox],[type=radio],[type=range]),progress)' + BARE + '{accent-color:var(--accent,auto)}';
++ const ALIAS_CSS = ':where(html){--error:var(--danger);--error-text:var(--danger-text);--ok:var(--success-text);--warn:var(--warning-text);--accent-text:var(--accent-strong)}';
++ // Comfort (4.45.0): one <html> attribute each, !important over the inline theme (Okabe-Ito colours, OLED black).
++ const COMFORT_CSS = 'html[data-ov-contrast=more]{--text-secondary:color-mix(in srgb,var(--text-primary) 88%,var(--bg-primary))!important;--text-muted:color-mix(in srgb,var(--text-primary) 76%,var(--bg-primary))!important;--border:color-mix(in srgb,var(--text-primary) 40%,var(--bg-primary))!important;--border-light:color-mix(in srgb,var(--text-primary) 55%,var(--bg-primary))!important}'
++ + 'html[data-ov-contrast=more] :is(p,dd,td) a:not([class]){text-decoration-line:underline;text-underline-offset:2px}html[data-ov-contrast=more] :focus-visible{outline:3px solid var(--accent-strong,var(--accent))!important;outline-offset:2px}'
++ + 'html[data-ov-font=readable]{--ov-font:"Atkinson Hyperlegible",Verdana,Tahoma,"Segoe UI",system-ui,sans-serif!important}html[data-ov-font=readable] body{font-family:var(--ov-font)!important;letter-spacing:.02em;word-spacing:.06em}html[data-ov-font=readable] :is(p,li,dd){line-height:1.7}'
++ + 'html[data-ov-colors=cb-safe]{--success:#56b4e9!important;--success-text:#7cc6ef!important;--warning:#f0e442!important;--warning-text:#f0e442!important;--danger:#e8743b!important;--danger-text:#f0985f!important}'
++ + 'html[data-ov-colors=cb-safe][data-theme-mode=light]{--success:#0072b2!important;--success-text:#005a8c!important;--warning:#e69f00!important;--warning-text:#8a6100!important;--danger:#d55e00!important;--danger-text:#a84400!important}'
++ + 'html[data-ov-oled=on][data-theme-mode=dark]{--bg-primary:#000!important;--bg-secondary:#050608!important;--bg-tertiary:#0b0d12!important;--bg-card:#0a0c10!important;--bg-input:#0b0d12!important;--bg-hover:#12151c!important}';
++ const COMFORT_ATTRS = { contrast: ['data-ov-contrast', 'normal'], font: ['data-ov-font', 'default'], colors: ['data-ov-colors', 'default'], oled: ['data-ov-oled', 'off'], icons: ['data-ov-icons', 'auto'], window: ['data-ov-window', 'glass'] };
++ // ── Motion (4.183.0; the owner, 2026-11-10: "still have full motion but actually just reduce it instead of getting
++ // rid of it"). auto follows the device, full plays everything, reduced is Calm (every effect, smaller and slower),
++ // off alone stops motion. <html data-ov-motion=full|reduced|off> is the one signal; --ov-move is what a moving
++ // thing multiplies its distance by (display.move() in script); under Calm an endless loop runs at CALM_RATE.
++ const CALM_MOVE = 0.35, CALM_RATE = 0.6;
++ let _rm = null;
++ function deviceCalm() { try { _rm = _rm || window.matchMedia('(prefers-reduced-motion: reduce)'); return !!_rm.matches; } catch (e) { return false; } }
++ function motionOf(d) { const m = (d || readDisplay()).motion; return m === 'off' ? 'off' : m === 'reduced' ? 'calm' : m === 'full' ? 'full' : deviceCalm() ? 'calm' : 'full'; }
++ function moveOf() { const m = motionOf(); return m === 'off' ? 0 : m === 'calm' ? CALM_MOVE : 1; }
++ const MOTION_CSS = ':where(html){--ov-move:1}html[data-ov-motion=reduced]{--ov-move:' + CALM_MOVE + ';scroll-behavior:auto!important}html[data-ov-motion=off]{--ov-move:0}'
++ + 'html[data-ov-motion=off] *:not([data-fx-demo] *),html[data-ov-motion=off] *:not([data-fx-demo] *)::before,html[data-ov-motion=off] *:not([data-fx-demo] *)::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}'
++ + 'html[data-ov-motion=off]::view-transition-group(*),html[data-ov-motion=off]::view-transition-old(*),html[data-ov-motion=off]::view-transition-new(*){animation-duration:.001ms!important}'
++ + 'html[data-ov-motion=off] .ov-icon .ovi-comet{display:none}';
++ const _paced = typeof WeakSet === 'function' ? new WeakSet() : null;
++ function paceLoops(scope) {
++ if (!_paced || typeof document === 'undefined') return;
++ try {
++ const calm = document.documentElement.getAttribute('data-ov-motion') === 'reduced';
++ const list = scope && scope.getAnimations ? scope.getAnimations({ subtree: true }) : document.getAnimations();
++ for (let i = 0; i < list.length; i++) {
++ const a = list[i], fx = a.effect, t = fx && fx.getComputedTiming ? fx.getComputedTiming() : null;
++ if (!t || t.iterations !== Infinity) continue;
++ const el = fx.target, slow = calm && !(el && el.closest && el.closest('[data-fx-demo]'));
++ if (slow && !_paced.has(a)) { _paced.add(a); a.updatePlaybackRate(a.playbackRate * CALM_RATE); }
++ else if (!slow && _paced.has(a)) { _paced.delete(a); a.updatePlaybackRate(a.playbackRate / CALM_RATE); }
++ }
++ } catch (e) { /* no Web Animations here: loops keep their own speed */ }
++ }
++ if (typeof document !== 'undefined' && document.addEventListener) {
++ 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) { /* */ }
++ return out;
++ }
++ function applyDisplay(d) {
++ if (typeof document === 'undefined') return;
++ const el = document.documentElement;
++ const m = motionOf(d), attr = m === 'off' ? 'off' : m === 'calm' ? 'reduced' : d.motion === 'full' ? 'full' : '';
++ if (attr) el.setAttribute('data-ov-motion', attr); else el.removeAttribute('data-ov-motion');
++ if (d.text === '100') { el.style.removeProperty('--ov-text-scale'); el.removeAttribute('data-ov-text'); } else { el.style.setProperty('--ov-text-scale', String(parseInt(d.text, 10) / 100)); el.setAttribute('data-ov-text', d.text); }
++ for (const k in COMFORT_ATTRS) { const [attr, off] = COMFORT_ATTRS[k]; if (d[k] && d[k] !== off) el.setAttribute(attr, d[k]); else el.removeAttribute(attr); }
++ if (!document.getElementById('ov-display-css') && document.head) {
++ const st = document.createElement('style'); st.id = 'ov-display-css';
++ st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}' + MOTION_CSS + BARE_CSS + ALIAS_CSS + COMFORT_CSS;
++ document.head.appendChild(st);
++ }
++ paceLoops();
++ try { window.dispatchEvent(new CustomEvent('ov:display', { detail: d })); } catch (e) { /* */ }
++ }
++ /** When the display settings in this browser were last changed (ms), or 0 for a copy older than 4.64.0. */
++ function displayAt() {
++ try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null'); return d && Number(d.at) > 0 ? Number(d.at) : 0; } catch (e) { return 0; }
++ }
++ 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]);
++ // 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) { /* */ }
++ applyDisplay(d);
++ if (d.seasonal + '|' + d.scheme !== was && !(opts && opts.noRepaint)) show(_shown.id, _shown.vars || THEMES[_shown.id] || THEMES[DEFAULT_ID], _shown.custom);
++ if (!(opts && opts.local) && typeof fetch !== 'undefined') {
++ const token = authToken(); const headers = { 'Content-Type': 'application/json' }; if (token) headers.Authorization = 'Bearer ' + token;
++ if (token || /(?:^|; )ov_sso_hint=account/.test(document.cookie)) fetch(_config.apiBase + '/api/themes/me/display', { method: 'PUT', headers: headers, credentials: 'include', keepalive: true, body: JSON.stringify(Object.assign({}, d, { at: at })) }).catch(function () {});
++ }
++ return d;
++ }
++
++ // ── Try a theme (4.41.0): ?ov-try=<catalog name> paints it for this tab without saving (?ov-try=off ends it); a bar
++ // offers Use it (saves to the account) and Stop. Only a name is read from the URL, never colours.
++ const TRY_KEY = 'ov_try';
++ const TRY_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
++ const tryName = function (id) { return String(id).split('-').map(function (w) { return w ? w[0].toUpperCase() + w.slice(1) : w; }).join(' '); };
++ function paintTry() {
++ applyVars(_trying.vars);
++ try {
++ const el = document.documentElement;
++ el.setAttribute('data-theme', _trying.id);
++ el.setAttribute('data-ov-trying', _trying.id);
++ el.removeAttribute('data-ov-season');
++ } catch { /* no DOM */ }
++ syncBrowserUi();
++ return _trying.id;
++ }
++ function readTry() {
++ let id = '';
++ try {
++ const p = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('ov-try') : null;
++ if (p === 'off') { sessionStorage.removeItem(TRY_KEY); return null; }
++ if (p && TRY_ID.test(p)) sessionStorage.setItem(TRY_KEY, p);
++ id = sessionStorage.getItem(TRY_KEY) || '';
++ } catch { id = ''; }
++ return TRY_ID.test(id) ? id : null;
++ }
++ function startTry(id, name) {
++ if (!id || !TRY_ID.test(id)) return;
++ try { sessionStorage.setItem(TRY_KEY, id); } catch { /* private mode: this page only */ }
++ const begin = function (vars, label) {
++ if (!vars || !Object.keys(vars).length) { stopTry(false); return; }
++ _trying = { id: id, name: label || tryName(id), vars: vars };
++ fadeNext();
++ paintTry();
++ whenReady(tryBar);
++ };
++ if (THEMES[id]) return begin(THEMES[id], name);
++ if (typeof fetch === 'undefined') return;
++ // A gallery theme comes from the catalog, once the page has run init({ apiBase }) (the script tag after this one).
++ whenReady(function () {
++ fetch(_config.apiBase + '/api/themes/' + encodeURIComponent(id))
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (d) { begin(d && d.theme && d.theme.variables, d && d.theme && d.theme.name); })
++ .catch(function () { stopTry(false); });
++ });
++ }
++ function stopTry(repaint) {
++ _trying = null;
++ try { sessionStorage.removeItem(TRY_KEY); } catch { /* */ }
++ try {
++ document.documentElement.removeAttribute('data-ov-trying');
++ const bar = document.getElementById('ov-try-bar');
++ if (bar) bar.remove();
++ const u = new URL(location.href);
++ if (u.searchParams.has('ov-try')) { u.searchParams.delete('ov-try'); history.replaceState(history.state, '', u.pathname + (u.search || '') + u.hash); }
++ } catch { /* no DOM */ }
++ if (repaint !== false) { fadeNext(); resolveAndApply(); }
++ }
++ function whenReady(fn) {
++ if (typeof document === 'undefined') return;
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true }); else fn();
++ }
++ function tryBar() {
++ if (!_trying || document.getElementById('ov-try-bar') || !document.body) return;
++ const t = _trying;
++ const bar = document.createElement('div');
++ bar.id = 'ov-try-bar';
++ bar.setAttribute('role', 'status');
++ bar.style.cssText = 'position:fixed;left:0;right:0;margin:0 auto;width:max-content;bottom:calc(var(--ovnav-appbar-h,0px) + 12px);z-index:2147483000;display:flex;align-items:center;gap:6px;max-width:calc(100vw - 24px);box-sizing:border-box;padding:4px 4px 4px 16px;border-radius:999px;border:1px solid var(--border-light,var(--border,#2c3d5c));background:var(--bg-card,#131c2e);color:var(--text-primary,#e6edf7);box-shadow:0 10px 30px rgba(0,0,0,.35);font:600 14px/1.3 var(--ov-font,system-ui,sans-serif)';
++ const label = document.createElement('span');
++ label.style.cssText = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0';
++ label.textContent = 'Trying ' + t.name;
++ const btn = function (text, strong) {
++ const b = document.createElement('button');
++ b.type = 'button';
++ b.textContent = text;
++ b.style.cssText = 'flex:none;min-height:44px;padding:0 16px;border-radius:999px;cursor:pointer;font:inherit;' + (strong
++ ? 'border:0;background:var(--accent-strong,var(--accent,#3b82f6));color:var(--on-accent-strong,#fff)'
++ : 'border:1px solid var(--border-light,var(--border,#2c3d5c));background:transparent;color:inherit');
++ return b;
++ };
++ const use = btn('Use it', true);
++ const stop = btn('Stop', false);
++ use.addEventListener('click', function () {
++ stopTry(false);
++ save(t.id, t.vars);
++ const done = document.createElement('div');
++ done.setAttribute('role', 'status');
++ done.style.cssText = bar.style.cssText + ';padding:12px 18px';
++ done.textContent = t.name + ' is your theme now, on every OpenVibe site.';
++ document.body.appendChild(done);
++ setTimeout(function () { done.remove(); }, 3500);
++ });
++ stop.addEventListener('click', function () { stopTry(true); });
++ bar.append(label, use, stop);
++ document.body.appendChild(bar);
++ }
++
++ // ── The look travels (4.64.0): each domain keeps its own copy, so a click to another OpenVibe site carries ?ov_look=
++ // (what this page paints, the display and theme with their dates). Added at click time, read only after an OpenVibe
++ // page, only colours pass; the next site keeps it when newer and drops it from the address before first paint.
++ const OV_HOST = /(^|\.)(openvibe\.[a-z]{2,12}|openre\.stream)$/i;
++ const PAINT_KEY = 'ov_paint';
++ const PAINT_ATTRS = ['data-theme', 'data-theme-mode', 'data-ov-season', 'data-ov-season-mark', 'data-ov-motion', 'data-ov-text', 'data-ov-contrast', 'data-ov-font', 'data-ov-colors', 'data-ov-oled', 'data-ov-icons', 'data-ov-window'];
++ const SAFE_VALUE = /^(#[0-9a-f]{3,8}|rgba?\([0-9.,\s%]+\)|[0-9.,\s]+|light|dark|(-?[0-9.]+(px)?\s+){2,4}rgba?\([0-9.,\s%]+\))$/i;
++ const SAFE_ATTR = /^[a-z0-9-]{1,40}$/;
++ function seasonUntil(s) {
++ if (!s) return 0;
++ const d = new Date(), y = d.getFullYear(), to = s.to.split('-');
++ const end = new Date(y + (s.from > s.to && s.from <= String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') ? 1 : 0), Number(to[0]) - 1, Number(to[1]) + 1);
++ return end.getTime();
++ }
++ function paintRecord() {
++ const el = document.documentElement, a = {};
++ for (let i = 0; i < PAINT_ATTRS.length; i++) { const v = el.getAttribute(PAINT_ATTRS[i]); if (v) a[PAINT_ATTRS[i]] = v; }
++ return { k: THEME_KEYS.map(function (k) { return el.style.getPropertyValue(k).trim(); }), a: a, ts: el.style.getPropertyValue('--ov-text-scale').trim(), u: seasonUntil(_season), at: Date.now() };
++ }
++ let _paintTimer = 0;
++ function rememberPaint() {
++ if (_paintTimer || typeof setTimeout === 'undefined') return;
++ _paintTimer = setTimeout(function () { _paintTimer = 0; if (_trying) return; try { localStorage.setItem(PAINT_KEY, JSON.stringify(paintRecord())); } catch (e) { /* */ } }, 0);
++ }
++ function lookToken() {
++ const rec = { v: 1, d: readDisplay(), da: displayAt(), t: _shown.id, c: _shown.custom ? 1 : 0, ta: themeAt(), p: paintRecord() };
++ return btoa(JSON.stringify(rec)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
++ }
++ function readLook() {
++ try {
++ if (typeof location === 'undefined' || !/[?&]ov_look=/.test(location.search)) return null;
++ const u = new URL(location.href), raw = u.searchParams.get('ov_look') || '';
++ u.searchParams.delete('ov_look');
++ try { history.replaceState(history.state, '', u.pathname + (u.search || '') + u.hash); } catch (e) { /* */ }
++ const from = document.referrer ? new URL(document.referrer).hostname : '';
++ if (!OV_HOST.test(from) || raw.length > 4000) return null;
++ const L = JSON.parse(atob(raw.replace(/-/g, '+').replace(/_/g, '/')));
++ return L && L.v === 1 ? L : null;
++ } catch (e) { return null; }
++ }
++ function adoptLook(L) {
++ if (!L) return;
++ const now = Date.now(), clamp = function (n) { n = Number(n) || 0; return n > now + 60000 ? now : n; };
++ 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]);
++ try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(Object.assign({}, d, { at: da }))); } catch (e) { /* */ }
++ }
++ const id = String(L.t || '');
++ if (TRY_ID.test(id) && ta > themeAt()) {
++ if (!L.c && THEMES[id]) cacheLocally(id, THEMES[id], false, ta);
++ else if (L.c && L.p && Array.isArray(L.p.k)) {
++ const vars = {};
++ for (let i = 0; i < THEME_KEYS.length; i++) { const v = String(L.p.k[i] || ''); if (v && SAFE_VALUE.test(v)) vars[THEME_KEYS[i]] = v; }
++ if (Object.keys(vars).length > 8) cacheLocally(id, vars, true, ta);
++ }
++ }
++ }
++ function decorateLink(e) {
++ const a = e.target && e.target.closest && e.target.closest('a[href]');
++ if (!a || a.hasAttribute('download')) return;
++ let u; try { u = new URL(a.href, location.href); } catch (err) { return; }
++ if (!/^https?:$/.test(u.protocol) || u.hostname === location.hostname || !OV_HOST.test(u.hostname)) return;
++ try { u.searchParams.set('ov_look', lookToken()); a.href = u.toString(); } catch (err) { /* */ }
++ }
++ const _arrived = readLook();
++ adoptLook(_arrived);
++ applyDisplay(readDisplay());
++ if (typeof document !== 'undefined' && document.addEventListener) {
++ ['pointerdown', 'keydown', 'click'].forEach(function (ev) { document.addEventListener(ev, function (e) { if (ev !== 'keydown' || e.key === 'Enter') decorateLink(e); }, true); });
++ window.addEventListener('ov:theme', rememberPaint);
++ window.addEventListener('ov:display', rememberPaint);
++ }
++
++ const activeId = resolveAndApply();
++ rememberPaint();
++ startTry(readTry());
++ // The icon link may be parsed after this script: repaint once the head is complete.
++ if (typeof document !== 'undefined' && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { _browserUiKey = ''; syncBrowserUi(); });
++ // A theme picked in another tab (or on another OpenVibe page of this site) applies here at once.
++ if (typeof window !== 'undefined') window.addEventListener('storage', function (e) { if (e.key === 'ov_theme') { try { fadeNext(); resolveAndApply(); } catch (err) { /* */ } } if (e.key === DISPLAY_KEY) { fadeNext(); applyDisplay(readDisplay()); show(_shown.id, _shown.vars || THEMES[_shown.id] || THEMES[DEFAULT_ID], _shown.custom); } });
++
++ if (typeof window !== 'undefined' && window.matchMedia) {
++ try {
++ const mq = window.matchMedia('(prefers-color-scheme: dark)');
++ const onDevice = function () { if (readDisplay().scheme === 'auto') { fadeNext(); show(_shown.id, _shown.vars || THEMES[_shown.id] || THEMES[DEFAULT_ID], _shown.custom); } };
++ if (mq.addEventListener) mq.addEventListener('change', onDevice); else if (mq.addListener) mq.addListener(onDevice);
++ } catch (e) { /* no media queries */ }
++ }
++
++ if (typeof window !== 'undefined' && window.matchMedia) {
++ try {
++ const rmq = window.matchMedia('(prefers-reduced-motion: reduce)');
++ const onMotion = function () { if (readDisplay().motion === 'auto') applyDisplay(readDisplay()); };
++ if (rmq.addEventListener) rmq.addEventListener('change', onMotion); else if (rmq.addListener) rmq.addListener(onMotion);
++ } catch (e) { /* no media queries */ }
++ }
++
++ // A theme not in the built-in map (community, custom) comes from the catalog, never falls back to the default.
++ function fetchThemeVars(themeId) {
++ return fetch(_config.apiBase + '/api/themes/' + encodeURIComponent(themeId))
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (d) { return (d && d.theme && d.theme.variables) || null; })
++ .catch(function () { return null; });
++ }
++
++ // ── The account's look from openvibe.network, at once (the cached look is already painted; newest copy wins). No
++ // token gate: the ov_token cookie rides along (credentials: 'include'), for sites whose script cannot read it.
++ let _lastSync = 0;
++ /** This site's id from <html data-ov-service> (openvibe-shared/shell writes it), or '' (4.43.0). */
++ function siteService() {
++ try { const s = document.documentElement.getAttribute('data-ov-service') || ''; return /^[a-z][a-z0-9-]{1,31}$/.test(s) ? s : ''; } catch { return ''; }
++ }
++ function syncFromServer() {
++ if (typeof fetch === 'undefined') return;
++ _lastSync = Date.now();
++ const token = authToken();
++ const headers = token ? { 'Authorization': 'Bearer ' + token } : {};
++ // A per-site look set in OpenVibe.Design ("Games in Neon Tokyo") answers for ?service=<this site> (4.43.0).
++ const site = siteService();
++ fetch(_config.apiBase + '/api/themes/me/active' + (site ? '?service=' + encodeURIComponent(site) : ''), { headers: headers, credentials: 'include' })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (data) {
++ if (data && data.display) {
++ const cur = readDisplay(); const next = {}; for (const k in DISPLAY_OPTS) next[k] = data.display[k] || cur[k];
++ const serverAt = Number(data.display_at) || 0, localAt = displayAt();
++ const takeServer = serverAt ? serverAt > localAt : !(localAt && Date.now() - localAt < 60000);
++ if (takeServer && JSON.stringify(next) !== JSON.stringify(cur)) { fadeNext(); setDisplay(data.display, { local: true, at: serverAt || localAt }); }
++ else if (serverAt && localAt > serverAt && JSON.stringify(next) !== JSON.stringify(cur)) setDisplay(cur, { at: localAt }); // newer here: the account catches up
++ }
++ if (!data || !data.theme_id) return;
++ const serverThemeAt = Number(data.theme_at) || 0, localThemeAt = themeAt();
++ if (serverThemeAt ? serverThemeAt < localThemeAt : (localThemeAt && Date.now() - localThemeAt < 60000)) return; // a newer pick here
++ const serverId = data.theme_id;
++ const custom = (data.custom_variables && typeof data.custom_variables === 'object'
++ && Object.keys(data.custom_variables).length) ? data.custom_variables : null;
++ const builtin = THEMES[serverId];
++ const settle = function (base) {
++ const vars = custom ? Object.assign({}, base || {}, custom) : (base || null);
++ if (!vars || !Object.keys(vars).length) return;
++ cacheLocally(serverId, vars, !!custom, serverThemeAt || localThemeAt || 1); // cacheLocally, not save() — no write-back loop
++ if (_shown.id === serverId && !!_shown.custom === !!custom && JSON.stringify(_shown.vars) === JSON.stringify(vars)) return;
++ fadeNext();
++ show(serverId, vars, !!custom);
++ };
++ if (builtin) return settle(builtin);
++ fetchThemeVars(serverId).then(settle);
++ })
++ .catch(function () { /* offline / signed out — keep the cached theme */ });
++ }
++ syncFromServer();
++ // Asked again when the page comes back into view, and shortly after arriving from another site (a change saved there
++ // just before the link may land after this page first asked).
++ if (typeof document !== 'undefined' && document.addEventListener) {
++ const again = function () { if (Date.now() - _lastSync > 10000) syncFromServer(); };
++ document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') again(); });
++ if (typeof window !== 'undefined' && window.addEventListener) window.addEventListener('pageshow', function (e) { if (e && e.persisted) again(); });
++ let from = '';
++ try { from = document.referrer ? new URL(document.referrer).hostname : ''; } catch (e) { from = ''; }
++ if (from && typeof location !== 'undefined' && from !== location.hostname) setTimeout(syncFromServer, 2500);
++ }
++
++ // ── Public API ───────────────────────────────────────────
++ const OpenVibeThemeLoader = {
++ THEMES: THEMES,
++ display: { get: readDisplay, set: setDisplay, options: DISPLAY_OPTS, motion: function () { return motionOf(); }, move: moveOf },
++ apply: applyById,
++ applyVars: applyVars,
++ // Seasons: SEASONS, now() → the season today (or null), showing() → the season whose theme is on screen (or null)
++ seasons: { SEASONS: SEASONS, now: seasonNow, showing: function () { return _season; } },
++ // Light / Dark / Auto: get() → 'light'|'dark'|'auto', set(mode); pairOf(id) → its other half; modeOf(id)
++ scheme: { get: getScheme, set: setScheme, device: deviceMode },
++ pairOf: function (id) { return PAIRS[id] || null; },
++ modeOf: function (id) { return MODES[id] || null; },
++ onColor: onColor,
++ save: save,
++ // Try a theme on this tab without saving it (?ov-try=): start(id), stop(), current() → the id being tried, or null.
++ try: { start: function (id) { startTry(String(id || '')); }, stop: function () { stopTry(true); }, current: function () { return _trying ? _trying.id : null; } },
++ getCurrent: getCurrent,
++ // This site's id (<html data-ov-service>), the key of its theme exception on the account (4.43.0).
++ site: function () { return siteService(); },
++ resolveAndApply: resolveAndApply,
++ init: function (opts) {
++ if (opts && typeof opts.apiBase === 'string' && opts.apiBase.trim()) {
++ _config.apiBase = opts.apiBase.trim();
++ }
++ return OpenVibeThemeLoader;
++ },
++ };
++
++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeThemeLoader;
++ else root.OpenVibeThemeLoader = OpenVibeThemeLoader;
++
++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
ddiff --git a/package.json b/package.json
iindex 5ac22b3..64c2bc7 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-extensions",
-- "version": "0.9.1",
++ "version": "0.9.2",
"private": true,
"description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.",
"scripts": {
ddiff --git a/scripts/check-panel.js b/scripts/check-panel.js
iindex 550e26a..11a0b03 100644
---- a/scripts/check-panel.js
++++ b/scripts/check-panel.js
@@@ -4,7 +4,7 @@
* The side panel in a real browser: headless Chrome (CDP over Node's own WebSocket, no dependencies) loads the
* unpacked extension, opens panel.html with a stubbed Network/Chat/Codes and a live agent view, and checks what a
* person would see: the Dock on its Agents tab, the agent's stream frame, the line and the meta, the four controls
-- * (Pause/Resume, Curtain now, Mute/Unmute, End), and the Dock's own styles and nothing a CSP refused.
++ * (Pause/Resume, Curtain now, Mute/Unmute, End, and the local Float), and the Dock's own styles and nothing a CSP refused.
*
* node scripts/check-panel.js (CHROME=/path/to/chrome to choose the browser; OV_EXT_DIR=an unpacked build, default browser/)
*
@@@ -189,7 +189,7 @@ async function main() {
watch: (() => { const w = q('a.ovda-watch'); return w ? { hidden: w.hidden, text: w.textContent.trim() } : null; })(),
frame: (q('iframe.ovda-frame') || {}).src || null,
tab: text(q('[role="tab"][aria-selected="true"]')),
-- styles: !!document.getElementById('ovdk-styles') && !!document.getElementById('ovda-styles') && !!document.getElementById('ovac-styles'),
++ styles: !!document.getElementById('ovdk-styles') && !!document.getElementById('ovda-styles'),
lineDisplay: q('.ovda-line') ? getComputedStyle(q('.ovda-line')).display : null,
};
})()`);
@@@ -229,12 +229,12 @@ async function main() {
if (seen && seen.line !== 'Ship the Dock') failures.push(`the agent's line is "${seen.line}"`);
if (seen && !/Paused by @ada/.test(seen.meta || '')) failures.push(`the meta does not say who paused it: "${seen.meta}"`);
if (seen && !/2 watching/.test(seen.meta || '')) failures.push(`the meta does not say how many watch: "${seen.meta}"`);
-- const wantActs = ['pause:Resume', 'curtain:Curtain now', 'mute:Unmute', 'end:End'];
-- if (seen && JSON.stringify(seen.acts) !== JSON.stringify(wantActs)) failures.push(`the four controls are ${JSON.stringify(seen.acts)}`);
-- if (seen && seen.actLinks.length !== 4) failures.push(`the read-only controls are not links: ${JSON.stringify(seen.actLinks)}`);
++ const wantActs = ['pause:Resume', 'curtain:Curtain now', 'mute:Unmute', 'end:End', 'float:Float'];
++ if (seen && JSON.stringify(seen.acts) !== JSON.stringify(wantActs)) failures.push(`the controls are ${JSON.stringify(seen.acts)}`);
++ if (seen && seen.actLinks.length !== 4) failures.push(`the four server controls are not read-only links: ${JSON.stringify(seen.actLinks)}`);
if (seen && seen.actLinks.some((href) => href !== 'https://openvibe.codes')) failures.push(`a control does not open Codes: ${JSON.stringify(seen.actLinks)}`);
if (seen && seen.frame !== AGENT.embed_url) failures.push(`the stream frame is "${seen.frame}"`);
-- if (seen && !seen.styles) failures.push("the Dock's own styles are missing");
++ if (seen && !seen.styles) failures.push("the Dock's and agent view's own styles are missing");
if (seen && seen.lineDisplay !== 'flex') failures.push(`the agent's styles did not apply (line display ${seen.lineDisplay})`);
if (security.length) failures.push(`CSP refused: ${security.join(' | ')}`);
if (!paused.some((u) => u.startsWith('https://openvibe.live/embed/agent/'))) failures.push('the panel never asked for the agent embed');