openvibers/openvibe.extensions Commits
The OpenVibe Dock in the side panel (0.7.0) (#10)
Changes
@@@ -75,7 +75,7 @@ the Companion never rewrites links or adds referral tags.| | ||---|---|--| **Permissions** | `activeTab`, `scripting` and `storage`. The host permission is `https://openvibe.coupons/*` only. `scripting` is used for four buttons: "Try these codes on this page" injects the packaged `lib/autoapply.js`, "Save its rating to OpenVibe Reviews" injects `lib/rating.js`, and "Keep on my Rent board" and "Check the rent" inject `lib/listing.js`, into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. |++| **Permissions** | `activeTab`, `scripting`, `storage`, and `sidePanel` in the Chrome build (Firefox's `sidebar_action` needs no permission of its own). The host permissions are the three OpenVibe origins: `https://openvibe.coupons/*` (the coupon API), `https://openvibe.network/*` (the Dock's state) and `https://openvibe.chat/*` (the Dock's Chats tab). `scripting` is used for four buttons: "Try these codes on this page" injects the packaged `lib/autoapply.js`, "Save its rating to OpenVibe Reviews" injects `lib/rating.js`, and "Keep on my Rent board" and "Check the rent" inject `lib/listing.js`, into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. || **Not requested** | `<all_urls>`, `tabs`, `cookies`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. || **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). The page's content only when you press "Try these codes on this page": `lib/autoapply.js` finds the coupon box, its Apply button and the order total, types the codes in, and returns to the popup which codes changed the total. It makes no request, reads no cookies or storage, and sends nothing anywhere; `test/manifest.test.js` scans it for that. On a review site, "Save its rating to OpenVibe Reviews" runs `lib/rating.js`, which only reads: the page's name, its rating, the scale, how many ratings, and the city from its structured data. "Keep on my Rent board" and "Check the rent" run `lib/listing.js`, which only reads: the price and its currency, the city and region (never the street) and the bedrooms, from the structured data, else the page's price tag. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. || **Sent by the extension** | To `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. |@@@ -104,11 +104,28 @@ Because the extension exposes nothing to web pages (no content scripts, nomalicious shop page can't use it to reach anyone's data. The server side of the same guarantee iscovered in Coupons' own tests: lookups are identical for every caller, and the API ignores cookies.++### The Dock in the side panel++++The **OpenVibe Dock** — the pill, the card with its 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, 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 needs the person's own OpenVibe sign-in (the token the Network gives every OpenVibe site). The++Companion had no OpenVibe session of its own — the `cpx_` install token is not one — so the settings++page asks for the OpenVibe token once, the same way it asks for the Coupons one, and the panel says++what is missing instead of pretending. Nothing is sent anywhere but the three OpenVibe origins above,++and the token stays in `storage.local`, on this device.++### Layout```browser/-- manifest.json MV3; Chromium (minimum_chrome_version 111) and Firefox (gecko id, strict_min_version 128)++ 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 (v4.54.0), bundled: the CSP allows no remote scriptpopup.html / popup.js DOM wiring onlyoptions.html / .js connect (save a checked token), disconnect, grant the API host permissionlib/config.js API_ORIGIN (the one origin it sends requests to); HANDOFF_ORIGINS (Rent, Watch, Food, Reviews)@@@ -117,7 +134,8 @@ browser/lib/autoapply.js "Try these codes on this page": injected into the active tab only when you press itlib/rating.js "Save its rating to OpenVibe Reviews": reads a review site's rating, injected only when you press itlib/listing.js "Keep on my Rent board" / "Check the rent": reads a listing's price, city and bedrooms, injected only when you press-- lib/api.js the only code that makes requests++ lib/api.js the only code that makes requests to the Coupons API++ lib/network-api.js the Dock's state on the Network (GET/PUT /api/v1/me/dock), with the person's own tokenlib/controller.js popup logic without the DOM (tested in Node)lib/platform.js chrome.* / browser.*: tabs.query, tabs.create (hand-off pages only), storage.local, permissionslib/format.js wording ("Expiry unknown", "No restrictions stated", …)@@@ -137,7 +155,7 @@ npm test| Requirement | Test ||---|---|--| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, a background worker, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, MV2. The API origin agrees across config, host permission and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js` fetches. | `test/manifest.test.js` |++| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, a background worker, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, MV2. The API origin agrees across config, host permission and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js` and `lib/network-api.js` fetch. | `test/manifest.test.js` || API client against a stub: routes; no `Authorization` without a token; never cookies, referrers or redirects; errors as codes (revoked, scope, 429, 503); timeouts; refused input. | `test/api-client.test.js` || The extension never sends page content or URLs beyond the hostname. The popup controller runs on checkout URLs full of personal data, and every request the stub receives is inspected. Non-shop pages send nothing. Only `tabs.query` and `storage.local` are touched. Only `cpx_` tokens are stored. | `test/privacy.test.js` || The hand-offs: nothing opens when the popup opens; a button opens one tab on openvibe.rent or openvibe.watch with the cleaned address (no credentials, fragment or tracking parameters); the Coupons API still gets the hostname only; `tabs.open` refuses other origins; rental sites lead with the board. | `test/handoff.test.js` |@@@ -5,26 +5,29 @@"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.6.0",++ "version": "0.7.0","surfaces": {"browser": {"name": "OpenVibe Companion",-- "version": "0.6.0",++ "version": "0.7.0","manifest": 3,"targets": [-- "chromium >= 111",++ "chromium >= 114","firefox >= 128"],-- "api": "https://openvibe.coupons (OpenVibe.Coupons public API)",++ "api": "https://openvibe.coupons (OpenVibe.Coupons public API), https://openvibe.network (the Dock's state), https://openvibe.chat (the Dock's Chats tab)","permissions": ["activeTab","scripting",-- "storage"++ "storage",++ "sidePanel (Chrome only; Firefox needs no permission for sidebar_action)"],"hostPermissions": [-- "https://openvibe.coupons/*"++ "https://openvibe.coupons/*",++ "https://openvibe.network/*",++ "https://openvibe.chat/*"],-- "credential": "optional cpx_ install token (coupons.lookup, coupons.report); no Network session",++ "credential": "optional cpx_ install token (coupons.lookup, coupons.report); the person's own OpenVibe token for the Dock in the side panel (opt-in, in the settings); the Coupons token is never an OpenVibe session","publishedToStores": false,"loadedInChrome": "yes: headless Chromium loads the 0.3.0 build (popup and options render, no console errors); lib/autoapply.js passes three fixture checkout pages in headless Chrome","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",@@@ -50,10 +53,11 @@"keeps a rental listing from any site on the OpenVibe.Rent board with the price, city and bedrooms the page states (read only when pressed), and checks its rent against the city median","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 a Network session; only openvibe.coupons as a host permission",++ "an optional cpx_ install token, never an OpenVibe session; and, for the Dock in the side panel, the person's own OpenVibe token (opt-in, in the settings)",++ "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock (v4.54.0) bundled under browser/shared/, reading and writing the same Network state as the Dock on a site, with the same tabs (Agents, Chats, Activity)","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"],-- "updated": "2026-10-23"++ "updated": "2026-10-30"}@@@ -26,6 +26,12 @@FOOD_ORIGIN: 'https://openvibe.food',REVIEWS_ORIGIN: 'https://openvibe.reviews',HANDOFF_ORIGINS: Object.freeze(['https://openvibe.rent', 'https://openvibe.watch', 'https://openvibe.food', 'https://openvibe.reviews']),++ // NETWORK_ORIGIN is where the Dock's state lives (GET/PUT /api/v1/me/dock) and CHAT_ORIGIN where the++ // Dock's Chats tab reads the person's conversations from. Both are OpenVibe's own, and the only++ // origins the side panel may reach besides the API origin.++ NETWORK_ORIGIN: 'https://openvibe.network',++ CHAT_ORIGIN: 'https://openvibe.chat',++ NETWORK_TOKEN_KEY: 'networkToken',MAX_TRIES: 10,CONNECT_PATH: '/connect-extension',TOKEN_KEY: 'installToken',nnew file mode 100644@@@ -0,0 +1,68 @@++/*++ * The OpenVibe.Network client the side panel uses: the person's Dock state (GET and PUT++ * /api/v1/me/dock) with their own sign-in as a Bearer token, and never a cookie.++ *++ * const net = createNetworkClient({ origin, fetch, getToken });++ * await net.dock() → { state, counts }++ * await net.saveDock(p) → { state, counts }++ * await net.check(token) → is this sign-in accepted?++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api;++ else root.OVNetwork = api;++})(typeof self !== 'undefined' ? self : this, function () {++ 'use strict';++++ function createNetworkClient(opts) {++ const origin = String(opts.origin || '').replace(/\/+$/, '');++ if (!/^https:\/\/[a-z0-9.-]+$/.test(origin) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {++ throw new TypeError('origin must be an https origin (http only for localhost development)');++ }++ const doFetch = opts.fetch;++ const getToken = opts.getToken || async function () { return null; };++ const timeoutMs = opts.timeoutMs || 8000;++++ async function request(method, path, body, tokenOverride) {++ const token = tokenOverride === undefined ? await getToken() : tokenOverride;++ const headers = { Accept: 'application/json' };++ if (token) headers.Authorization = 'Bearer ' + token;++ if (body) headers['Content-Type'] = 'application/json';++ const ctrl = typeof AbortController === 'function' ? new AbortController() : null;++ const timer = ctrl ? setTimeout(function () { ctrl.abort(); }, timeoutMs) : null;++ let res;++ try {++ res = await doFetch(origin + path, {++ method: method,++ headers: headers,++ body: body ? JSON.stringify(body) : undefined,++ credentials: 'omit',++ referrerPolicy: 'no-referrer',++ signal: ctrl ? ctrl.signal : undefined,++ });++ } catch (err) {++ throw new Error('could not reach OpenVibe.Network');++ } finally {++ if (timer) clearTimeout(timer);++ }++ if (res.status === 401 || res.status === 403) throw new Error('OpenVibe.Network refused that sign-in');++ if (!res.ok) throw new Error('OpenVibe.Network answered ' + res.status);++ return res.json();++ }++++ return {++ origin: origin,++ /** The person's Dock state, as the Network has it. */++ async dock() { return request('GET', '/api/v1/me/dock'); },++ /** The Dock's state, as the Network keeps it. */++ async saveDock(patch) { return request('PUT', '/api/v1/me/dock', patch); },++ /** Is this sign-in accepted? (the settings page asks before it saves one) */++ async check(token) {++ const res = await request('GET', '/api/v1/me/dock', null, token);++ return !!(res && res.state);++ },++ };++ }++++ return { createNetworkClient: createNetworkClient };++});@@@ -8,6 +8,9 @@* tabs.create needs no permission* storage.getToken/setToken/clearToken the optional cpx_ install token, in storage.local* (never storage.sync: it stays on this device)++ * storage.getNetworkToken/setNetworkToken/clearNetworkToken++ * the person's OpenVibe sign-in for the Dock in the side panel, also in++ * storage.local* permissions.hasApi/requestApi the host permission for the API origin (Firefox* lets people withhold it; reporting needs it)* scripting.tryCodes(tabId, codes) when the person presses "Try these codes on this page": inject@@@ -28,7 +31,7 @@'use strict';function createPlatform(ext, config) {-- const origins = [config.API_ORIGIN + '/*'];++ const origins = [config.API_ORIGIN + '/*', config.NETWORK_ORIGIN + '/*', config.CHAT_ORIGIN + '/*'];const call = function (fn, thisArg, args) {// chrome.* in MV3 and browser.* both return promises.return fn.apply(thisArg, args);@@@ -84,6 +87,21 @@await call(ext.storage.local.set, ext.storage.local, [o]);},async clearToken() { await call(ext.storage.local.remove, ext.storage.local, [config.TOKEN_KEY]); },++ // The person's own OpenVibe sign-in, for the Dock in the side panel. It is the Network's own++ // token (the one every OpenVibe site uses), not a new kind of credential, and it stays on++ // this device like the install token.++ async getNetworkToken() {++ const out = await call(ext.storage.local.get, ext.storage.local, [config.NETWORK_TOKEN_KEY]);++ const t = out && out[config.NETWORK_TOKEN_KEY];++ return typeof t === 'string' && t.length >= 20 ? t : null;++ },++ async setNetworkToken(token) {++ if (typeof token !== 'string' || token.length < 20) throw new TypeError('that is not an OpenVibe.Network token');++ const o = {};++ o[config.NETWORK_TOKEN_KEY] = token;++ await call(ext.storage.local.set, ext.storage.local, [o]);++ },++ async clearNetworkToken() { await call(ext.storage.local.remove, ext.storage.local, [config.NETWORK_TOKEN_KEY]); },},permissions: {async hasApi() {@@@ -2,8 +2,8 @@"manifest_version": 3,"name": "OpenVibe Companion","short_name": "OpenVibe",-- "version": "0.6.0",-- "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; and one click to keep a rental listing on your OpenVibe.Rent board or watch a page. Reads the tab only when you open it.",++ "version": "0.7.0",++ "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; 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","author": "OpenVibers","icons": {@@@ -24,18 +24,24 @@"page": "options.html","open_in_tab": false},++ "sidebar_action": {++ "default_panel": "panel.html",++ "open_in_sidebar": true++ },"permissions": ["activeTab","scripting","storage"],"host_permissions": [-- "https://openvibe.coupons/*"++ "https://openvibe.coupons/*",++ "https://openvibe.network/*",++ "https://openvibe.chat/*"],"content_security_policy": {-- "extension_pages": "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src https://openvibe.coupons; object-src 'none'; base-uri 'none'"++ "extension_pages": "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src https://openvibe.coupons https://openvibe.network https://openvibe.chat; object-src 'none'; base-uri 'none'"},-- "minimum_chrome_version": "111",++ "minimum_chrome_version": "114","browser_specific_settings": {"gecko": {"id": "[email protected]",@@@ -20,7 +20,20 @@</form><p id="state" class="muted" aria-live="polite"></p><p><button id="disconnect" type="button">Disconnect</button> <span class="muted">Removes the token from this browser. Revoke it on openvibe.coupons as well.</span></p>-- <p id="permission-row" hidden><button id="grant" type="button">Allow access to openvibe.coupons</button> <span class="muted">Needed to report; the Companion never asks for access to other sites.</span></p>++ <p id="permission-row" hidden><button id="grant" type="button">Allow access to the OpenVibe sites</button> <span class="muted">Needed to report a code and to read your Dock; the Companion never asks for access to other sites.</span></p>++ <h2>The Dock in the side panel</h2>++ <p>The Dock shows what your agents are doing, what your chats wait for and what just happened, on every site, in the side panel. It is the same Dock, and it keeps the same place.</p>++ <ol>++ <li>Sign in at <a id="network-link" target="_blank" rel="noopener noreferrer" href="https://openvibe.network/my">openvibe.network</a>.</li>++ <li>Paste your OpenVibe token here. It is the one your browser already has for OpenVibe (in the page, <code>localStorage.ov_token</code>); it stays on this device and goes only to openvibe.network.</li>++ </ol>++ <form id="network-form">++ <label for="network-token">OpenVibe token</label>++ <input id="network-token" type="password" autocomplete="off" spellcheck="false" placeholder="eyJ…" size="48">++ <button type="submit">Save</button>++ </form>++ <p id="network-state" class="muted" aria-live="polite"></p>++ <p><button id="network-disconnect" type="button">Disconnect the Dock</button> <span class="muted">Removes the OpenVibe token from this browser.</span></p><h2>What this extension can see</h2><ul><li>The tab you are on, and only when you open the Companion: its address and title.</li>@@@ -31,6 +44,7 @@</main><script src="lib/config.js"></script><script src="lib/api.js"></script>++<script src="lib/network-api.js"></script><script src="lib/platform.js"></script><script src="options.js"></script></body>@@@ -8,7 +8,10 @@const config = self.OVConfig;const platform = self.OVPlatform.createPlatform(ext, config);const api = self.OVApi.createApiClient({ origin: config.API_ORIGIN, fetch: self.fetch.bind(self), getToken: platform.storage.getToken, timeoutMs: config.TIMEOUT_MS });++ const net = self.OVNetwork.createNetworkClient({ origin: config.NETWORK_ORIGIN, fetch: self.fetch.bind(self), getToken: platform.storage.getNetworkToken, timeoutMs: config.TIMEOUT_MS });const state = document.getElementById('state');++ const networkState = document.getElementById('network-state');++ const networkInput = document.getElementById('network-token');const input = document.getElementById('token');const permissionRow = document.getElementById('permission-row');document.getElementById('connect-link').href = config.API_ORIGIN + config.CONNECT_PATH;@@@ -34,6 +37,31 @@}});++ async function refreshNetwork() {++ const token = await platform.storage.getNetworkToken();++ networkState.textContent = token ? 'Connected (the Dock in the side panel works).' : 'Not connected: the side panel asks for your OpenVibe sign-in.';++ }++++ document.getElementById('network-form').addEventListener('submit', async function (ev) {++ ev.preventDefault();++ const token = networkInput.value.trim();++ if (token.length < 20) { networkState.textContent = 'That does not look like an OpenVibe token.'; return; }++ networkState.textContent = 'Checking…';++ try {++ if (!(await net.check(token))) { networkState.textContent = 'openvibe.network did not accept that token.'; return; }++ await platform.storage.setNetworkToken(token);++ networkInput.value = '';++ await refreshNetwork();++ } catch (e) {++ networkState.textContent = 'Could not reach openvibe.network. The token was not saved.';++ }++ });++++ document.getElementById('network-disconnect').addEventListener('click', async function () {++ await platform.storage.clearNetworkToken();++ await refreshNetwork();++ });++document.getElementById('disconnect').addEventListener('click', async function () {await platform.storage.clearToken();await refresh();@@@ -45,4 +73,5 @@});refresh();++ refreshNetwork();})();nnew file mode 100644@@@ -0,0 +1,79 @@++/*++ * The Dock in the side panel: the browser's panel is the frame, so the Dock fills it (no floating++ * card, no corners, no pill) and keeps the look it has on a site — the same components, the same++ * tokens, the same tabs.++ */++++:root {++ /* The Network's default dark theme (the "vibe" theme of the OpenVibe Kit). */++ --bg-primary: #0a0f1c;++ --bg-secondary: #101828;++ --bg-tertiary: #172238;++ --bg-card: #131c2e;++ --bg-hover: #1c2a44;++ --bg-input: #0d1424;++ --text-primary: #e6edf7;++ --text-secondary: #96a7c2;++ --text-muted: #8192ac;++ --accent: #3b82f6;++ --accent-light: #60a5fa;++ --accent-dark: #1d4ed8;++ --accent-strong: #3472d8;++ --on-accent: #ffffff;++ --on-accent-strong: #ffffff;++ --border: #1f2d47;++ --border-light: #2c3d5c;++ --live-red: #ef4444;++ --success: #22c55e;++ --warning: #f59e0b;++ --danger: #ef4444;++ --info: #38bdf8;++}++++@media (prefers-color-scheme: light) {++ :root {++ --bg-primary: #f5f7fc;++ --bg-secondary: #eaeff8;++ --bg-tertiary: #e2e9f4;++ --bg-card: #ffffff;++ --bg-hover: #e9eff9;++ --bg-input: #f1f5fb;++ --text-primary: #111827;++ --text-secondary: #3f4a5f;++ --text-muted: #5b6779;++ --accent: #2563eb;++ --accent-light: #60a5fa;++ --accent-dark: #1d4ed8;++ --accent-strong: #3472d8;++ --border: #d9e0ec;++ --border-light: #e7ecf4;++ --live-red: #e5484d;++ --success: #16a34a;++ --warning: #d97706;++ --danger: #dc2626;++ --info: #0284c7;++ }++}++++html, body { margin: 0; height: 100%; background: var(--bg-primary); color: var(--text-primary); font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }++body.panel { overflow: hidden; }++++/* The Dock, full bleed: the panel is the frame. */++.ovdk, .ovdk[data-corner] {++ position: static !important;++ inset: auto !important;++ left: auto !important; top: auto !important; right: auto !important; bottom: auto !important;++ width: 100% !important; height: 100% !important;++ max-width: none !important; max-height: none !important;++ border-radius: 0 !important; border-bottom: 0 !important;++ box-shadow: none !important; padding-bottom: 0 !important;++}++.ovdk-pill { display: none !important; }++.ovdk-size { display: none !important; }++/* The panel is the browser's: it has no window of its own to pop out into, and nothing to close. */++.ovdk [data-act="pop"], .ovdk [data-act="close"] { display: none !important; }++++#gate { padding: 20px; }++#gate h1 { font-size: 16px; margin: 0 0 8px; }++#gate p { margin: 0 0 12px; color: var(--text-secondary); }++#gate button { font: inherit; padding: 8px 14px; border-radius: 10px; border: 1px solid var(--border); background: var(--accent-strong); color: var(--on-accent-strong); cursor: pointer; }nnew file mode 100644@@@ -0,0 +1,23 @@++<!DOCTYPE html>++<html lang="en">++<head>++<meta charset="utf-8">++<title>OpenVibe Dock</title>++<link rel="stylesheet" href="shared/kit.css">++<link rel="stylesheet" href="panel.css">++</head>++<body class="panel">++<main id="dock" aria-label="OpenVibe Dock"></main>++<section id="gate" hidden>++ <h1 id="gate-title">Sign in to OpenVibe.Network</h1>++ <p id="gate-text">The Dock shows what your agents are doing and what your chats wait for.</p>++ <button id="gate-button" type="button" hidden>Open the settings</button>++</section>++<script src="lib/config.js"></script>++<script src="lib/platform.js"></script>++<script src="lib/network-api.js"></script>++<script src="shared/ov-mark.js"></script>++<script src="shared/dock.js"></script>++<script src="panel.js"></script>++</body>++</html>nnew file mode 100644@@@ -0,0 +1,95 @@++/*++ * The Dock in the side panel: the same Dock every OpenVibe site shows, in the browser's own side++ * panel (Chrome's side panel, Firefox's sidebar), on any site.++ *++ * The Dock is the released OpenVibe.Shared one, bundled under browser/shared/ because the++ * extension's CSP allows no remote script. The panel gives it the Network origin and the++ * person's Network sign-in, so it reads and writes the same state as the Dock on a site: the++ * same tabs (Agents, Chats, Activity) and the same place.++ *++ * The extension had no Network sign-in of its own (it holds the Coupons install token, which is++ * not an OpenVibe sign-in), so the settings page asks for the person's Network token once, the++ * same way it asks for the Coupons one. Without it the panel says so instead of pretending.++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api;++ else { root.OVPanel = api; if (root.document && root.OpenVibeDock) api.boot(root); }++})(typeof self !== 'undefined' ? self : this, function () {++ 'use strict';++++ function createPanel(opts) {++ const doc = opts.doc;++ const gate = doc.getElementById('gate');++ const gateTitle = doc.getElementById('gate-title');++ const gateText = doc.getElementById('gate-text');++ const gateButton = doc.getElementById('gate-button');++++ function showGate(title, text, button) {++ gateTitle.textContent = title;++ gateText.textContent = text;++ gateButton.textContent = button || '';++ gateButton.hidden = !button;++ gate.hidden = false;++ }++++ /** The Dock, with the Network's state, or the gate that says what is missing. */++ async function start() {++ const token = await opts.platform.storage.getNetworkToken();++ if (!token) {++ showGate('Sign in to OpenVibe.Network',++ 'The Dock shows what your agents are doing and what your chats wait for. It needs your OpenVibe.Network sign-in.',++ 'Open the settings');++ return { mounted: false, reason: 'no-token' };++ }++ try {++ await opts.net.dock();++ } catch (err) {++ showGate('OpenVibe.Network said no',++ 'That sign-in was not accepted. Sign in to OpenVibe.Network again and paste a fresh token in the settings.',++ 'Open the settings');++ return { mounted: false, reason: 'refused' };++ }++ opts.dock.mount({++ token: token,++ apiBase: opts.config.NETWORK_ORIGIN,++ chatBase: opts.config.CHAT_ORIGIN,++ sibling: opts.sibling,++ });++ opts.dock.open();++ gate.hidden = true;++ return { mounted: true, reason: null };++ }++++ if (gateButton && typeof gateButton.addEventListener === 'function') {++ gateButton.addEventListener('click', function () { if (opts.openSettings) opts.openSettings(); });++ }++ return { start: start, showGate: showGate };++ }++++ /** The page wiring: the real browser APIs and the real Dock. */++ function boot(root) {++ const ext = root.browser || root.chrome;++ const config = root.OVConfig;++ const platform = root.OVPlatform.createPlatform(ext, config);++ const net = root.OVNetwork.createNetworkClient({++ origin: config.NETWORK_ORIGIN,++ fetch: self.fetch.bind(self),++ getToken: platform.storage.getNetworkToken,++ timeoutMs: config.TIMEOUT_MS,++ });++ const panel = createPanel({++ doc: root.document,++ config: config,++ platform: platform,++ net: net,++ dock: root.OpenVibeDock,++ sibling: function (name) { return ext.runtime.getURL('shared/' + name); },++ openSettings: function () { ext.runtime.openOptionsPage(); },++ });++ panel.start();++ return panel;++ }++++ return { createPanel: createPanel, boot: boot };++});nnew file mode 100644@@@ -0,0 +1,383 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — the Action Center (✦), every agent task in one place (plan 2026-10-28 §3.10; Shared 4.31.0)++//++// notification-ui.js draws the ✦ beside the bell and loads this file, from beside itself, on the first click.++// OpenVibeActionCenter.toggle({ anchor, token: () => jwt, apiBase }) · open(opts) · close() · isOpen()++// OpenVibeActionCenter.mount(container, { token, apiBase, buckets, onCounts }) → { refresh(), destroy() }++// the Dock's Agent tab (dock.js): the items of some buckets in a container of the Dock's, waiting first, the++// same rows and the same approve and decline, re-read every 15 s while something runs.++//++// Reads GET <apiBase>/api/v1/me/actions network.me-actions-result@1 (Bearer, or the cookie on++// openvibe.network itself)++// Writes POST <apiBase>/api/v1/me/actions/:service/:id/approve|decline++//++// Five tabs: Running (live: re-read every 15 s while open and something runs), Waiting for you (approve or decline++// here), Scheduled, Done (a receipt: cost, steps, the page), Failed (why, and open it to try again). A service that did++// not answer is named under the tabs; the rest still show. Desktop: a 420 px popover under the bar. Phones: a full-height++// sheet with a handle (drag down, ×, or a tap outside closes it). Opaque, on the theme's raised surface, and while it is++// open toasts wait (aria-modal: notification-ui.js holds them).++// ═══════════════════════════════════════════════════════════════++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OpenVibeActionCenter) return;++++ const BUCKETS = [['running', 'Running'], ['waiting', 'Waiting for you'], ['scheduled', 'Scheduled'], ['done', 'Done'], ['failed', 'Failed']];++ const SHORT = { running: 'Running', waiting: 'Waiting', scheduled: 'Scheduled', done: 'Done', failed: 'Failed' };++ const EMPTY = {++ running: ['Nothing running', 'When an agent is working for you, its steps show here as they happen.'],++ waiting: ['Nothing needs you', 'When an agent asks before it spends, sends or deletes, you approve it here.'],++ scheduled: ['Nothing scheduled', 'Agents you set to run later, like a morning brief, show here.'],++ done: ['Nothing finished yet', 'Finished work shows here with what it cost and where it is.'],++ failed: ['Nothing failed', 'If a task fails, it shows here with why, so you can try again.'],++ };++ const SERVICE = { actor: 'Actor', codes: 'Codes', ai: 'AI', network: 'Network', run: 'Run' };++ const REFRESH_MS = 15000;++ const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;++ const G = {++ spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',++ code: '<path d="M8.5 7.5L4 12l4.5 4.5M15.5 7.5L20 12l-4.5 4.5M13.5 5.5l-3 13"/>',++ ai: '<rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/><path d="M9.5 3.5v3M14.5 3.5v3M9.5 17.5v3M14.5 17.5v3M3.5 9.5h3M3.5 14.5h3M17.5 9.5h3M17.5 14.5h3"/>',++ 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"/>',++ desk: '<rect x="3.5" y="4.5" width="17" height="11" rx="2"/><path d="M9 19.5h6M12 15.5v4"/>',++ refresh: '<path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4"/>',++ close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',++ 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"/>',++ };++ const KIND_GLYPH = { agent_task: 'spark', code_task: 'code', ai_run: 'ai', approval: 'key', desk_session: 'desk' };++ const TONE = { running: 'var(--accent, #3b82f6)', waiting: 'var(--warning, #f5a524)', scheduled: 'var(--text-secondary, #a0a0ab)', done: 'var(--success, #30a46c)', failed: 'var(--live-red, #e5484d)' };++++ let el = null, opts = null, data = null, tab = null, timer = null, loading = false, seq = 0;++++ function esc(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }++ const isPhone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };++ function apiWith(o, path, init = {}) {++ const base = String((o && o.apiBase) || 'https://openvibe.network').replace(/\/+$/, '');++ let cross = true; try { cross = new URL(base).origin !== root.location.origin; } catch { /* */ }++ const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}) };++ const t = o && (typeof o.token === 'function' ? o.token() : o.token);++ if (t) headers.authorization = `Bearer ${t}`;++ return fetch(base + path, { ...init, headers, credentials: cross ? 'omit' : 'include' }).then(async (r) => {++ const body = await r.json().catch(() => ({}));++ if (!r.ok) { const e = new Error(body.detail || body.title || `HTTP ${r.status}`); e.status = r.status; throw e; }++ return body;++ });++ }++ const api = (path, init) => apiWith(opts, path, init);++ function ago(iso) {++ const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return '';++ const s = (Date.now() - t) / 1000;++ if (s < 0) { const m = -s / 60; return m < 60 ? `in ${Math.max(1, Math.round(m))} min` : m < 1440 ? `in ${Math.round(m / 60)} h` : `in ${Math.round(m / 1440)} d`; }++ if (s < 45) return 'just now';++ if (s < 3600) return `${Math.max(1, Math.floor(s / 60))} min ago`;++ if (s < 86400) return `${Math.floor(s / 3600)} h ago`;++ return `${Math.floor(s / 86400)} d ago`;++ }++ const money = (v) => (typeof v === 'number' && v > 0 ? (v < 0.01 ? `$${v.toFixed(4).replace(/0+$/, '')}` : `$${v.toFixed(2)}`) : null);++++ function styles() {++ if (document.getElementById('ovac-styles')) return;++ const st = document.createElement('style');++ st.id = 'ovac-styles';++ st.textContent = `++ @keyframes ovac-in { from { transform: translateY(-8px); opacity: 0; } to { transform: none; opacity: 1; } }++ @keyframes ovac-up { from { transform: translateY(48px); opacity: .5; } to { transform: none; opacity: 1; } }++ @keyframes ovac-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }++ .ovac { position: fixed; z-index: 99999; display: none; flex-direction: column; overflow: hidden; top: var(--ovac-top, 62px); right: max(8px, env(safe-area-inset-right, 0px));++ width: min(420px, calc(100vw - 16px)); max-height: calc(100vh - var(--ovac-top, 62px) - 12px); max-height: calc(100dvh - var(--ovac-top, 62px) - 12px);++ background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));++ border-radius: 18px; box-shadow: 0 24px 60px rgba(0,0,0,.45); font-family: inherit; animation: ovac-in .2s cubic-bezier(.2,.8,.2,1); }++ .ovac.open { display: flex; }++ .ovac svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }++ .ovac-head { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 14px 10px 8px 16px; }++ .ovac-head h3 { margin: 0; display: flex; align-items: center; gap: 8px; font-size: 17px; font-weight: 750; letter-spacing: -.01em; }++ .ovac-head h3 svg { width: 19px; height: 19px; color: var(--accent-light, var(--accent, #60a5fa)); }++ .ovac-acts { display: flex; gap: 2px; }++ .ovac-acts button { width: 36px; height: 36px; border: 0; border-radius: 10px; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; }++ .ovac-acts button:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-primary, #ececf1); }++ .ovac-acts button svg { width: 18px; height: 18px; }++ .ovac-acts button.spin svg { animation: ovac-spin .8s linear infinite; }++ @keyframes ovac-spin { to { transform: rotate(360deg); } }++ .ovac-acts [data-act="close"] { display: none; }++ .ovac-tabs { display: flex; overflow-x: auto; scrollbar-width: none; padding: 0 6px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }++ .ovac-tabs::-webkit-scrollbar { display: none; }++ .ovac-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; }++ .ovac-tabs button:hover, .ovac-tabs button[aria-selected="true"] { color: var(--text-primary, #ececf1); }++ .ovac-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); }++ .ovac-tabs button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -3px; border-radius: 10px; }++ .ovac-tabs .n { min-width: 18px; padding: 2px 5px; box-sizing: border-box; border-radius: 999px; font-size: 10.5px; font-weight: 750; line-height: 1.2; text-align: center; font-variant-numeric: tabular-nums;++ color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--accent, #3b82f6) 24%, transparent); }++ .ovac-tabs [data-tab="waiting"] .n { background: color-mix(in srgb, var(--warning, #f5a524) 30%, transparent); }++ .ovac-tabs .n:empty { display: none; }++ .ovac-note { margin: 8px 12px 0; padding: 8px 12px; border-radius: 12px; font-size: 12.5px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); background: color-mix(in srgb, var(--warning, #f5a524) 12%, transparent); }++ .ovac-list { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 6px 6px 10px; }++ .ovac-item { display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; padding: 10px 10px 10px 8px; border-radius: 14px; }++ .ovac-item:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 4%, transparent); }++ .ovac-glyph { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; color: color-mix(in srgb, var(--ovac-tone) 68%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovac-tone) 15%, transparent); }++ .ovac-glyph svg { width: 20px; height: 20px; }++ .ovac-body { min-width: 0; }++ .ovac-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 13.5px; font-weight: 650; line-height: 1.32; color: inherit; text-decoration: none; }++ .ovac-title:hover { text-decoration: underline; text-underline-offset: 2px; }++ .ovac-sum { margin: 2px 0 0; font-size: 12.5px; line-height: 1.42; color: var(--text-secondary, #b6b6c0); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }++ .ovac-item[data-bucket="failed"] .ovac-sum { color: color-mix(in srgb, var(--live-red, #e5484d) 55%, var(--text-primary, #ececf1)); }++ .ovac-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-top: 6px; font-size: 11.5px; color: var(--text-muted, #8b8b96); }++ .ovac-state { display: inline-flex; align-items: center; gap: 5px; font-weight: 700; color: color-mix(in srgb, var(--ovac-tone) 55%, var(--text-primary, #ececf1)); }++ .ovac-state::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--ovac-tone); }++ .ovac-item[data-bucket="running"] .ovac-state::before { animation: ovac-pulse 1.4s ease-in-out infinite; }++ .ovac-cost { font-variant-numeric: tabular-nums; font-weight: 650; color: var(--text-secondary, #b6b6c0); }++ .ovac-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }++ .ovac-btns button, .ovac-btns a { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 0 14px; border-radius: 999px; font: inherit; font-size: 12.5px; font-weight: 700; cursor: pointer; text-decoration: none;++ border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); }++ .ovac-btns .primary { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, var(--on-accent, #fff)); }++ .ovac-btns button:disabled { opacity: .55; cursor: default; }++ .ovac-btns svg { width: 14px; height: 14px; }++ .ovac-err { margin: 6px 0 0; font-size: 12px; color: color-mix(in srgb, var(--live-red, #e5484d) 60%, var(--text-primary, #ececf1)); }++ .ovac-empty { text-align: center; padding: 44px 24px 52px; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }++ .ovac-empty svg { width: 46px; height: 46px; display: block; margin: 0 auto 12px; color: var(--accent, #3b82f6); opacity: .75; stroke-width: 1.4; }++ .ovac-empty b { display: block; margin-bottom: 4px; font-size: 15px; color: var(--text-primary, #ececf1); }++ .ovac-wait { display: flex; justify-content: center; padding: 28px; }++ .ovac-wait span { width: 20px; height: 20px; border-radius: 50%; border: 2px solid var(--border, rgba(255,255,255,.12)); border-top-color: var(--accent, #3b82f6); animation: ovac-spin .8s linear infinite; }++ .ovac-foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #8b8b96); }++ .ovac-foot a { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; text-decoration: none; }++ @media (max-width: 640px) {++ .ovac { top: calc(env(safe-area-inset-top, 0px) + 10px); left: 0; right: 0; bottom: 0; width: auto; max-height: none; border-radius: 22px 22px 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); animation-name: ovac-up; animation-duration: .28s; }++ .ovac-head { padding-top: 20px; touch-action: none; }++ .ovac-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))); }++ .ovac-acts [data-act="close"] { display: grid; }++ .ovac-acts button { width: 40px; height: 40px; }++ }++ html.ovac-sheet-open, html.ovac-sheet-open body { overflow: hidden; }++ .ovac-mounted { display: flex; flex-direction: column; min-height: 0; color: var(--text-primary, #ececf1); }++ .ovac-mounted .ovac-list { padding: 4px 6px 10px; }++ .ovac-mounted svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }++ .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);++ }++++ function build() {++ styles();++ el = document.createElement('div');++ el.className = 'ovac';++ el.setAttribute('role', 'dialog');++ el.setAttribute('aria-label', 'Agents');++ el.hidden = true;++ el.innerHTML = `++ <div class="ovac-head"><h3>${svg(G.spark)}Agents</h3>++ <div class="ovac-acts"><button type="button" data-act="refresh" title="Refresh" aria-label="Refresh">${svg(G.refresh)}</button><button type="button" data-act="close" title="Close" aria-label="Close">${svg(G.close)}</button></div></div>++ <div class="ovac-tabs" role="tablist" aria-label="Agents">${BUCKETS.map(([id]) => `<button type="button" role="tab" data-tab="${id}" aria-selected="false" tabindex="-1">${SHORT[id]}<span class="n" data-n="${id}"></span></button>`).join('')}</div>++ <div class="ovac-note" hidden></div>++ <div class="ovac-list" role="tabpanel" aria-live="polite"></div>++ <div class="ovac-foot"><span data-role="when"></span><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;++ document.body.appendChild(el);++ const tabs = [...el.querySelectorAll('[role="tab"]')];++ const pick = (b, focus) => { setTab(b.dataset.tab); if (focus) b.focus(); };++ tabs.forEach((b) => b.addEventListener('click', () => pick(b)));++ el.querySelector('.ovac-tabs').addEventListener('keydown', (e) => {++ const i = tabs.indexOf(document.activeElement); if (i < 0) return;++ const j = e.key === 'ArrowRight' ? (i + 1) % tabs.length : e.key === 'ArrowLeft' ? (i - 1 + tabs.length) % tabs.length : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : -1;++ if (j >= 0) { e.preventDefault(); pick(tabs[j], true); }++ });++ el.querySelector('[data-act="refresh"]').addEventListener('click', () => load());++ el.querySelector('[data-act="close"]').addEventListener('click', () => close());++ el.querySelector('.ovac-list').addEventListener('click', (e) => {++ const b = e.target.closest('button[data-do]'); if (!b) return;++ decide(b.closest('.ovac-item'), b.dataset.do, b);++ });++ document.addEventListener('click', (e) => { if (isOpen() && !el.contains(e.target) && !(opts && opts.anchor && opts.anchor.contains(e.target))) close(); });++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isOpen()) close(); });++ // Phones: the sheet's head drags down to close.++ const head = el.querySelector('.ovac-head');++ let y0 = null, dy = 0;++ head.addEventListener('pointerdown', (e) => { if (!isPhone() || e.pointerType === 'mouse' || e.target.closest('button')) return; y0 = e.clientY; dy = 0; el.style.transition = 'none'; });++ head.addEventListener('pointermove', (e) => { if (y0 == null) return; dy = Math.max(0, e.clientY - y0); el.style.transform = `translateY(${dy}px)`; });++ const end = () => { if (y0 == null) return; y0 = null; el.style.transition = 'transform .2s ease'; if (dy > 110) close(); else el.style.transform = ''; setTimeout(() => { el.style.transition = ''; }, 220); };++ head.addEventListener('pointerup', end); head.addEventListener('pointercancel', end);++ if (root.OpenVibePanels) { try { root.OpenVibePanels.register({ el, openClass: 'open', id: 'action-center', close: () => close() }); } catch { /* */ } }++ }++++ function setTab(id) {++ tab = id;++ el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === id; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });++ el.querySelector('.ovac-list').setAttribute('aria-label', (BUCKETS.find(([b]) => b === id) || ['', ''])[1]);++ paint();++ }++++ function itemHTML(it) {++ const tone = TONE[it.bucket] || TONE.running;++ const meta = [++ `<span class="ovac-state">${esc(it.bucket === 'waiting' ? 'Needs you' : SHORT[it.bucket] || it.state)}</span>`,++ `<span>${esc([SERVICE[it.service] || it.service, it.agent].filter(Boolean).join(' · '))}</span>`,++ money(it.cost_usd) ? `<span class="ovac-cost">${money(it.cost_usd)}</span>` : '',++ typeof it.steps === 'number' && it.steps > 0 ? `<span>${it.steps} step${it.steps === 1 ? '' : 's'}</span>` : '',++ `<span>${esc(it.bucket === 'scheduled' && it.next_run_at ? `runs ${ago(it.next_run_at)}` : ago(it.updated_at))}</span>`,++ ].join('');++ const decides = it.bucket === 'waiting' && it.actions.includes('approve');++ const btns = decides++ ? `<div class="ovac-btns"><button type="button" class="primary" data-do="approve">Approve</button><button type="button" data-do="decline">Decline</button><a href="${esc(it.href)}">${svg(G.open)}Details</a></div>`++ : it.bucket === 'failed' && it.actions.includes('retry') ? `<div class="ovac-btns"><a href="${esc(it.href)}">${svg(G.refresh)}Try again</a></div>` : '';++ const text = it.bucket === 'failed' && it.error ? it.error : it.summary;++ return `<article class="ovac-item" data-bucket="${esc(it.bucket)}" data-service="${esc(it.service)}" data-id="${esc(it.id)}" style="--ovac-tone:${tone}">++ <span class="ovac-glyph">${svg(G[KIND_GLYPH[it.kind]] || G.spark)}</span>++ <div class="ovac-body"><a class="ovac-title" href="${esc(it.href)}">${esc(it.title)}</a>${text ? `<p class="ovac-sum">${esc(text)}</p>` : ''}++ <div class="ovac-meta">${meta}</div>${btns}</div></article>`;++ }++++ function paint() {++ if (!el) return;++ const list = el.querySelector('.ovac-list');++ const note = el.querySelector('.ovac-note');++ if (!data) { list.innerHTML = '<div class="ovac-wait"><span></span></div>'; note.hidden = true; return; }++ if (data.error) {++ list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${data.error.status === 401 ? 'Sign in to see your agents' : 'Could not load your agents'}</b>${data.error.status === 401 ? '' : 'Check your connection and try again.'}</div>`;++ note.hidden = true;++ return;++ }++ for (const [id] of BUCKETS) {++ const n = data.counts[id] || 0;++ const span = el.querySelector(`[data-n="${id}"]`);++ span.textContent = n ? (n > 99 ? '99+' : String(n)) : '';++ span.parentNode.setAttribute('aria-label', n ? `${SHORT[id]}, ${n}` : SHORT[id]);++ }++ const missing = Object.entries(data.sources || {}).filter(([, s]) => s === 'timeout' || s === 'unavailable').map(([k]) => SERVICE[k] || k);++ note.hidden = !missing.length;++ note.textContent = missing.length ? `${missing.join(' and ')} did not answer, so some items may be missing. Refresh to try again.` : '';++ const items = data.buckets[tab] || [];++ if (!items.length) {++ const [t, s] = EMPTY[tab] || EMPTY.running;++ list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${esc(t)}</b>${esc(s)}</div>`;++ } else list.innerHTML = items.map(itemHTML).join('');++ const when = el.querySelector('[data-role="when"]');++ when.textContent = data.generated_at ? `Updated ${ago(data.generated_at)}` : '';++ }++++ async function load({ quiet = false } = {}) {++ if (loading) return;++ loading = true;++ const my = ++seq;++ const btn = el && el.querySelector('[data-act="refresh"]');++ if (btn) btn.classList.add('spin');++ if (!quiet && !data) paint();++ try {++ const doc = await api('/api/v1/me/actions');++ if (my !== seq) return;++ data = doc;++ // First open: the tab with something in it, waiting first.++ if (!tab) tab = ['waiting', 'running', 'scheduled', 'done', 'failed'].find((b) => (doc.counts[b] || 0) > 0) || 'running';++ setTab(tab);++ } catch (e) {++ if (my !== seq) return;++ data = { error: e };++ if (!tab) setTab('running'); else paint();++ } finally {++ loading = false;++ if (btn) btn.classList.remove('spin');++ schedule();++ }++ }++ function schedule() {++ clearTimeout(timer);++ if (isOpen() && data && !data.error && (data.counts.running || 0) > 0) timer = setTimeout(() => load({ quiet: true }), REFRESH_MS);++ }++++ async function decide(item, what, btn, call = api, reload = () => load({ quiet: true })) {++ if (!item || (what !== 'approve' && what !== 'decline')) return;++ item.querySelectorAll('.ovac-btns button').forEach((b) => { b.disabled = true; });++ const old = item.querySelector('.ovac-err'); if (old) old.remove();++ try {++ await call(`/api/v1/me/actions/${encodeURIComponent(item.dataset.service)}/${encodeURIComponent(item.dataset.id)}/${what}`, { method: 'POST', body: '{}' });++ btn.textContent = what === 'approve' ? 'Approved' : 'Declined';++ setTimeout(reload, 600);++ } catch (e) {++ item.querySelectorAll('.ovac-btns button').forEach((b) => { b.disabled = false; });++ item.querySelector('.ovac-body').insertAdjacentHTML('beforeend', `<p class="ovac-err" role="alert">${esc(e.message || 'That did not work. Try again.')}</p>`);++ }++ }++++ function isOpen() { return !!(el && el.classList.contains('open')); }++ function open(o) {++ opts = { ...(opts || {}), ...(o || {}) };++ if (!el) build();++ if (root.OpenVibePanels) { try { root.OpenVibePanels.closeAll(el); } catch { /* */ } }++ let bottom = 56;++ try {++ const bar = opts.anchor && opts.anchor.closest && opts.anchor.closest('nav, header, .navbar');++ bottom = (bar || opts.anchor || null) ? (bar || opts.anchor).getBoundingClientRect().bottom : 56;++ } catch { /* no layout: under a 56 px bar */ }++ el.style.setProperty('--ovac-top', `${Math.max(8, Math.round(bottom) + 6)}px`);++ el.hidden = false;++ el.setAttribute('aria-modal', 'true');++ el.classList.add('open');++ el.style.transform = '';++ document.documentElement.classList.toggle('ovac-sheet-open', isPhone());++ if (opts.anchor) { opts.anchor.setAttribute('aria-expanded', 'true'); opts.anchor.classList.add('open'); }++ load();++ setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]')?.focus({ preventScroll: true }); } catch { /* */ } }, 0);++ }++ function close() {++ if (!el) return;++ const inside = el.contains(document.activeElement);++ el.classList.remove('open');++ el.removeAttribute('aria-modal');++ el.hidden = true;++ el.style.transform = '';++ clearTimeout(timer);++ document.documentElement.classList.remove('ovac-sheet-open');++ if (opts && opts.anchor) { opts.anchor.setAttribute('aria-expanded', 'false'); opts.anchor.classList.remove('open'); if (inside) { try { opts.anchor.focus({ preventScroll: true }); } catch { /* */ } } }++ }++ function toggle(o) { if (isOpen()) close(); else open(o); }++++ /**++ * A list of some buckets in someone else's container (the Dock's Agent tab): waiting first, then running, each++ * bucket under a small heading when there is more than one. Its own data, timer and errors; the popover's are++ * untouched. onCounts(counts) hears every answer, so the Dock's tab and pill can follow it.++ */++ function mount(container, o = {}) {++ styles();++ const mo = { ...o };++ const want = Array.isArray(mo.buckets) && mo.buckets.length ? mo.buckets.filter((b) => SHORT[b]) : ['waiting', 'running'];++ const call = (path, init) => apiWith(mo, path, init);++ let doc = null, t = 0, gone = false, busy = false;++ container.classList.add('ovac-mounted');++ container.innerHTML = '<div class="ovac-list" aria-live="polite"><div class="ovac-wait"><span></span></div></div>';++ const list = container.firstChild;++ const draw = () => {++ if (gone) return;++ if (doc && doc.error) {++ list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${doc.error.status === 401 ? 'Sign in to see your agents' : 'Could not load your agents'}</b>${doc.error.status === 401 ? '' : 'Check your connection and try again.'}</div>`;++ return;++ }++ if (!doc) return;++ const groups = want.map((b) => [b, (doc.buckets && doc.buckets[b]) || []]).filter(([, items]) => items.length);++ if (!groups.length) {++ const [h, p] = mo.empty || ['Nothing is running', 'When an agent works for you, it shows here with its steps, and anything it asks you shows first.'];++ list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${esc(h)}</b>${esc(p)}</div>`;++ return;++ }++ list.innerHTML = groups.map(([b, items]) => (groups.length > 1 ? `<h4 class="ovac-group" data-bucket="${b}">${esc(b === 'waiting' ? 'Needs you' : SHORT[b])}<span>${items.length}</span></h4>` : '')++ + items.map(itemHTML).join('')).join('');++ };++ const refresh = async () => {++ if (gone || busy) return;++ busy = true; clearTimeout(t);++ try { doc = await call('/api/v1/me/actions'); } catch (e) { doc = { error: e }; }++ busy = false;++ if (gone) return;++ draw();++ if (doc && !doc.error && typeof mo.onCounts === 'function') { try { mo.onCounts(doc.counts || {}); } catch { /* */ } }++ if (doc && !doc.error && (doc.counts.running || 0) > 0) t = setTimeout(refresh, REFRESH_MS);++ };++ list.addEventListener('click', (e) => {++ const b = e.target.closest('button[data-do]'); if (!b) return;++ decide(b.closest('.ovac-item'), b.dataset.do, b, call, refresh);++ });++ refresh();++ return { refresh, destroy() { gone = true; clearTimeout(t); container.classList.remove('ovac-mounted'); container.innerHTML = ''; } };++ }++++ root.OpenVibeActionCenter = { open, close, toggle, isOpen, mount, _state: () => ({ data, tab }) };++})(typeof window !== 'undefined' ? window : globalThis);nnew file mode 100644@@@ -0,0 +1,799 @@++// ═══════════════════════════════════════════════════════════════++// 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), 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). The agent's live screen is K-D3.++// 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.++// ═══════════════════════════════════════════════════════════════++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OpenVibeDock) return;++++ const W = [320, 720], H = [360, 900], DEF = { width: 400, height: 560 };++ const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];++ const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;++ const G = {++ spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',++ bell: '<path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2h-14z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>',++ 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"/>',++ 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"/>',++ };++ const TABS = [['agent', 'Agents', G.spark], ['chats', 'Chats', G.chat], ['activity', 'Activity', G.bell]];++ 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;++ // 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; } };++ 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}`);++++ function call(path, init = {}) {++ const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');++ const t = typeof o.token === 'function' ? o.token() : o.token;++ const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };++ return fetch(base + path, { ...init, headers, credentials: 'omit' }).then((r) => (r.ok ? r.json() : null));++ }++ const notify = () => { store(doc); paintPill(); };++++ /** A change to the state: painted now, sent to the Network a beat later (several changes go as one PUT). */++ function save(patch) {++ const st = state();++ const next = { ...st, ...patch, place: patch.place ? { ...st.place, ...patch.place } : st.place };++ doc = { ...(doc || {}), state: next, counts: counts() };++ pending = { ...pending, ...patch, ...(patch.place ? { place: { ...(pending.place || {}), ...patch.place } } : {}) };++ notify();++ clearTimeout(saveT);++ saveT = setTimeout(flush, 700);++ }++ async function flush() {++ const body = pending; pending = {};++ if (!Object.keys(body).length) return;++ try {++ let r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(body) });++ // A Network whose state contract does not know "popped" yet refuses the whole body; ask again without it,++ // so the Dock's place and its tab still reach the Network.++ if (!r && 'popped' in body) {++ const rest = { ...body }; delete rest.popped;++ r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(rest) });++ }++ if (r && r.state && !Object.keys(pending).length) { doc = { ...doc, state: r.state, counts: r.counts || counts() }; notify(); badges(); }++ } catch { /* offline, or no route yet: the copy stands and the next change tries again */ }++ }++++ 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); }++ sc.addEventListener('load', () => ok(test()));++ sc.addEventListener('error', () => ok(false));++ });++ }++++ function styles() {++ if (document.getElementById('ovdk-styles')) return;++ 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));++ 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; }++ .ovdk-pill[data-corner$="left"] { right: auto; left: max(16px, env(safe-area-inset-left, 0px)); }++ .ovdk-pill[data-corner^="top"] { bottom: auto; top: calc(66px + env(safe-area-inset-top, 0px)); }++ .ovdk-pill[data-tone="waiting"] { --t: var(--warning, #f5a524); }++ .ovdk-pill:hover { border-color: color-mix(in srgb, var(--t) 45%, var(--border, #333340)); }++ .ovdk-pill:active { scale: .96; }++ .ovdk-pill:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 3px; }++ .ovdk-m { position: relative; display: grid; place-items: center; width: 30px; height: 30px; }++ .ovdk-m::before, .ovdk-m::after { content: ''; position: absolute; top: -1px; right: -2px; width: 9px; height: 9px; border-radius: 50%; background: var(--t); }++ .ovdk-m::after { box-shadow: 0 0 0 2.5px var(--bg); }++ .ovdk-pill[data-tone="waiting"] .ovdk-m::before { animation: ovdk-ping 1.8s ease-out infinite; }++ .ovdk-pill[data-tone="idle"] .ovdk-m::before, .ovdk-pill[data-tone="idle"] .ovdk-m::after, .ovdk-t:empty { display: none; }++ .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;++ 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));++ border-radius: 22px; box-shadow: 0 30px 80px -24px rgba(0,0,0,.62), 0 2px 10px -2px rgba(0,0,0,.25); font-family: inherit; transform-origin: 100% 100%; }++ .ovdk[hidden] { display: none; }++ .ovdk[data-corner$="left"] { right: auto; left: max(var(--ovdk-gap), env(safe-area-inset-left, 0px)); }++ .ovdk[data-corner^="top"] { bottom: auto; top: calc(66px + env(safe-area-inset-top, 0px)); }++ .ovdk[data-corner="bottom-left"] { transform-origin: 0 100%; }++ .ovdk[data-corner="top-right"] { transform-origin: 100% 0; }++ .ovdk[data-corner="top-left"] { transform-origin: 0 0; }++ .ovdk.is-in { animation: ovdk-grow .3s cubic-bezier(.2,.9,.25,1.08); }++ .ovdk.is-moving { transition: none !important; box-shadow: 0 40px 90px -20px rgba(0,0,0,.7); cursor: grabbing; }++ @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; }++ .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; }++ .ovdk-tabs button { position: relative; display: inline-flex; align-items: center; gap: 7px; height: 36px; padding: 0 14px 0 11px; border: 0; border-radius: 999px; background: transparent;++ 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"] 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;++ color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); }++ .ovdk-tabs .n[data-tone="waiting"] { background: color-mix(in srgb, var(--warning, #f5a524) 34%, transparent); }++ .ovdk-tabs .n:empty { display: none; }++ .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-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; }++ .ovdk-scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }++ .ovdk-foot { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12.5px; }++ .ovdk-foot a, .ovdk-foot button { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0; border: 0; background: none; font: inherit; font-weight: 700; cursor: pointer; text-decoration: none; color: var(--accent-light, var(--accent, #60a5fa)); }++ .ovdk-foot button { color: var(--text-secondary, #b6b6c0); }++ .ovdk-foot button:hover { color: var(--text-primary, #ececf1); }++ .ovdk-foot svg { width: 15px; height: 15px; }++ .ovdk-wait { display: flex; justify-content: center; padding: 36px; }++ .ovdk-wait span { width: 20px; height: 20px; border-radius: 50%; border: 2px solid var(--border, rgba(255,255,255,.12)); border-top-color: var(--accent, #3b82f6); animation: ovdk-spin .8s linear infinite; }++ @keyframes ovdk-spin { to { transform: rotate(360deg); } }++ .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[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; }++ .ovdk[data-corner="top-right"] .ovdk-size::after { top: auto; bottom: 9px; border-top: 0; border-bottom: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 0; border-bottom-left-radius: 3px; }++ .ovdk[data-corner="top-left"] .ovdk-size { top: auto; bottom: 0; left: auto; right: 0; }++ .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 11px; gap: 6px; } .ovdk-tabs button svg { display: none; } }++ .ovdk-muted { color: var(--text-muted, #8b8b96); }++ .ovdk-i { display: inline-grid; place-items: center; }++ .ovdk-i svg { width: 18px; height: 18px; }++ .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; }++ .ovdk-chat:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 5%, transparent); }++ .ovdk-chat:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: -2px; }++ .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-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; }++ .is-unread .ovdk-chat-b b, .is-unread .ovdk-chat-p { color: var(--text-primary, #ececf1); font-weight: 750; }++ .ovdk-chat-m { display: grid; justify-items: end; gap: 4px; font-size: 11.5px; color: var(--text-muted, #8b8b96); font-variant-numeric: tabular-nums; }++ .ovdk-unread { min-width: 20px; padding: 1px 6px; box-sizing: border-box; border-radius: 999px; text-align: center; font-size: 11px; font-weight: 800;++ color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }++ .ovdk-thread-h { flex: none; display: flex; align-items: center; gap: 6px; padding: 6px 10px 6px 6px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }++ .ovdk-thread-h b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; }++ .ovdk-back { width: 40px; height: 40px; flex: none; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; display: grid; place-items: center; }++ .ovdk-back:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); }++ .ovdk-msgs { list-style: none; margin: 0; padding: 12px; display: flex; flex-direction: column; gap: 3px; }++ .ovdk-msg { display: grid; justify-items: start; max-width: 84%; margin-top: 8px; }++ .ovdk-msg.is-cont { margin-top: 0; }++ .ovdk-msg.is-mine { align-self: flex-end; justify-items: end; }++ .ovdk-who { margin: 0 0 3px 10px; font-size: 11.5px; font-weight: 700; color: var(--text-muted, #8b8b96); }++ .ovdk-text { padding: 8px 12px; border-radius: 16px; font-size: 14px; line-height: 1.42; white-space: pre-wrap; overflow-wrap: anywhere;++ background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); }++ .ovdk-msg.is-mine .ovdk-text { color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }++ .ovdk-send { flex: none; display: flex; align-items: flex-end; gap: 8px; margin: 8px 10px 10px; padding: 4px 4px 4px 12px; border-radius: 16px;++ 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;++ 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.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-size { display: none; }++ .ovdk-tabs { flex: 1; } .ovdk-tabs button { flex: 1; justify-content: center; }++ }++ 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;++ border-radius: 0 !important; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)) !important; box-shadow: none !important; }++ .ovdk-pip .ovdk-head { padding-top: 10px !important; cursor: grab !important; }++ .ovdk-pip .ovdk-head::before { display: none !important; }++ .ovdk-pip .ovdk-size { display: none !important; }++ `;++ document.head.appendChild(st);++ }++++ function build() {++ styles();++ el = document.createElement('section');++ el.className = 'ovdk';++ el.setAttribute('role', 'dialog');++ el.setAttribute('aria-label', 'Dock');++ el.hidden = true;++ el.innerHTML = `++ <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="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>++ </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);++++ const tabs = [...el.querySelectorAll('[role="tab"]')];++ tabs.forEach((b) => b.addEventListener('click', () => setTab(b.dataset.tab, true)));++ el.querySelector('.ovdk-tabs').addEventListener('keydown', (e) => {++ const i = tabs.indexOf(document.activeElement); if (i < 0) return;++ const j = e.key === 'ArrowRight' ? (i + 1) % tabs.length : e.key === 'ArrowLeft' ? (i - 1 + tabs.length) % tabs.length : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : -1;++ if (j >= 0) { e.preventDefault(); setTab(tabs[j].dataset.tab, true); tabs[j].focus(); }++ });++ el.querySelector('[data-act="close"]').addEventListener('click', () => close());++ 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.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;++ 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();++ });++ root.addEventListener('resize', () => { if (isOpen()) layout(); });++ bindMove();++ bindSize();++ paintPop();++ }++++ // ── Where it sits ────────────────────────────────────────++ function layout() {++ const p = place();++ el.dataset.corner = CORNERS.includes(p.corner) ? p.corner : 'bottom-right';++ if (phone()) { el.style.width = ''; el.style.height = ''; clear(0); return; }++ const w = clamp(p.width || DEF.width, W[0], Math.max(W[0], Math.min(W[1], root.innerWidth - 32)));++ const h = clamp(p.height || DEF.height, H[0], Math.max(H[0], Math.min(H[1], root.innerHeight - 96)));++ el.style.width = `${w}px`; el.style.height = `${h}px`;++ // Desktop toasts (bottom right) stack above the card when there is room, so neither hides the other.++ clear(el.dataset.corner === 'bottom-right' && root.innerHeight - h > 320 ? h + 12 : 0);++ }++ const clear = (px) => document.documentElement.style.setProperty('--ov-dock-clear', `${px}px`);++++ /**++ * The head drags the card, the tabs too once the pointer has moved 6 px (a tap still picks the tab; pin and close++ * never drag). On release it snaps to the nearest corner (a FLIP, so it glides there).++ */++ function bindMove() {++ const head = el.querySelector('.ovdk-head');++ let s = null, swallow = false;++ // Armed on the press; the window hears the moves, so a quick drag that leaves the head before 6 px still counts.++ const move = (e) => {++ if (!s || e.pointerId !== s.id) return;++ s.dx = e.clientX - s.x; s.dy = e.clientY - s.y;++ if (!s.live) {++ if (Math.abs(s.dx) + Math.abs(s.dy) < 6) return;++ s.live = true;++ if (!phone()) el.classList.add('is-moving');++ }++ if (phone()) { if (e.pointerType !== 'mouse') el.style.transform = `translateY(${Math.max(0, s.dy)}px)`; return; }++ el.style.transform = `translate(${s.dx}px, ${s.dy}px)`;++ };++ const end = (e) => {++ if (!s || (e && e.pointerId !== s.id)) return;++ const { r, dx, dy, live } = s; s = null;++ root.removeEventListener('pointermove', move); root.removeEventListener('pointerup', end); root.removeEventListener('pointercancel', end);++ if (!live) return;++ swallow = true; setTimeout(() => { swallow = false; }, 0);++ el.classList.remove('is-moving');++ if (phone()) {++ el.style.transition = 'transform .2s ease';++ if (dy > 110) close(); else el.style.transform = '';++ setTimeout(() => { el.style.transition = ''; }, 220);++ return;++ }++ const cx = r.left + dx + r.width / 2, cy = r.top + dy + r.height / 2;++ const corner = `${cy < root.innerHeight / 2 ? 'top' : 'bottom'}-${cx < root.innerWidth / 2 ? 'left' : 'right'}`;++ const from = { x: r.left + dx, y: r.top + dy };++ el.style.transform = '';++ save({ place: { desktop: { ...place(), corner } } });++ layout();++ const to = el.getBoundingClientRect();++ if (reduced()) return;++ el.style.transform = `translate(${from.x - to.left}px, ${from.y - to.top}px)`;++ el.getBoundingClientRect();++ el.style.transition = 'transform .28s cubic-bezier(.2,.9,.25,1.05)';++ el.style.transform = '';++ setTimeout(() => { el.style.transition = ''; }, 300);++ };++ head.addEventListener('pointerdown', (e) => {++ if (e.button || e.target.closest('.ovdk-acts')) 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);++ });++ // A drag that began on a tab is a drag, not a pick.++ head.addEventListener('click', (e) => { if (swallow) { swallow = false; e.preventDefault(); e.stopPropagation(); } }, true);++ }++++ /** The free corner (the one away from the anchor) resizes the card. */++ function bindSize() {++ const grip = el.querySelector('.ovdk-size');++ let s = null;++ grip.addEventListener('pointerdown', (e) => {++ if (e.button || phone()) return;++ e.preventDefault();++ const r = el.getBoundingClientRect();++ s = { x: e.clientX, y: e.clientY, w: r.width, h: r.height, id: e.pointerId, c: el.dataset.corner };++ try { grip.setPointerCapture(e.pointerId); } catch { /* */ }++ });++ grip.addEventListener('pointermove', (e) => {++ if (!s || e.pointerId !== s.id) return;++ const dx = e.clientX - s.x, dy = e.clientY - s.y;++ const w = clamp(s.w + (s.c.endsWith('right') ? -dx : dx), W[0], Math.min(W[1], root.innerWidth - 32));++ const h = clamp(s.h + (s.c.startsWith('bottom') ? -dy : dy), H[0], Math.min(H[1], root.innerHeight - 96));++ el.style.width = `${Math.round(w)}px`; el.style.height = `${Math.round(h)}px`;++ s.nw = Math.round(w); s.nh = Math.round(h);++ });++ const end = (e) => {++ if (!s || (e && e.pointerId !== s.id)) return;++ const { nw, nh } = s; s = null;++ if (nw && nh) { save({ place: { desktop: { ...place(), width: nw, height: nh } } }); layout(); }++ };++ grip.addEventListener('pointerup', end); grip.addEventListener('pointercancel', end);++ }++++ // ── Tabs ─────────────────────────────────────────────────++ function badges() {++ if (!el) return;++ const c = counts();++ const agent = (c.waiting || 0) + (c.running || 0);++ const n = (k, v, tone) => { const b = el.querySelector(`[data-n="${k}"]`); b.textContent = v ? (v > 99 ? '99+' : String(v)) : ''; b.dataset.tone = tone || ''; };++ 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-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);++ }++ function setTab(id, remember) {++ tab = TABS.some(([t]) => t === id) ? id : 'activity';++ if (tab !== 'chats') clearTimeout(chats.timer);++ 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; });++ if (remember && state().tab !== tab) save({ tab });++ fill(tab);++ }++++ async function fill(id) {++ const panel = el.querySelector(`[data-panel="${id}"]`);++ 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; }++ mounts[id] = { refresh: null };++ panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';++ if (id === 'agent') {++ const ok = await load('action-center.js', () => !!(root.OpenVibeActionCenter && root.OpenVibeActionCenter.mount));++ if (!ok) return failed(panel, id);++ panel.innerHTML = `<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>`;++ const m = root.OpenVibeActionCenter.mount(panel.firstChild, {++ token: o.token, apiBase: o.apiBase, buckets: ['waiting', 'running'],++ 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, anchor: (pill && !pill.hidden) ? pill : anchor }); } catch { /* */ }++ });++ return;++ }++ const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));++ if (!ok) return failed(panel, id);++ const N = root.OpenVibeNotifications;++ if (!N.__ovNavInit) { try { N.init({ token: typeof o.token === 'function' ? o.token() : o.token, apiBase: o.apiBase || 'https://openvibe.network' }); N.__ovNavInit = true; } catch { /* */ } }++ panel.innerHTML = '';++ N.renderDock(panel);++ mounts[id] = { refresh: () => { try { N.refresh(); } catch { /* */ } } };++ }++ function failed(panel, id) {++ mounts[id] = null;++ panel.innerHTML = `<div class="ovdk-none">That part of the Dock did not load. Check your connection, then open it again.</div>`;++ }++++ // ── Chats (Dock plan K-D2) ───────────────────────────────++ // 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 chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');++ const me = () => String(o.me || '');++ 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; };++ function chatCall(path, init = {}) {++ const t = typeof o.token === 'function' ? o.token() : o.token;++ const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };++ return fetch(chatBase() + path, { ...init, 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; }++ 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);++ }++ 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`;++ };++ function avatar(p, group) {++ const a = h('span', 'ovdk-av');++ const url = p && /^https:\/\//.test(String(p.avatar_url || '')) ? p.avatar_url : '';++ if (url && !group) { const img = h('img'); img.src = url; img.alt = ''; img.loading = 'lazy'; img.referrerPolicy = 'no-referrer'; a.append(img); }++ else a.textContent = group ? '#' : String((p && (p.display_name || p.username)) || '?').trim().charAt(0).toUpperCase() || '?';++ return a;++ }++ 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);++ 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;++ panel.textContent = '';++ const scroll = h('div', 'ovdk-scroll');++ if (!chats.list.length) {++ 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) => {++ const li = h('li'), b = h('button', 'ovdk-chat');++ b.type = 'button';++ const unread = Number(c.unread_count) || 0;++ const mine = (c.participants || []).some((p) => p.id === c.last_sender_id && p.username === me());++ const body = h('span', 'ovdk-chat-b');++ body.append(h('b', '', titleOf(c)), h('span', 'ovdk-chat-p', c.last_message ? `${mine ? 'You: ' : ''}${String(c.last_message).replace(/\s+/g, ' ').slice(0, 140)}` : 'No messages yet'));++ 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.setAttribute('aria-label', `${titleOf(c)}${unread ? `, ${unread} unread` : ''}`);++ if (unread) li.className = 'is-unread';++ b.addEventListener('click', () => openChat(String(c.id), true));++ li.append(b); ul.append(li);++ });++ 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.append(h('span', 'ovdk-text', String(m.message || '')));++ li.title = new Date(m.created_at).toLocaleString();++ return li;++ }++ async function openChat(id, remember) {++ clearTimeout(chats.timer);++ const panel = chats.panel; if (!panel) return;++ chats.open = String(id); chats.msgs = []; chats.last = 0;++ if (remember) save({ chat: chats.open });++ const conv = (chats.list || []).find((c) => String(c.id) === chats.open);++ panel.textContent = '';++ const head = h('div', 'ovdk-thread-h');++ 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);++ // 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');++ const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);++ const form = h('form', 'ovdk-send');++ const box = h('textarea'); box.rows = 1; box.maxLength = 4000; box.placeholder = 'Message'; box.setAttribute('aria-label', 'Message');++ 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;++ 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`; };++ 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 })); } });++ form.addEventListener('submit', async (e) => {++ e.preventDefault();++ const text = box.value.trim();++ if (!text || chats.busy) return;++ chats.busy = true; go.disabled = true; err.hidden = true;++ try {++ await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages`, { method: 'POST', body: JSON.stringify({ message: text }) });++ box.value = ''; fit();++ await pull(list, scroll);++ } catch (x) {++ err.textContent = x.status === 429 ? 'Slow down a little, then send it again.' : x.status === 403 ? 'You can’t send messages in this conversation.' : 'That did not send. Try again.';++ err.hidden = false;++ } finally { chats.busy = false; go.disabled = false; box.focus(); }++ });++ list.append(h('li', 'ovdk-wait', null));++ list.firstChild.innerHTML = '<span></span>';++ await pull(list, scroll, true);++ setTimeout(() => { try { box.focus({ preventScroll: true }); } 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.')); }++ return;++ }++ if (id !== chats.open) 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); });++ if (first && !chats.msgs.length) list.append(h('li', 'ovdk-none', 'No messages yet. Say hello.'));++ 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);++ }++++ // ── The pill ─────────────────────────────────────────────++ let pill = null, readT = 0, bound = false;++ 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;++ }++ function paintPill() {++ if (!pill) return;++ const c = counts(), st = state(), w = c.waiting || 0, r = c.running || 0;++ const corner = place().corner;++ // Popped out: the page keeps the pill, and it says where the Dock went.++ if (popped) {++ pill.hidden = false;++ pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';++ pill.dataset.tone = 'idle';++ pill.querySelector('.ovdk-t').textContent = 'In a window';++ pill.setAttribute('aria-label', 'The Dock is in a window of its own \u2014 click to bring it back');++ return;++ }++ const show = !isOpen() && !!(w || r || st.pinned);++ pill.hidden = !show;++ pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';++ pill.dataset.tone = w ? 'waiting' : r ? 'running' : 'idle';++ pill.querySelector('.ov-mark').setAttribute('data-state', r && !w ? 'busy' : '');++ pill.querySelector('.ovdk-t').textContent = w ? 'Needs you' : r ? (r > 1 ? `${r} working` : 'Working') : '';++ pill.setAttribute('aria-label', [w ? `${w} need${w > 1 ? '' : 's'} you` : '', r ? `${r} working` : ''].filter(Boolean).join(', ') || 'Open the Dock');++ // Desktop toasts (bottom right, notification-ui.js) stack above a pill in their corner; an open Dock sets its own.++ if (!isOpen()) clear(show && pill.dataset.corner === 'bottom-right' && !phone() ? 58 : 0);++ }++ function read() {++ clearTimeout(readT);++ if (!pill || document.hidden) return;++ const t = typeof o.token === 'function' ? o.token() : o.token;++ if (!t) return;++ fetch(`${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/me/dock`, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' })++ .then((r) => { if (r.status !== 404) return r.ok ? r.json() : null; store({ off: Date.now() + 864e5 }); if (pill) pill.hidden = true; return null; })++ .then((next) => {++ if (!next || !next.state || !pill) return;++ update(next);++ if (!isOpen() && next.state.open && !phone()) open();++ const c = next.counts || {};++ if (c.running || c.waiting) readT = setTimeout(read, 30000);++ }).catch(() => { /* offline: the copy stands */ });++ }++ /** 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) {++ clearTimeout(readT);++ if (pill) { pill.remove(); pill = null; }++ if (isOpen()) close();++ doc = null;++ return;++ }++ o = { ...o, ...opts };++ styles();++ if (!pill) {++ const kept = store();++ if (kept && kept.off > Date.now()) return;++ if (kept && kept.state && !doc) doc = kept;++ pill = document.createElement('button');++ pill.type = 'button'; pill.id = 'ovdk-pill'; pill.className = 'ovdk-pill'; pill.hidden = true;++ pill.innerHTML = '<span class="ovdk-m"><span class="ov-mark" data-size="26"></span></span><span class="ovdk-t"></span>';++ pill.addEventListener('click', () => { if (popped) backToPage(); else open(); });++ document.body.appendChild(pill);++ paintPill();++ }++ if (!bound) { bound = true; document.addEventListener('visibilitychange', () => { if (!document.hidden) read(); }); }++ read();++ }++++ // ── Pop out (K-D4) ────────────────────────────────────────++ // On desktop Chromium the panel can move into a window of its own (Document Picture-in-Picture). The same DOM and++ // the same tabs go with it; the window is dressed with the Dock's styles and the theme's colours, so the Dock looks++ // there as it does here. The page keeps the pill, which says where the Dock went; clicking it brings the panel back.++ const PIP_W = 360, PIP_H = 520;++ const pip = () => (root.documentPictureInPicture && typeof root.documentPictureInPicture.requestWindow === 'function') ? root.documentPictureInPicture : null;++ // The theme's tokens, so the window is painted in the same colours (the names only: the values are read from the page).++ const TOKENS = 'accent accent-light accent-strong bg bg-card bg-elevated bg-hover bg-input bg-primary bg-secondary bg-tertiary border border-light danger danger-strong gold live-red on-accent on-accent-strong success text-muted text-primary text-secondary warning'.split(' ');++++ function paintPop() {++ const b = el.querySelector('[data-act="pop"]');++ 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);++ }++++ /** The window gets the Dock's styles, the theme's colours and the page's own type: the Dock looks the same there. */++ function dress(win) {++ const d = win.document;++ if (!d.documentElement) d.appendChild(d.createElement('html'));++ if (!d.head) d.documentElement.appendChild(d.createElement('head'));++ if (!d.body) d.documentElement.appendChild(d.createElement('body'));++ d.title = 'OpenVibe Dock';++ for (const id of ['ovdk-styles', 'ovac-styles', 'openvibe-notif-styles', 'ov-mark-css']) {++ const s = document.getElementById(id);++ if (!s) continue;++ const c = d.createElement('style');++ c.id = id; c.textContent = s.textContent;++ d.head.appendChild(c);++ }++ const cs = getComputedStyle(document.documentElement);++ for (const n of TOKENS) { const v = cs.getPropertyValue('--' + n); if (v) d.documentElement.style.setProperty('--' + n, v.trim()); }++ const b = getComputedStyle(document.body);++ const s = d.body.style;++ s.fontFamily = b.fontFamily; s.fontSize = b.fontSize; s.fontWeight = b.fontWeight; s.lineHeight = b.lineHeight;++ s.background = b.backgroundColor;++ d.documentElement.style.colorScheme = cs.colorScheme || '';++ d.documentElement.classList.add('ovdk-pip');++ }++++ /** The panel moves into a window of its own. Only from a click: the browser wants a gesture, and a Dock that opened++ * a window by itself would be a window nobody asked for. */++ async function popOut() {++ if (popped || !pip()) return;++ let win = null;++ try { win = await pip().requestWindow({ width: PIP_W, height: PIP_H }); } catch { return; }++ if (!win || !win.document) return;++ pipWin = win;++ popped = true;++ dress(win);++ win.document.body.appendChild(el);++ win.addEventListener('pagehide', () => { if (popped && !pipClosing) backToPage(); });++ paintPop();++ save({ open: true, popped: true });++ paintPill();++ }++++ /** The panel comes back to the page: the window was closed, or the pill was pressed. */++ function backToPage() {++ if (!popped) return;++ popped = false;++ const w = pipWin; pipWin = null;++ pipClosing = true;++ try { if (w && !w.closed) w.close(); } catch { /* the window is already gone */ }++ pipClosing = false;++ if (el.parentNode !== document.body) document.body.appendChild(el);++ el.hidden = false;++ layout();++ paintPop();++ save({ popped: false });++ paintPill();++ }++++ // ── Open and close ───────────────────────────────────────++ function isOpen() { return !!(el && !el.hidden); }++ function open(opts = {}) {++ o = { ...o, ...opts };++ if (opts.doc) doc = opts.doc;++ if (!el) build();++ lastFocus = document.activeElement;++ const c = counts();++ const want = opts.tab || (c.waiting || c.running ? 'agent' : state().tab || 'activity');++ layout();++ badges();++ 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');++ setTab(want, false);++ readUnread();++ if (!state().open || state().tab !== tab) save({ open: true, tab }); else notify();++ setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]').focus({ preventScroll: true }); } catch { /* */ } }, 0);++ }++ function close() {++ if (!isOpen()) return;++ 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;++ el.style.transform = '';++ el.removeAttribute('aria-modal');++ document.documentElement.classList.remove('ovdk-sheet-open');++ clear(0);++ clearTimeout(chats.timer); clearTimeout(chats.unreadT);++ save(wasPopped ? { open: false, popped: false } : { open: false });++ if (inside) {++ const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);++ try { if (back) back.focus({ preventScroll: true }); } catch { /* */ }++ }++ }++ /** A fresh copy from the Network (navbar.js reads it while something runs): counts and anything changed elsewhere. */++ function update(next) {++ if (!next || !next.state) return;++ doc = Object.keys(pending).length ? { ...next, state: state() } : next;++ store(doc);++ paintPill();++ if (!el) return;++ badges();++ paintPin();++ if (isOpen()) layout();++ }++++ root.OpenVibeDock = { mount, open, close, isOpen, update, _state: () => ({ doc, tab, pending }) };++})(typeof window !== 'undefined' ? window : globalThis);nnew file mode 100644@@@ -0,0 +1,275 @@++/* openvibe-shared/kit.css — the OpenVibe Kit's look (kit.js builds the markup; docs/kit.md describes every piece).++ *++ * Phone first: a page fits the screen it is on. The head and its tabs stick under the navbar, the dock (a conversation's++ * composer, a form's save) sticks above the navbar's app bar, rows are 44 px or more to the touch, nothing scrolls++ * sideways but a strip that is meant to (tabs, numbers, chips). From 600 px (a tablet) lists breathe and numbers sit in++ * a row; from 1024 px (a computer) .ovk-split puts a side panel beside the content.++ *++ * Colours come from the site's theme (theme-loader.js: --bg-*, --text-*, --accent, --accent-strong, --border, --success,++ * --danger…), so the kit wears every site's theme and every theme's light and dark; the fallbacks are the network's++ * default dark. Every class starts with ovk- and nothing here styles bare elements, so the kit can sit beside a site's++ * own CSS. */++++:root {++ --ovk-bg: var(--bg-primary, #0b0f17);++ --ovk-panel: var(--bg-card, var(--bg-secondary, #121826));++ --ovk-panel-2: var(--bg-hover, var(--bg-tertiary, #1a2233));++ --ovk-input: var(--bg-input, var(--bg-tertiary, #0f1522));++ --ovk-line: var(--border, rgba(255, 255, 255, .1));++ --ovk-text: var(--text-primary, #e8ecf3);++ --ovk-muted: var(--text-muted, var(--text-secondary, #93a0b4));++ --ovk-accent: var(--accent, #3b82f6);++ --ovk-fill: var(--accent-strong, var(--accent, #3b82f6));++ --ovk-on-fill: var(--on-accent-strong, var(--on-accent, #fff));++ --ovk-ok: var(--success, #22c55e);++ --ovk-warn: var(--warning, #f59e0b);++ --ovk-bad: var(--danger, var(--error, #ef4444));++ --ovk-live: var(--live-red, #ef4444);++ --ovk-radius: 14px;++ --ovk-radius-sm: 10px;++ --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-font: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);++}++++/* Heading and paragraph rules double their class (.ovk-section.ovk-section > h2): a site's own ".site h2" has the same++ * specificity as ".ovk-section > h2" and loads later, so it used to restyle the kit's headings (Report, 2026-10-29). */++++/* ── Basics ── */++.ovk-i { width: 1.15em; height: 1.15em; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; vertical-align: -.2em; }++.ovk-sr { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }++.ovk-muted { color: var(--ovk-muted); }++.ovk-inline { display: inline; margin: 0; }++.ovk-page { box-sizing: border-box; width: 100%; max-width: 960px; margin: 0 auto; padding: 0 16px 24px; color: var(--ovk-text); font-family: var(--ovk-font); }++.ovk-page--wide { max-width: 1280px; }++++/* ── Buttons ── */++.ovk-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; box-sizing: border-box; min-height: var(--ovk-tap); padding: 10px 18px; border-radius: 12px; border: 1px solid transparent; font: inherit; font-weight: 700; font-size: .95rem; line-height: 1.2; text-decoration: none; cursor: pointer; transition: filter .12s, border-color .12s, background .12s; }++.ovk-btn--primary { background: var(--ovk-fill); color: var(--ovk-on-fill); }++.ovk-btn--primary:hover { filter: brightness(1.08); }++.ovk-btn--quiet { background: var(--ovk-panel); color: var(--ovk-text); border-color: var(--ovk-line); }++.ovk-btn--quiet:hover { border-color: var(--ovk-accent); }++.ovk-btn--ghost { background: transparent; color: var(--ovk-accent); padding-left: 8px; padding-right: 8px; }++.ovk-btn--danger { background: transparent; color: var(--ovk-bad); border-color: color-mix(in srgb, var(--ovk-bad) 45%, transparent); }++.ovk-btn--danger:hover { background: color-mix(in srgb, var(--ovk-bad) 12%, transparent); }++.ovk-btn--small { min-height: 36px; padding: 6px 13px; border-radius: 999px; font-size: .88rem; }++.ovk-btn:focus-visible, .ovk-icon-btn:focus-visible, .ovk-go:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: 2px; }++.ovk-btn[disabled] { opacity: .55; cursor: default; filter: none; }++.ovk-icon-btn { display: inline-grid; place-items: center; box-sizing: border-box; width: 40px; height: 40px; flex: none; padding: 0; border-radius: 12px; border: 1px solid transparent; background: transparent; color: var(--ovk-muted); font: inherit; cursor: pointer; text-decoration: none; list-style: none; }++.ovk-icon-btn::-webkit-details-marker { display: none; }++.ovk-icon-btn:hover { color: var(--ovk-text); background: var(--ovk-panel-2); border-color: var(--ovk-line); }++.ovk-icon-btn[hidden] { display: none; }++.ovk-icon-btn.is-done { color: var(--ovk-ok); }++.ovk-icon-btn--danger:hover { color: var(--ovk-bad); }++.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: .7rem; font-weight: 800; letter-spacing: .03em; text-transform: uppercase; color: var(--ovk-muted); white-space: nowrap; }++.ovk-badge--accent { color: var(--ovk-accent); border-color: color-mix(in srgb, var(--ovk-accent) 50%, transparent); }++.ovk-badge--ok { color: var(--ovk-ok); border-color: color-mix(in srgb, var(--ovk-ok) 50%, transparent); }++.ovk-badge--warn { color: var(--ovk-warn); border-color: color-mix(in srgb, var(--ovk-warn) 50%, transparent); }++.ovk-badge--bad { color: var(--ovk-bad); border-color: color-mix(in srgb, var(--ovk-bad) 50%, transparent); }++.ovk-badge--live { color: #fff; background: var(--ovk-live); border-color: transparent; }++.ovk-dot { display: inline-grid; place-items: center; flex: none; min-width: 20px; height: 20px; padding: 0 6px; box-sizing: border-box; border-radius: 999px; background: var(--ovk-fill); color: var(--ovk-on-fill); font-size: .72rem; font-weight: 800; }++++/* ── The head: back, title and one line, icon actions; its tabs under it; sticky under the navbar ── */++.ovk-head { position: sticky; top: var(--ovk-top); z-index: 40; margin: 0 -16px 12px; padding: 6px 16px 0; background: color-mix(in srgb, var(--ovk-bg) 88%, transparent); -webkit-backdrop-filter: saturate(1.4) blur(14px); backdrop-filter: saturate(1.4) blur(14px); border-bottom: 1px solid transparent; transition: border-color .2s, box-shadow .2s; }++.ovk-scrolled .ovk-head { border-bottom-color: var(--ovk-line); box-shadow: 0 10px 24px -18px rgba(0, 0, 0, .7); }++.ovk-head--static { position: static; background: transparent; -webkit-backdrop-filter: none; backdrop-filter: none; }++.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; }++.ovk-head-tools { display: flex; align-items: center; gap: 2px; margin-right: -8px; }++.ovk-head-tools form { margin: 0; }++.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; }++.ovk-tabs a:hover { color: var(--ovk-text); background: var(--ovk-panel-2); }++.ovk-tabs a[aria-current="page"] { color: var(--ovk-text); background: var(--ovk-panel-2); box-shadow: inset 0 0 0 1px var(--ovk-line); }++.ovk-tabs .ovk-i { width: 16px; height: 16px; }++.ovk-tab-n { min-width: 18px; padding: 0 5px; border-radius: 999px; background: var(--ovk-panel); color: var(--ovk-muted); font-size: .72rem; font-weight: 800; text-align: center; }++++/* ── Lists: rows that read like a phone's (a mark, the name and time on one line, one line under it) ── */++.ovk-list-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 18px 0 8px; }++.ovk-list-head.ovk-list-head h2 { margin: 0; color: var(--ovk-muted); font-family: inherit; font-size: .76rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }++.ovk-list-head a { display: inline-flex; align-items: center; min-height: 32px; color: var(--ovk-accent); font-size: .84rem; font-weight: 700; text-decoration: none; }++/* 44 px to the touch: tab pills (36 px) and list links grow on touch screens and narrow windows. */++@media (pointer: coarse), (max-width: 759px) { .ovk-tabs a, .ovk-list-head a { min-height: 44px; } }++.ovk-list { list-style: none; display: grid; grid-template-columns: minmax(0, 1fr); margin: 0 0 14px; padding: 0; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-panel); overflow: hidden; }++.ovk-list--flush { margin: 0; border: 0; border-radius: 0; border-top: 1px solid var(--ovk-line); background: transparent; }++.ovk-row { margin: 0; color: var(--ovk-text); }++.ovk-row + .ovk-row { border-top: 1px solid var(--ovk-line); }++.ovk-row > a, .ovk-row--static { display: flex; align-items: center; gap: 12px; min-height: 56px; box-sizing: border-box; padding: 8px 14px; color: inherit; text-decoration: none; min-width: 0; }++.ovk-row > a:hover { background: var(--ovk-panel-2); }++.ovk-row > a:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: -2px; }++.ovk-row--static { padding-right: 8px; }++.ovk-row-main { flex: 1; min-width: 0; display: grid; gap: 1px; }++.ovk-row-top { display: flex; align-items: center; gap: 8px; min-width: 0; }++.ovk-row-top strong { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; }++.ovk-row-top time, .ovk-row-meta { flex: none; margin-left: auto; color: var(--ovk-muted); font-size: .78rem; font-variant-numeric: tabular-nums; }++.ovk-row-sub { display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--ovk-muted); font-size: .9rem; }++.ovk-row-sub > span:first-child { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovk-row--unread .ovk-row-top strong { font-weight: 800; }++.ovk-row--unread .ovk-row-sub { color: var(--ovk-text); }++.ovk-row-trail { display: flex; align-items: center; gap: 2px; flex: none; }++.ovk-row-trail form { display: flex; align-items: center; gap: 2px; margin: 0; }++.ovk-row-trail select { max-width: 9.5em; padding: 5px 6px; border-radius: 8px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: .86rem; }++.ovk-mark { --ovk-mark: 40px; flex: none; display: inline-grid; place-items: center; width: var(--ovk-mark); height: var(--ovk-mark); box-sizing: border-box; border-radius: 50%; object-fit: cover; background: hsl(var(--ovk-hue, 215) 45% 38%); color: #fff; font-size: calc(var(--ovk-mark) * .34); font-weight: 800; letter-spacing: .02em; line-height: 1; }++.ovk-mark--square { border-radius: 28%; }++.ovk-empty-line { margin: 0 0 14px; padding: 14px; border: 1px dashed var(--ovk-line); border-radius: var(--ovk-radius); color: var(--ovk-muted); }++++/* ── 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-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; }++.ovk-fold { margin: 0 0 10px; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-panel); color: var(--ovk-text); }++.ovk-fold > summary { display: flex; align-items: center; gap: 10px; min-height: 48px; box-sizing: border-box; padding: 10px 14px; cursor: pointer; list-style: none; font-weight: 650; }++.ovk-fold > summary::-webkit-details-marker { display: none; }++.ovk-fold > summary > span { flex: 1; min-width: 0; }++.ovk-fold > summary > .ovk-i { color: var(--ovk-muted); transition: transform .15s; }++.ovk-fold[open] > summary > .ovk-i { transform: rotate(90deg); }++.ovk-fold[open] > summary { border-bottom: 1px solid var(--ovk-line); }++.ovk-fold > summary:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: -2px; border-radius: var(--ovk-radius); }++.ovk-fold-body { padding: 12px 14px 14px; }++.ovk-fold-body > :first-child { margin-top: 0; }++.ovk-fold-body > :last-child { margin-bottom: 0; }++.ovk-fold--danger > summary { color: var(--ovk-bad); }++.ovk-more { position: relative; display: inline-block; }++.ovk-more > summary { list-style: none; }++.ovk-pop { position: absolute; right: 0; top: calc(100% + 4px); z-index: 60; min-width: 210px; padding: 6px; box-sizing: border-box; border-radius: 12px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); box-shadow: 0 14px 32px rgba(0, 0, 0, .4); }++.ovk-pop form { margin: 0; }++.ovk-pop-item { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--ovk-text); font: inherit; font-weight: 600; text-align: left; text-decoration: none; cursor: pointer; }++.ovk-pop-item:hover, .ovk-pop-item:focus-visible { background: var(--ovk-panel-2); outline: none; }++.ovk-pop-item--danger { color: var(--ovk-bad); }++++/* ── Settings rows: what it does on the left, its control on the right ── */++.ovk-set-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 52px; box-sizing: border-box; padding: 8px 14px; border-top: 1px solid var(--ovk-line); color: var(--ovk-text); cursor: pointer; }++.ovk-section > h2 + .ovk-set-row, .ovk-section > .ovk-note + .ovk-set-row { border-top: 0; }++div.ovk-set-row { cursor: default; }++.ovk-set-text { flex: 1; min-width: 0; display: grid; gap: 2px; }++.ovk-set-text small { color: var(--ovk-muted); font-size: .8rem; }++.ovk-set-row select { flex: none; max-width: 52%; margin: 0; padding: 7px 8px; border-radius: 10px; 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; }++.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: 0 1px 3px rgba(0, 0, 0, .35); transition: transform .15s; }++.ovk-switch:checked { background: var(--ovk-fill); }++.ovk-switch:checked::after { transform: translateX(18px); }++.ovk-switch:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: 2px; }++.ovk-seg { display: inline-flex; flex: none; gap: 2px; padding: 3px; border-radius: 10px; background: var(--ovk-panel-2); }++.ovk-seg input, .ovk-chip input { position: absolute; opacity: 0; pointer-events: none; }++.ovk-seg span { display: block; padding: 6px 12px; border-radius: 8px; 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; }++.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 .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; }++.ovk-form label.ovk-check { display: flex; align-items: flex-start; gap: 10px; color: var(--ovk-text); font-size: .95rem; }++.ovk-form input:not([type=checkbox]):not([type=radio]):not([type=range]), .ovk-form select, .ovk-form textarea { width: 100%; box-sizing: border-box; min-height: var(--ovk-tap); padding: 10px 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 1rem; }++.ovk-form textarea { min-height: 96px; resize: vertical; }++.ovk-form input:focus-visible, .ovk-form select:focus-visible, .ovk-form textarea:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: 0; border-color: transparent; }++.ovk-form.ovk-form > p { margin: 0; }++/* A settings row inside a form stays a row: the label on the left, its control (not stretched) on the right. */++.ovk-form label.ovk-set-row { display: flex; color: var(--ovk-text); font-size: 1rem; }++.ovk-form .ovk-set-row select { width: auto; min-height: 0; padding: 7px 8px; font-size: .9rem; }++.ovk-form .ovk-section { margin: 0; }++++/* ── Find (search, add by name) and the dock (a conversation's composer) ── */++.ovk-find { display: flex; align-items: center; gap: 8px; box-sizing: border-box; margin: 0 0 12px; padding: 4px 4px 4px 14px; border: 1px solid var(--ovk-line); border-radius: 999px; background: var(--ovk-panel); color: var(--ovk-muted); }++.ovk-find:focus-within { border-color: var(--ovk-accent); }++.ovk-find--inset { margin: 0 14px 12px; }++.ovk-find input { flex: 1; min-width: 0; min-height: 38px; margin: 0; padding: 8px 0; border: 0; background: transparent; color: var(--ovk-text); font: inherit; font-size: 1rem; outline: none; -webkit-appearance: none; appearance: none; }++.ovk-find input::-webkit-search-cancel-button { -webkit-appearance: none; }++.ovk-find select { flex: none; max-width: 38%; padding: 6px 8px; border: 0; border-radius: 999px; background: var(--ovk-panel-2); color: var(--ovk-text); font: inherit; font-size: .86rem; }++.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-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); }++++/* ── App pages: the conversation fills the screen between the head and the dock, and only it scrolls ── */++.ovk-app { display: flex; flex-direction: column; min-height: calc(100dvh - var(--ovk-top) - var(--ovk-bottom)); }++.ovk-app > .ovk-feed { flex: 1 1 auto; }++.ovk-feed { min-height: 240px; }++@supports (height: 100dvh) {++ .ovk-app--fixed { height: calc(100dvh - var(--ovk-top) - var(--ovk-bottom)); min-height: 0; }++ .ovk-app--fixed > .ovk-feed { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }++ .ovk-app--fixed > .ovk-head { position: static; }++ .ovk-app--fixed > .ovk-dock { position: static; }++}++++/* ── Numbers, empty states, notices, code, sheets ── */++.ovk-stats { position: relative; display: flex; gap: 8px; margin: 12px -16px; padding: 0 16px; overflow-x: auto; scrollbar-width: none; }++.ovk-stats::-webkit-scrollbar { display: none; }++.ovk-stats > div { display: flex; flex-direction: column-reverse; flex: 0 0 auto; min-width: 104px; box-sizing: border-box; padding: 8px 14px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-panel); }++.ovk-stats dt { margin: 0; color: var(--ovk-muted); font-size: .76rem; line-height: 1.25; }++.ovk-stats dt a { color: inherit; text-decoration: none; }++/* A number that is a link: the whole tile is the target (the label's link stretched over it), never an underline. */++.ovk-stat--link { position: relative; transition: border-color .15s; }++.ovk-stat--link dt a::after { content: ""; position: absolute; inset: 0; border-radius: inherit; }++.ovk-stat--link:hover { border-color: var(--ovk-accent); }++.ovk-stat--link dt a:focus-visible { outline: none; }++.ovk-stat--link:focus-within { outline: 2px solid var(--ovk-accent); outline-offset: -2px; }++.ovk-stats dd { margin: 0; color: var(--ovk-text); font-size: 1.2rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.2; }++.ovk-empty { display: grid; justify-items: center; gap: 8px; margin: 0 0 14px; padding: 24px 16px; border: 1px dashed var(--ovk-line); border-radius: var(--ovk-radius); color: var(--ovk-muted); text-align: center; }++.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: 12px; 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); }++.ovk-notice--error { border-color: color-mix(in srgb, var(--ovk-bad) 50%, transparent); }++.ovk-notice--error > .ovk-i { color: var(--ovk-bad); }++.ovk-notice--warn > .ovk-i { color: var(--ovk-warn); }++.ovk-notice--info > .ovk-i { color: var(--ovk-accent); }++.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: 10px; 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; }++.ovk-sheet { width: min(560px, 100vw); max-width: 100vw; max-height: 85dvh; margin: auto; padding: 0; box-sizing: border-box; border: 1px solid var(--ovk-line); border-radius: 18px; background: var(--ovk-panel); color: var(--ovk-text); overflow: hidden; }++.ovk-sheet::backdrop { background: rgba(0, 0, 0, .55); }++.ovk-sheet[open] { display: flex; flex-direction: column; }++.ovk-sheet-head { display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 16px; border-bottom: 1px solid var(--ovk-line); }++.ovk-sheet-head.ovk-sheet-head h2 { flex: 1; margin: 0; font-size: 1.05rem; }++.ovk-sheet-body { padding: 14px 16px calc(16px + env(safe-area-inset-bottom, 0px)); overflow-y: auto; }++++/* ── Phones (< 600 px): bottom sheets, full-width forms ── */++++@media (max-width: 599px) {++ .ovk-sheet { margin: auto 0 0; width: 100vw; border-radius: 18px 18px 0 0; border-bottom: 0; }++ .ovk-row > a, .ovk-row--static { padding-left: 12px; }++ .ovk-head-tools { gap: 0; }++}++/* ── Tablets and up (≥ 600 px): numbers in a row, roomier lists ── */++@media (min-width: 600px) {++ .ovk-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); margin-left: 0; margin-right: 0; padding: 0; overflow: visible; }++ .ovk-save .ovk-btn { width: auto; min-width: 200px; }++ .ovk-dock { margin-left: 0; margin-right: 0; padding-left: 0; padding-right: 0; }++}++/* ── Computers (≥ 1024 px): a side panel beside the content ── */++.ovk-split { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); }++@media (min-width: 1024px) {++ .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++ 44px to a finger and looks the same, and the find bar's field and select are 44px tall. */++@media (pointer: coarse) {++ .ovk-btn--small { min-height: 44px; }++ .ovk-go { position: relative; }++ .ovk-go::after { content: ''; position: absolute; inset: -3px; }++ .ovk-find input, .ovk-find select { min-height: 44px; }++}nnew file mode 100644@@@ -0,0 +1,1437 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — Notification UI Client (shared across every OpenVibe site)++//++// One store (openvibe.network) → one bell everywhere. This file renders the bell + its panel, toasts, a full-page++// inbox, and handles Web Push subscription.++//++// Usage: <script src="https://openvibe.network/shared/notification-ui.js"></script>++// OpenVibeNotifications.init({ token, apiBase, swPath, onAction })++// OpenVibeNotifications.createBell(mountEl)++// OpenVibeNotifications.renderInbox(containerEl) // full-page inbox++// OpenVibeNotifications.renderDock(containerEl) // the Dock's Activity tab (dock.js): the same list, no frame++//++// Reads: GET /api/notifications?limit&offset&unread_only&tab&q&since++// GET /api/notifications/unread-count, /unread-by-tab (the counts on the bell's tabs)++// Realtime (init({ realtime: true }), off by default): notification-live.js, loaded from beside this file, hears the++// person's network.notification.created over OpenVibe.Events with a ticket from POST /api/v1/realtime/ticket;++// each event or gap re-reads the count. Polling stays: every 15 s without a stream, every 2 min with one.++// Writes: POST /:id/read, /read-batch, /read-all, /:id/dismiss, /api/push/{subscribe,unsubscribe}++//++// Notifications 2.0 (2026-10-28, the owner's screenshot of three alert toasts drawn over the open menu):++// • a toast is an opaque, themed card: a line glyph for its category (never an emoji) or the sender's avatar with++// the glyph as a badge, a one-line title, at most two lines of text, chips, up to two actions, a timeout bar;++// • toasts never draw over an open menu, panel or dialog: they wait (timers paused) and show when it closes;++// • phones show one at a time under the bar (with "+N more" and swipe to dismiss), desktops up to three at the++// bottom right;++// • the same notice arriving again within 15 minutes joins the card that is showing (a count, site chips);++// • operator alerts (OPERATOR_ALERT, or rich_content.kind "ops_alert") read as System cards: "Browser check++// failed · events · release 80b7955", a status link, and the fix command behind a copy button in the bell —++// never raw shell text in the body;++// • the ✦ beside the bell (4.31.0, desktop) opens the Action Center (action-center.js, loaded from beside this file++// on the first click): every agent task in one place; on phones the bell's Agents tab links to it;++// • the bell (4.30.0) is a 420 px popover on desktop and a full-height sheet on phones: tabs (All · Mentions · Live ·++// Deals & alerts · System · Agents) counted by Network's unread-by-tab, an Unread filter, day headers, thumbnails,++// unread dots and grouped repeats; on touch screens a row swipes right to read and left to clear.++// ═══════════════════════════════════════════════════════════════++++(function (root) {++ 'use strict';++++ const POLL_INTERVAL = 15_000;++ const REALTIME_POLL = 120_000; // with an open realtime stream the poll is only a safety net (reads made on other sites)++ const SELF_SRC = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';++ const PAGE_SIZE = 30;++ const TOAST_DURATION = { low: 0, normal: 5200, high: 8000, critical: 0 }; // 0 = sticky++ const MAX_TOASTS = 3; // desktop; phones show one and count the rest++ const GROUP_WINDOW = 15 * 60 * 1000;++ const SOUNDS = { normal: 'notification.mp3', high: 'notification-high.mp3', critical: 'notification-alarm.mp3' };++ const CATEGORY_LABELS = {++ 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' };++++ // Line glyphs (24×24, stroked like the open-loop mark): one per kind of notice.++ const GLYPH = {++ live: '<circle cx="12" cy="12" r="2.6"/><path d="M6.4 6.4a8 8 0 0 0 0 11.2M17.6 6.4a8 8 0 0 1 0 11.2M9.2 9.2a4 4 0 0 0 0 5.6M14.8 9.2a4 4 0 0 1 0 5.6"/>',++ people: '<circle cx="9" cy="8.5" r="3.2"/><path d="M3.5 19.5a5.5 5.5 0 0 1 11 0"/><circle cx="17.2" cy="9.4" r="2.4"/><path d="M15.6 14.4a4.5 4.5 0 0 1 5.4 4.6"/>',++ chat: '<path d="M4.5 5.5h15v10.5H10l-5.5 4z"/>',++ game: '<path d="M6.5 8.5h11a3.5 3.5 0 0 1 3.5 3.5v2.6a2.9 2.9 0 0 1-5.1 1.9L14.2 14.6H9.8L8.1 16.5A2.9 2.9 0 0 1 3 14.6V12a3.5 3.5 0 0 1 3.5-3.5z"/><path d="M8.3 10.6v3M6.8 12.1h3"/><path d="M15.6 11.4h.01M17.2 12.9h.01"/>',++ coin: '<circle cx="12" cy="12" r="8.2"/><path d="M14.6 9.6c-.6-.9-1.5-1.3-2.6-1.3-1.5 0-2.6.8-2.6 1.9s1.1 1.6 2.6 1.9 2.6.8 2.6 2-1.1 1.9-2.6 1.9c-1.1 0-2.1-.5-2.6-1.3M12 6.6v1.7M12 15.7v1.7"/>',++ tag: '<path d="M4 4.5h7.2l8.8 8.8-6.7 6.7-8.8-8.8z"/><circle cx="8.4" cy="8.9" r="1.3"/>',++ trophy: '<path d="M8 4h8v5a4 4 0 0 1-8 0zM8 6H5.2a3 3 0 0 0 3 3.6M16 6h2.8a3 3 0 0 1-3 3.6M12 13v3.6M8.6 20h6.8"/>',++ shield: '<path d="M12 3.2l7 3v5.2c0 4.3-2.9 7.8-7 9.6-4.1-1.8-7-5.3-7-9.6V6.2z"/>',++ gear: '<circle cx="12" cy="12" r="3"/><path d="M12 3.5v2.2M12 18.3v2.2M3.5 12h2.2M18.3 12h2.2M6 6l1.6 1.6M16.4 16.4L18 18M6 18l1.6-1.6M16.4 7.6L18 6"/>',++ grid: '<rect x="4" y="4" width="6.6" height="6.6" rx="1.6"/><rect x="13.4" y="4" width="6.6" height="6.6" rx="1.6"/><rect x="4" y="13.4" width="6.6" height="6.6" rx="1.6"/><rect x="13.4" y="13.4" width="6.6" height="6.6" rx="1.6"/>',++ pulse: '<path d="M3 12.5h4.2l2.3-5.5 4.2 10.5 2.3-5h4.9"/>',++ check: '<path d="M5 12.6l4.6 4.6L19 7.6"/>',++ bell: '<path d="M6.2 16.2V11a5.8 5.8 0 0 1 11.6 0v5.2l1.6 2H4.6z"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0"/>',++ 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"/>',++ };++ 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)',++ gold: 'var(--gold, #e3b341)', accent: 'var(--accent, #3b82f6)', muted: 'var(--text-secondary, #a0a0ab)',++ };++ const CATEGORY_TONE = { stream: 'live', social: 'accent', chat: 'accent', game: 'accent', economy: 'gold', achievement: 'gold', moderation: 'warn', system: 'muted', service: 'accent', admin: 'accent' };++ // The bell's tabs. Network defines what each holds (server/notifications/tabs.js, ?tab= and /unread-by-tab).++ const TABS = [['all', 'All'], ['mentions', 'Mentions'], ['live', 'Live'], ['alerts', 'Deals & alerts'], ['system', 'System'], ['agents', 'Agents']];++ const TAB_EMPTY = {++ all: ['No notifications yet', 'When something happens across OpenVibe, it lands here.'],++ mentions: ['No mentions yet', 'When someone mentions you, replies to you or messages you, it shows here.'],++ live: ['Nobody has gone live yet', 'When someone you follow goes live, it shows here.'],++ alerts: ['No alerts yet', 'Deal, coupon, price and trade watches you set tell you here.'],++ system: ['Nothing from the system', 'Sign-ins, security notices and announcements show here.'],++ agents: ['No agent work yet', 'When an agent needs your approval or finishes a task, it shows here.'],++ };++++ let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };++ let _pollTimer = null;++ let _lastSeenAt = null; // newest created_at we have toasted / seen++ let _unreadCount = 0;++ let _toastContainer = null;++ let _panelEl = null;++ let _bellEl = null;++ let _audioCache = {};++ let _preferences = { enabled: true, sound: true, toasts: true, muted_categories: [] };++ let _channel = null; // BroadcastChannel for same-origin tab sync++ let _live = null; // OVNotificationLive feed (realtime on)++ let _lastPoll = 0;++ let _soon = null; let _soonAll = false;++ const _views = new Map(); // container → view state (panel + inboxes)++ const _queue = []; // toast cards waiting for a free screen (a menu open, or a phone already showing one)++ let _menuOpen = false; // the OpenVibe menu (search.js) says so through its 'openvibe-menu' event++++ // ── Styles ──────────────────────────────────────────────++ function injectStyles() {++ if (document.getElementById('openvibe-notif-styles')) return;++ const style = document.createElement('style');++ style.id = 'openvibe-notif-styles';++ style.textContent = `++ @keyframes ovn-in-up { from { transform: translateY(14px) scale(.98); opacity: 0; } to { transform: none; opacity: 1; } }++ @keyframes ovn-in-down { from { transform: translateY(-14px) scale(.98); opacity: 0; } to { transform: none; opacity: 1; } }++ @keyframes ovn-out { to { transform: translateX(24px); opacity: 0; } }++ @keyframes ovn-badge-pop { 0%,100% { transform: scale(1); } 50% { transform: scale(1.22); } }++ @keyframes ovn-bell-ring { 0%{ transform: rotate(0); } 12%{ transform: rotate(13deg); } 24%{ transform: rotate(-12deg); } 36%{ transform: rotate(8deg); } 48%{ transform: rotate(-4deg); } 60%{ transform: rotate(0); } }++ @keyframes ovn-panel-in { from { transform: translateY(-8px); opacity: 0; } to { transform: none; opacity: 1; } }++ @keyframes ovn-sheet-up { from { transform: translateY(48px); opacity: .5; } to { transform: none; opacity: 1; } }++ @keyframes ovn-spin { to { transform: rotate(360deg); } }++++ /* Toasts. Opaque cards on the theme's raised surface; held (hidden, timers paused) while a menu is open. */++ .openvibe-toast-container { position: fixed; z-index: 2147482600; display: flex; flex-direction: column; gap: 10px; pointer-events: none;++ right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ov-dock-clear, 0px)); width: min(380px, calc(100vw - 32px));++ font-family: inherit; transition: opacity .18s ease; }++ .openvibe-toast-container.ovn-held { opacity: 0; visibility: hidden; }++ .openvibe-toast-container.ovn-phone { top: var(--ovn-top, 64px); bottom: auto; left: max(8px, env(safe-area-inset-left, 0px)); right: max(8px, env(safe-area-inset-right, 0px)); width: auto; }++ .openvibe-toast { pointer-events: auto; position: relative; overflow: hidden; display: grid; grid-template-columns: 38px 1fr; gap: 12px; align-items: start;++ padding: 12px 34px 12px 12px; border-radius: 16px; cursor: pointer; touch-action: pan-y;++ background: var(--bg-elevated, var(--bg-card, #1f2129)); color: var(--text-primary, #ececf1);++ border: 1px solid var(--border, rgba(255,255,255,.1)); box-shadow: 0 18px 40px rgba(0,0,0,.34), 0 2px 6px rgba(0,0,0,.2);++ animation: ovn-in-up .26s cubic-bezier(.2,.8,.2,1); }++ .ovn-phone .openvibe-toast { animation-name: ovn-in-down; }++ .openvibe-toast::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--ovn-tone, var(--accent, #3b82f6)); }++ .openvibe-toast:hover, .openvibe-toast:focus-visible { border-color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 55%, var(--border, #333)); outline: none; }++ .openvibe-toast.removing { animation: ovn-out .24s ease forwards; }++ .openvibe-toast-body { min-width: 0; }++ .openvibe-toast-title { display: flex; align-items: baseline; gap: 6px; min-width: 0; font-size: 14px; font-weight: 650; line-height: 1.3; }++ .openvibe-toast-title .t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++ .openvibe-toast-title .n { flex: none; font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 50%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 16%, transparent); }++ .openvibe-toast-sub { margin-top: 1px; font-size: 11.5px; color: var(--text-muted, #8b8b96); display: flex; gap: 6px; align-items: center; }++ .openvibe-toast-msg { margin-top: 4px; font-size: 13px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }++ .openvibe-toast-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }++ .openvibe-toast-actions button, .openvibe-toast-actions a { font: inherit; font-size: 12.5px; font-weight: 650; padding: 7px 13px; border-radius: 999px; cursor: pointer; text-decoration: none; line-height: 1;++ border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); }++ .openvibe-toast-actions .primary { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, var(--on-accent, #fff)); }++ .openvibe-toast-close { position: absolute; top: 8px; right: 8px; width: 26px; height: 26px; border-radius: 50%; border: none; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; font-size: 16px; line-height: 1; }++ .openvibe-toast-close:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-primary, #ececf1); }++ .openvibe-toast-progress { position: absolute; left: 0; bottom: 0; height: 2px; width: 100%; transform-origin: left; background: var(--ovn-tone, var(--accent, #3b82f6)); opacity: .7; }++ .openvibe-toast-more { margin-left: auto; flex: none; font: inherit; font-size: 11px; font-weight: 700; padding: 2px 9px; border-radius: 999px; cursor: pointer;++ border: 1px solid color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 40%, var(--border, #333)); background: transparent; color: var(--text-secondary, #b6b6c0); }++ .openvibe-toast.priority-critical { border-color: color-mix(in srgb, var(--ovn-tone) 60%, var(--border, #333)); }++++ /* The glyph (or avatar with a glyph badge), shared by toasts and the list. */++ .ovn-glyph { position: relative; width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; flex: none;++ color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 68%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 15%, transparent); }++ .ovn-glyph svg { width: 21px; height: 21px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }++ .ovn-glyph.ovn-avatar { border-radius: 50%; background: var(--bg-tertiary, #2a2b33); }++ .ovn-glyph.ovn-avatar img { width: 38px; height: 38px; border-radius: 50%; object-fit: cover; display: block; }++ .ovn-glyph .ovn-badge { position: absolute; right: -3px; bottom: -3px; width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center;++ background: var(--ovn-tone, var(--accent, #3b82f6)); color: var(--bg-elevated, var(--bg-card, #1f2129)); box-shadow: 0 0 0 2px var(--bg-elevated, var(--bg-card, #1f2129)); }++ .ovn-glyph .ovn-badge svg { width: 11px; height: 11px; stroke-width: 2.4; }++ .ovn-chips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }++ .ovn-chip { font-size: 11px; font-weight: 600; line-height: 1; padding: 4px 8px; border-radius: 999px; color: var(--text-secondary, #b6b6c0); background: var(--bg-tertiary, rgba(255,255,255,.06)); border: 1px solid var(--border, rgba(255,255,255,.08)); }++ .ovn-chip.sev { color: color-mix(in srgb, var(--ovn-tone) 50%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovn-tone) 14%, transparent); border-color: transparent; text-transform: uppercase; letter-spacing: .03em; font-size: 10px; }++++ .openvibe-bell { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; border-radius: 10px; }++ .openvibe-bell svg { width: 22px; height: 22px; fill: none; stroke: var(--text-secondary, #b0b0b8); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; transition: stroke .2s; }++ .openvibe-bell:hover svg, .openvibe-bell.open svg { stroke: var(--accent-light, var(--accent, #60a5fa)); }++ .openvibe-bell:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++ .openvibe-bell .badge { position: absolute; top: 1px; right: 0; min-width: 17px; height: 17px; padding: 0 5px; box-sizing: border-box; background: var(--live-red, #e5484d); color: #fff; border-radius: 999px;++ font-size: 10px; font-weight: 750; display: flex; align-items: center; justify-content: center; line-height: 1; pointer-events: none; box-shadow: 0 0 0 2px var(--bg-primary, #111);++ font-variant-numeric: tabular-nums; animation: ovn-badge-pop .36s ease; }++ .openvibe-bell .badge.hidden { display: none; }++ .openvibe-bell.ringing svg { animation: ovn-bell-ring .7s ease; transform-origin: 50% 15%; }++ /* The ✦ beside the bell: the Action Center (desktop; phones reach it from the bell's Agents tab). */++ .openvibe-actions-btn { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; border-radius: 10px; }++ .openvibe-actions-btn svg { width: 21px; height: 21px; fill: none; stroke: var(--text-secondary, #b0b0b8); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; transition: stroke .2s; }++ .openvibe-actions-btn:hover svg, .openvibe-actions-btn.open svg { stroke: var(--accent-light, var(--accent, #60a5fa)); }++ .openvibe-actions-btn:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++ @media (max-width: 640px) { .openvibe-actions-btn { display: none; } }++ .ovn-ac-link { display: flex; align-items: center; gap: 10px; width: calc(100% - 12px); margin: 6px; padding: 10px 14px; border-radius: 14px; border: 1px dashed color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, #333));++ background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); color: var(--text-primary, #ececf1); font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; text-align: left; }++ .ovn-ac-link svg { width: 18px; height: 18px; flex: none; fill: none; stroke: var(--accent-light, var(--accent, #60a5fa)); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }++++ /* The panel (desktop popover, phone sheet) and the full inbox share one list view. */++ .openvibe-notif-panel { position: fixed; z-index: 99999; display: none; flex-direction: column; overflow: hidden;++ top: var(--ovn-top, 62px); right: max(8px, env(safe-area-inset-right, 0px)); width: min(420px, calc(100vw - 16px));++ max-height: calc(100vh - var(--ovn-top, 62px) - 12px); max-height: calc(100dvh - var(--ovn-top, 62px) - 12px - env(safe-area-inset-bottom, 0px));++ background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));++ border-radius: 18px; box-shadow: 0 24px 60px rgba(0,0,0,.45); animation: ovn-panel-in .2s cubic-bezier(.2,.8,.2,1); font-family: inherit; }++ .openvibe-notif-panel.open { display: flex; }++ .openvibe-notif-panel, .openvibe-notif-inbox, .openvibe-notif-dock { --ovn-bg: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }++ .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 .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; }++ .ovn-foot-read:hover { color: var(--text-primary, #ececf1); }++ .ovn-foot-read svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }++ .openvibe-notif-inbox { display: flex; flex-direction: column; overflow: hidden; min-height: 420px; max-height: 78vh; border-radius: 18px;++ background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1)); font-family: inherit; }++++ .openvibe-nv-head { padding: 14px 14px 10px 16px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }++ .openvibe-nv-head h3 { font-size: 17px; font-weight: 750; letter-spacing: -.01em; margin: 0; display: flex; align-items: center; gap: 8px; }++ .openvibe-nv-head h3 .count { font-size: 11px; font-weight: 750; background: var(--live-red, #e5484d); color: #fff; border-radius: 999px; padding: 2px 7px; font-variant-numeric: tabular-nums; }++ .openvibe-nv-head h3 .count.zero { display: none; }++ .openvibe-nv-actions { display: flex; gap: 2px; align-items: center; }++ .openvibe-nv-actions button, .openvibe-nv-actions a { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; border: none; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; text-decoration: none; }++ .openvibe-nv-actions button > span, .openvibe-nv-actions a > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }++ .openvibe-nv-actions button:hover, .openvibe-nv-actions a:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-primary, #ececf1); }++ .openvibe-nv-actions button.on { color: var(--accent-light, var(--accent, #60a5fa)); }++ .openvibe-nv-actions button:disabled { opacity: .4; cursor: default; }++ .openvibe-nv-actions svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }++++ .openvibe-nv-tools { padding: 0 12px; display: flex; flex-direction: column; gap: 6px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }++ .openvibe-nv-search { position: relative; }++ .openvibe-nv-search input { width: 100%; box-sizing: border-box; padding: 9px 30px 9px 34px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); background: var(--bg-input, var(--bg-tertiary, rgba(255,255,255,.04))); color: var(--text-primary, #ececf1); font: inherit; font-size: 13px; outline: none; }++ .openvibe-nv-search input:focus { border-color: var(--accent, #3b82f6); }++ .openvibe-nv-search > svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; fill: none; stroke: var(--text-muted, #8b8b96); stroke-width: 1.8; stroke-linecap: round; pointer-events: none; }++ .openvibe-nv-search .clear { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted, #8b8b96); cursor: pointer; font-size: 16px; display: none; }++ .openvibe-nv-search.has-q .clear { display: block; }++ .openvibe-nv-find { display: flex; gap: 8px; align-items: center; }++ .openvibe-nv-find .openvibe-nv-search { flex: 1; min-width: 0; }++ .openvibe-nv-unread { flex: none; display: inline-flex; align-items: center; gap: 6px; height: 37px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; }++ .openvibe-nv-unread:hover { color: var(--text-primary, #ececf1); }++ .openvibe-nv-unread[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, var(--on-accent, #fff)); }++ .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 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); }++ .openvibe-nv-tabs button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -3px; border-radius: 10px; }++ .openvibe-nv-tabs .n { min-width: 18px; padding: 2px 5px; box-sizing: border-box; border-radius: 999px; font-size: 10.5px; font-weight: 750; line-height: 1.2; text-align: center; font-variant-numeric: tabular-nums;++ color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--accent, #3b82f6) 24%, transparent); }++ .openvibe-nv-tabs .n:empty { display: none; }++ .openvibe-nv-tabs.is-more { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 36px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 36px), transparent); }++++ .openvibe-nv-list { flex: 1; overflow-y: auto; padding: 2px 0 6px; overscroll-behavior: contain; }++ .openvibe-nv-day { position: sticky; top: 0; z-index: 1; padding: 10px 16px 6px; font-size: 11.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted, #8b8b96); background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }++ /* A row: the opaque .ovn-row over .ovn-under, which a swipe uncovers (Read on the left, Clear on the right). */++ .openvibe-notif-item { position: relative; margin: 0 6px; border-radius: 14px; overflow: hidden; cursor: pointer; }++ .openvibe-notif-item .ovn-row { position: relative; z-index: 1; display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; padding: 10px 14px 10px 16px; border-radius: 14px;++ background: var(--ovn-bg, var(--bg-elevated, #1d1f27)); transition: background .15s, transform .2s ease; touch-action: pan-y; outline: none; }++ .openvibe-notif-item .ovn-row.has-thumb { grid-template-columns: 38px minmax(0, 1fr) 56px; }++ .openvibe-notif-item .ovn-row:focus-visible { box-shadow: inset 0 0 0 2px var(--accent, #3b82f6); }++ .openvibe-notif-item:hover .ovn-row { background: color-mix(in srgb, var(--text-primary, #ececf1) 5%, var(--ovn-bg, #1d1f27)); }++ .openvibe-notif-item.unread .ovn-row { background: color-mix(in srgb, var(--accent, #3b82f6) 8%, var(--ovn-bg, #1d1f27)); }++ .openvibe-notif-item.unread:hover .ovn-row { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, var(--ovn-bg, #1d1f27)); }++ .openvibe-notif-item.swiping .ovn-row { transition: none; }++ .openvibe-notif-item .thumb { width: 56px; height: 56px; border-radius: 10px; object-fit: cover; display: block; background: var(--bg-tertiary, #2a2b33); }++ .openvibe-notif-item .ovn-under { position: absolute; inset: 0; display: none; align-items: center; justify-content: space-between; padding: 0 22px; font-size: 13px; font-weight: 750; }++ .openvibe-notif-item[data-swipe] .ovn-under { display: flex; }++ .openvibe-notif-item[data-swipe="read"] .ovn-under { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); }++ .openvibe-notif-item[data-swipe="clear"] .ovn-under { background: var(--danger-strong, #c42b30); color: #fff; justify-content: flex-end; }++ .openvibe-notif-item[data-swipe="read"] .ovn-under .clear, .openvibe-notif-item[data-swipe="clear"] .ovn-under .read { display: none; }++ .openvibe-notif-item .ovn-under span { display: inline-flex; align-items: center; gap: 6px; }++ .openvibe-notif-item .ovn-under svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }++ .openvibe-notif-item .content { min-width: 0; }++ .openvibe-notif-item .title { font-size: 13.5px; font-weight: 550; line-height: 1.32; display: flex; align-items: baseline; gap: 6px; }++ .openvibe-notif-item .title .t { min-width: 0; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }++ .openvibe-notif-item.unread .title { font-weight: 700; }++ .openvibe-notif-item .title .count { flex: none; font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 50%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 15%, transparent); }++ .openvibe-notif-item .msg { margin-top: 2px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); line-height: 1.42; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }++ .openvibe-notif-item .meta { font-size: 11.5px; color: var(--text-muted, #8b8b96); margin-top: 5px; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }++ .openvibe-notif-item .meta .dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .6; }++ .openvibe-notif-item .unread-dot { position: absolute; left: 5px; top: 26px; width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); }++ .openvibe-notif-item .row-actions { margin-left: auto; display: flex; gap: 2px; opacity: 0; transition: opacity .15s; }++ .openvibe-notif-item:hover .row-actions, .openvibe-notif-item:focus-within .row-actions { opacity: 1; }++ /* The row's buttons sit on its time line, never over its text; touch screens always show them. */++ @media (hover: none), (pointer: coarse) { .openvibe-notif-item .row-actions { opacity: 1; } }++ .openvibe-notif-item .row-actions button { width: 28px; height: 28px; border-radius: 8px; border: none; background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; }++ .openvibe-notif-item .row-actions button:hover { color: var(--text-primary, #ececf1); background: var(--bg-hover, rgba(255,255,255,.08)); }++ .openvibe-notif-item .row-actions svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }++ .openvibe-notif-item .copy { font: inherit; font-size: 11.5px; font-weight: 650; margin-top: 7px; padding: 5px 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; }++ .openvibe-notif-item .copy:hover { color: var(--text-primary, #ececf1); }++ .openvibe-nv-empty { text-align: center; padding: 48px 24px 56px; color: var(--text-muted, #8b8b96); font-size: 13.5px; }++ .openvibe-nv-empty svg { width: 64px; height: 64px; display: block; margin: 0 auto 12px; fill: none; stroke: var(--accent, #3b82f6); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; opacity: .75; }++ .openvibe-nv-empty b { display: block; font-size: 15px; color: var(--text-primary, #ececf1); margin-bottom: 4px; }++ .openvibe-nv-more { display: flex; justify-content: center; padding: 12px; }++ .openvibe-nv-more button { border: 1px solid var(--border, rgba(255,255,255,.1)); background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12.5px; padding: 7px 16px; border-radius: 999px; cursor: pointer; }++ .openvibe-nv-more .spinner { width: 18px; height: 18px; border: 2px solid var(--border, rgba(255,255,255,.12)); border-top-color: var(--accent, #3b82f6); border-radius: 50%; animation: ovn-spin .8s linear infinite; }++ .openvibe-nv-foot { padding: 10px 16px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #8b8b96); display: flex; justify-content: space-between; align-items: center; gap: 8px; }++ .openvibe-nv-foot a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; font-weight: 650; }++ .openvibe-nv-note { font-size: 12px; color: var(--text-secondary, #b6b6c0); padding: 8px 16px; background: color-mix(in srgb, var(--warning, #f5a524) 12%, transparent); }++++ /* Phones: the panel is a full-height sheet over a scrim; its handle drags down to close, as do × and a tap outside. */++ .openvibe-nv-actions [data-act="close"] { display: none; }++ @media (max-width: 640px) {++ .openvibe-notif-panel { top: calc(env(safe-area-inset-top, 0px) + 10px); left: 0; right: 0; bottom: 0; width: auto; max-height: none; border-radius: 22px 22px 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); animation-name: ovn-sheet-up; animation-duration: .28s; }++ .openvibe-notif-panel .openvibe-nv-head { position: relative; padding-top: 20px; touch-action: none; }++ .openvibe-notif-panel .openvibe-nv-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))); }++ .openvibe-notif-panel .openvibe-nv-actions [data-act="close"] { display: grid; }++ .openvibe-notif-panel .openvibe-nv-actions [data-act="inbox"] { display: none; }++ .openvibe-nv-actions button, .openvibe-nv-actions a { width: 40px; height: 40px; }++ .openvibe-notif-item .row-actions { opacity: 1; }++ }++ 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; }++ }++ `;++ document.head.appendChild(style);++ }++++ // ── Helpers ─────────────────────────────────────────────++ function esc(v) {++ if (v == null) return '';++ return String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');++ }++ function richOf(n) {++ const rc = n && (n.rich_content ?? n.richContent);++ if (!rc) return {};++ if (typeof rc === 'object') return rc;++ try { return JSON.parse(rc) || {}; } catch { return {}; }++ }++ function urlOf(n) { return (n && n.url) || richOf(n).url || null; }++ function isRead(n) { return !!(n && (n.is_read === 1 || n.is_read === true || n.read === true)); }++ function parseDate(v) {++ if (!v) return null;++ // SQLite "YYYY-MM-DD HH:MM:SS" is UTC without a zone marker.++ const s = String(v);++ const d = new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s) ? s.replace(' ', 'T') + 'Z' : s);++ return isNaN(d.getTime()) ? null : d;++ }++ function timeAgo(v) {++ const d = parseDate(v); if (!d) return '';++ const diff = (Date.now() - d.getTime()) / 1000;++ if (diff < 45) return 'just now';++ if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))} min ago`;++ if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;++ if (diff < 604800) return `${Math.floor(diff / 86400)} d ago`;++ return d.toLocaleDateString();++ }++ /** "Today", "Yesterday", a weekday this week, else the date: the list's day headers. */++ function dayLabel(v) {++ const d = parseDate(v); if (!d) return 'Earlier';++ const start = new Date(); start.setHours(0, 0, 0, 0);++ const day = new Date(d.getTime()); day.setHours(0, 0, 0, 0);++ const days = Math.round((start - day) / 86400000);++ if (days <= 0) return 'Today';++ if (days === 1) return 'Yesterday';++ if (days < 7) { try { return d.toLocaleDateString(undefined, { weekday: 'long' }); } catch { return 'This week'; } }++ try { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } catch { return 'Earlier'; }++ }++ function inboxUrl() {++ if (_config.inboxUrl) return _config.inboxUrl;++ try { return new URL('/notifications', _config.apiBase).toString(); } catch { return 'https://openvibe.network/notifications'; }++ }++ function navigateTo(url) {++ if (!url) return;++ try {++ const u = new URL(url, window.location.href);++ window.location.href = u.toString();++ } catch { window.location.href = url; }++ }++ const svg = (paths) => `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;++ function isPhone() {++ try { return !!(root.matchMedia && root.matchMedia('(max-width: 640px)').matches); } catch { return false; }++ }++++ // ── What a notification says (one presentation for toasts and the list) ─────++ /** OpenVibeBrowserCheckFailed → "Browser check failed". */++ function humanize(name) {++ const s = String(name || '').replace(/^OpenVibe(?=[A-Z])/, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').trim();++ return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : 'Alert';++ }++ function isOpsAlert(n, rich) { return !!n && (n.type === 'OPERATOR_ALERT' || rich.kind === 'ops_alert'); }++ /**++ * An operator alert, from its structured fields (rich_content.kind "ops_alert": name, state, severity, sites,++ * release, summary, command) or, for older notices, read out of its title and message.++ */++ function presentOps(n, rich) {++ const title = String(n.title || '');++ const m = /^(Alert|Still firing|Resolved):\s*(.+)$/i.exec(title);++ const state = rich.state || (m ? (m[1].toLowerCase() === 'resolved' ? 'resolved' : (m[1].toLowerCase() === 'still firing' ? 'reminded' : 'firing')) : 'firing');++ const name = rich.name || (m ? m[2] : title);++ const raw = String(n.message || '');++ const command = rich.command || ((/run:\s*(sudo [^;]+?)(?:\.\s|;|\.$|$)/.exec(raw) || [])[1]) || null;++ const sites = Array.isArray(rich.sites) && rich.sites.length ? rich.sites.slice(0, 6)++ : [...new Set(((raw.match(/(?:^|\.\s+)([a-z0-9][a-z0-9.-]{1,30}):\s/g) || []).map(s => s.replace(/^\.\s+/, '').replace(/:\s$/, ''))))].slice(0, 6);++ const release = rich.release || ((/release ([0-9a-f]{7,40})/i.exec(raw) || [])[1] || null);++ const failing = (/\((\d+) failing check/i.exec(raw) || [])[1];++ let summary = rich.summary || raw++ .replace(/run:\s*sudo [^;]+?(?:\.\s|;|\.$|$)/g, '')++ .replace(/Firing since [^.]+\.?/g, '')++ .replace(/(?:^|\.\s+)[a-z0-9][a-z0-9.-]{1,30}:\s/g, ' ')++ .replace(/\s+/g, ' ').trim();++ if (release || failing) summary = [release ? `Release ${String(release).slice(0, 7)}` : null, failing ? `${failing} failing check${failing === '1' ? '' : 's'}` : null].filter(Boolean).join(' · ');++ const severity = rich.severity || (n.priority === 'critical' ? 'critical' : n.priority === 'high' ? 'warning' : 'info');++ const tone = state === 'resolved' ? 'ok' : severity === 'critical' ? 'danger' : 'warn';++ return {++ kind: 'ops', key: `ops:${name}:${state === 'resolved' ? 'resolved' : 'open'}`, tone, glyph: state === 'resolved' ? 'check' : 'pulse',++ title: (state === 'resolved' ? 'Resolved: ' : state === 'reminded' ? 'Still failing: ' : '') + humanize(name),++ message: summary.slice(0, 220), chips: sites, severity: state === 'resolved' ? 'resolved' : severity, command,++ label: 'System', url: rich.runbook_url || urlOf(n), actionLabel: 'Open status',++ };++ }++ 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) : [];++ 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,++ label: SERVICE_LABELS[n.service] || (n.service && n.service !== 'network' ? n.service : '') || CATEGORY_LABELS[cat] || '',++ url: urlOf(n), actionLabel: null,++ };++ }++ function avatarOf(n) {++ const rich = richOf(n);++ const img = n.sender_avatar || rich.thumbnail || (rich.user && rich.user.avatar_url);++ return img && /^https?:\/\//.test(String(img)) ? String(img) : null;++ }++ /** A picture for the right of a row (rich_content.image, thumbnail_url or thumbnail), unless it is the avatar. */++ function thumbOf(n) {++ const rich = richOf(n);++ const img = rich.image || rich.thumbnail_url || rich.thumbnail;++ if (!img || !/^https:\/\//.test(String(img))) return null;++ return String(img) === avatarOf(n) ? null : String(img);++ }++ function glyphHtml(n, p) {++ const tone = TONE[p.tone] || TONE.accent;++ const img = p.kind === 'ops' ? null : avatarOf(n);++ if (img) return `<span class="ovn-glyph ovn-avatar" style="--ovn-tone:${tone}"><img src="${esc(img)}" alt="" loading="lazy" onerror="this.remove()"><span class="ovn-badge">${svg(GLYPH[p.glyph] || GLYPH.bell)}</span></span>`;++ return `<span class="ovn-glyph" style="--ovn-tone:${tone}">${svg(GLYPH[p.glyph] || GLYPH.bell)}</span>`;++ }++ function chipsHtml(p, extra = []) {++ const sev = p.severity ? [`<span class="ovn-chip sev" style="--ovn-tone:${TONE[p.tone] || TONE.accent}">${esc(p.severity)}</span>`] : [];++ const all = sev.concat((p.chips || []).concat(extra).map(c => `<span class="ovn-chip">${esc(c)}</span>`));++ return all.length ? `<div class="ovn-chips">${all.join('')}</div>` : '';++ }++++ // ── API ─────────────────────────────────────────────────++ function isCrossOrigin() {++ try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }++ }++ function apiFetch(path, opts = {}) {++ const headers = { ...(opts.headers || {}) };++ if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;++ if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {++ headers['Content-Type'] = 'application/json';++ opts = { ...opts, body: JSON.stringify(opts.body) };++ }++ return fetch(`${_config.apiBase}${path}`, { ...opts, headers, credentials: isCrossOrigin() ? 'omit' : 'include' });++ }++ async function apiJson(path, opts) {++ const res = await apiFetch(path, opts);++ let data = null; try { data = await res.json(); } catch { /* */ }++ if (!res.ok) { const e = new Error((data && data.error) || `HTTP ${res.status}`); e.status = res.status; e.body = data; throw e; }++ return data || {};++ }++ function hasSession() { return !!_config.token || !isCrossOrigin(); }++++ // Quiet hours (OpenVibe.Network GET /api/notifications/quiet-hours, 4.30.0): during the person's own window, in their++ // time zone, a toast and its sound wait: the bell and the badge still fill, and a critical notice still shows. Read++ // once when the layer starts and again every ten minutes; a Network that does not answer means no quiet hours.++ let _quiet = null, _quietAt = 0;++ function loadQuiet() {++ if (!hasSession() || Date.now() - _quietAt < 10 * 60000) return;++ _quietAt = Date.now();++ apiJson('/api/notifications/quiet-hours').then((q) => { _quiet = q && typeof q.start === 'string' ? q : null; }).catch(() => { _quiet = null; });++ }++ const minutesOf = (s) => { const m = /^(\d{1,2}):(\d{2})$/.exec(String(s || '')); return m ? Number(m[1]) * 60 + Number(m[2]) : null; };++ function inQuiet() {++ loadQuiet();++ const q = _quiet;++ if (!q || !q.enabled) return false;++ const a = minutesOf(q.start), b = minutesOf(q.end);++ if (a == null || b == null || a === b) return false;++ let now;++ try {++ const parts = new Intl.DateTimeFormat('en-GB', { timeZone: q.tz || 'UTC', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date());++ now = Number(parts.find((x) => x.type === 'hour').value) * 60 + Number(parts.find((x) => x.type === 'minute').value);++ } catch { const d = new Date(); now = d.getUTCHours() * 60 + d.getUTCMinutes(); }++ return a < b ? now >= a && now < b : now >= a || now < b;++ }++ const held = (n) => (n && n.priority) !== 'critical' && inQuiet();++++ // ── Cross-tab sync (same origin) ────────────────────────++ function channel() {++ if (_channel !== null) return _channel || null;++ try { _channel = new BroadcastChannel('openvibe-notifications'); _channel.onmessage = (e) => onSyncMessage(e.data || {}); }++ catch { _channel = false; }++ return _channel || null;++ }++ function broadcast(msg) { try { channel()?.postMessage(msg); } catch { /* */ } }++ function onSyncMessage(msg) {++ 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); }++ }++++ // ── Sound ───────────────────────────────────────────────++ function playSound(priority) {++ if (!_preferences.sound) return;++ if (priority !== 'critical' && inQuiet()) return;++ const file = SOUNDS[priority]; if (!file) return;++ const url = `${_config.soundBase}/${file}`;++ try {++ if (!_audioCache[url]) _audioCache[url] = new Audio(url);++ const a = _audioCache[url]; a.currentTime = 0; a.volume = priority === 'critical' ? 0.8 : 0.5; a.play().catch(() => {});++ } catch { /* */ }++ }++++ // ── Layers: toasts never draw over an open menu, panel or dialog ─────────++ // What counts as open, read from the page itself: this file loads late (after the bell mounts), so an++ // 'openvibe-menu' event fired before it is missed; the menu's own class and every bar toggle's aria-expanded are not.++ const OPEN_LAYERS = '.ovs.is-open, .openvibe-navbar [aria-expanded="true"], .openvibe-notif-panel.open, dialog[open], [aria-modal="true"]:not([hidden])';++ function layerBusy() {++ if (_menuOpen) return true;++ try { if (root.OpenVibePanels && typeof root.OpenVibePanels.anyOpen === 'function' && root.OpenVibePanels.anyOpen()) return true; } catch { /* */ }++ if (document.body && document.body.classList && document.body.classList.contains('ov-panel-open')) return true;++ // Only what is drawn counts: a closed menu may keep aria-modal while it sits at display: none.++ try { for (const el of document.querySelectorAll(OPEN_LAYERS)) if (el.getClientRects().length) return true; } catch { /* */ }++ return false;++ }++ let _layerWatch = false;++ function watchLayers() {++ if (_layerWatch || typeof document === 'undefined') return;++ _layerWatch = true;++ document.addEventListener('openvibe-menu', (e) => { _menuOpen = !!(e && e.detail && e.detail.open); syncLayer(); });++ try {++ // aria-expanded and open change only when something opens or closes, so watching them page-wide is cheap.++ if (typeof root.MutationObserver === 'function' && document.body) new root.MutationObserver(() => syncLayer()).observe(document.body, { attributes: true, subtree: true, attributeFilter: ['aria-expanded', 'open', 'aria-modal'] });++ if (typeof root.MutationObserver === 'function' && document.body) new root.MutationObserver(() => syncLayer()).observe(document.body, { attributes: true, attributeFilter: ['class'] });++ } catch { /* */ }++ // Without an observer, any click may have opened or closed something: check right after it.++ document.addEventListener('click', () => Promise.resolve().then(syncLayer), true);++ }++ function syncLayer() {++ if (!_toastContainer) return;++ const busy = layerBusy();++ const was = _toastContainer.classList.contains('ovn-held');++ if (busy === was) return;++ _toastContainer.classList.toggle('ovn-held', busy);++ for (const el of _toastContainer.querySelectorAll('.openvibe-toast')) busy ? pauseToast(el) : resumeToast(el);++ if (!busy) flushQueue();++ }++++ // ── Toasts ──────────────────────────────────────────────++ function ensureToastContainer() {++ if (_toastContainer) return;++ _toastContainer = document.createElement('div');++ _toastContainer.className = 'openvibe-toast-container';++ _toastContainer.setAttribute('role', 'region');++ _toastContainer.setAttribute('aria-label', 'Notifications');++ _toastContainer.setAttribute('aria-live', 'polite');++ document.body.appendChild(_toastContainer);++ watchLayers();++ }++ function placeContainer() {++ const phone = isPhone();++ _toastContainer.classList.toggle('ovn-phone', phone);++ if (phone) {++ // Under whatever bar the site has; above it when the bar is hidden on scroll.++ const bar = document.querySelector('nav.openvibe-navbar, nav.navbar, header.navbar, .navbar');++ const r = bar && bar.getBoundingClientRect ? bar.getBoundingClientRect() : null;++ const top = r && r.bottom > 0 ? Math.round(r.bottom) + 8 : 10;++ _toastContainer.style.setProperty('--ovn-top', top + 'px');++ }++ }++ function visibleToasts() { return _toastContainer ? [..._toastContainer.querySelectorAll('.openvibe-toast:not(.removing)')] : []; }++ function maxVisible() { return isPhone() ? 1 : MAX_TOASTS; }++++ // Urgent first: a full stack makes room by letting a lower-priority card go, never a higher one.++ const RANK = { critical: 3, high: 2, normal: 1, low: 0 };++ const rank = (n) => RANK[(n && n.priority) || 'normal'] ?? 1;++ const _shownIds = new Set(); // one notification id is one card, however many times a poll or a push brings it++ function showToast(n) {++ if (!_preferences.toasts) return;++ if (held(n)) return;++ if (_preferences.muted_categories.includes(n.category)) return;++ const id = n && n.id != null ? String(n.id) : null;++ if (id) {++ if (_shownIds.has(id)) return;++ _shownIds.add(id);++ if (_shownIds.size > 500) _shownIds.delete(_shownIds.values().next().value);++ }++ ensureToastContainer();++ const p = present(n);++ // The same notice again within the window joins the card already showing (or waiting).++ const now = Date.now();++ const live = visibleToasts().find(el => el.__ovn && el.__ovn.key === p.key && now - el.__ovn.at < GROUP_WINDOW);++ if (live) { mergeToast(live, n, p); return; }++ const queued = _queue.find(q => q.p.key === p.key);++ if (queued) { queued.count += 1; queued.n = rank(n) >= rank(queued.n) ? n : queued.n; queued.chips = [...new Set(queued.chips.concat(p.chips))]; return; }++ _queue.push({ n, p, count: 1, chips: p.chips.slice() });++ _queue.sort((a, b) => rank(b.n) - rank(a.n)); // stable: arrival order within a priority++ flushQueue();++ }++ function flushQueue() {++ if (!_toastContainer || layerBusy()) { if (_toastContainer) _toastContainer.classList.toggle('ovn-held', layerBusy()); updateMore(); return; }++ placeContainer();++ while (_queue.length) {++ const vis = visibleToasts();++ if (vis.length < maxVisible()) { renderToast(_queue.shift()); continue; }++ const lowest = vis.reduce((a, el) => (rank(el.__ovn.n) < rank(a.__ovn.n) ? el : a), vis[0]);++ if (rank(_queue[0].n) > rank(lowest.__ovn.n)) { dismissToast(lowest); continue; }++ break;++ }++ updateMore();++ }++ /** "+N more" lives inside the card that is showing (a phone shows one at a time), never floating over the page. */++ function updateMore() {++ if (!_toastContainer) return;++ const waiting = _queue.length;++ const first = visibleToasts()[0];++ _toastContainer.querySelectorAll('.openvibe-toast-more').forEach((m) => { if (!waiting || !isPhone() || !first || !first.contains(m)) m.remove(); });++ if (!waiting || !isPhone() || !first) return;++ let more = first.querySelector('.openvibe-toast-more');++ if (!more) {++ more = document.createElement('button');++ more.type = 'button'; more.className = 'openvibe-toast-more';++ more.addEventListener('click', (e) => { e.stopPropagation(); const f = visibleToasts()[0]; if (f) dismissToast(f); else flushQueue(); });++ const sub = first.querySelector('.openvibe-toast-sub');++ (sub || first.querySelector('.openvibe-toast-body') || first).appendChild(more);++ }++ more.textContent = `+${waiting} more`;++ more.setAttribute('aria-label', `${waiting} more notification${waiting === 1 ? '' : 's'}: show the next`);++ }++ function renderToast(q) {++ const { n, p } = q;++ const toast = document.createElement('div');++ toast.className = `openvibe-toast priority-${esc(n.priority || 'normal')}`;++ toast.dataset.notifId = n.id || '';++ toast.tabIndex = 0;++ toast.style.setProperty('--ovn-tone', TONE[p.tone] || TONE.accent);++ if (n.priority === 'critical') toast.setAttribute('role', 'alert');++ toast.__ovn = { key: p.key, at: Date.now(), count: q.count, chips: q.chips.slice(), ids: n.id ? [n.id] : [], n };++ const rich = richOf(n);++ const custom = Array.isArray(rich.actions) ? rich.actions.filter(a => a && a.type !== 'input' && a.label).slice(0, 2) : [];++ const actions = custom.length ? custom : (p.url ? [{ id: 'open', label: p.actionLabel || 'View', url: p.url, style: 'primary' }] : []);++ toast.innerHTML = `++ ${glyphHtml(n, p)}++ <div class="openvibe-toast-body">++ <div class="openvibe-toast-title"><span class="t">${esc(p.title)}</span><span class="n" data-role="n"${q.count > 1 ? '' : ' hidden'}>×${q.count}</span></div>++ <div class="openvibe-toast-sub">${esc(p.label || '')}${p.label ? '<span aria-hidden="true">·</span>' : ''}<span>now</span></div>++ ${p.message ? `<div class="openvibe-toast-msg" data-role="msg">${esc(p.message)}</div>` : ''}++ <div data-role="chips">${chipsHtml({ ...p, chips: q.chips })}</div>++ ${actions.length ? `<div class="openvibe-toast-actions">${actions.map(a => a.url++ ? `<a href="${esc(a.url)}" data-action="${esc(a.id || 'open')}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</a>`++ : `<button type="button" data-action="${esc(a.id)}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</button>`).join('')}</div>` : ''}++ </div>++ <button type="button" class="openvibe-toast-close" aria-label="Dismiss">×</button>`;++ const duration = TOAST_DURATION[n.priority] ?? 5200;++ toast.__ovnDuration = duration;++ if (duration > 0) {++ const bar = document.createElement('div');++ bar.className = 'openvibe-toast-progress';++ toast.appendChild(bar);++ startTimer(toast, duration);++ }++ toast.querySelector('.openvibe-toast-close').addEventListener('click', (e) => { e.stopPropagation(); dismissToast(toast); });++ toast.addEventListener('click', (e) => {++ if (e.target.closest && e.target.closest('[data-action]')) return;++ const ids = toast.__ovn.ids; if (ids.length) markRead(ids);++ dismissToast(toast);++ if (p.url) navigateTo(p.url);++ });++ toast.addEventListener('keydown', (e) => { if (e.key === 'Escape') dismissToast(toast); });++ toast.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', (e) => {++ e.stopPropagation();++ const ids = toast.__ovn.ids; if (ids.length) markRead(ids);++ const a = actions.find(x => String(x.id || 'open') === btn.dataset.action);++ if (a && a.url) { e.preventDefault(); dismissToast(toast); navigateTo(a.url); return; }++ if (_config.onAction) _config.onAction(n.id, btn.dataset.action, n);++ dismissToast(toast);++ }));++ // Hover or focus holds the timer; a swipe sideways (or up, on a phone) dismisses.++ toast.addEventListener('mouseenter', () => pauseToast(toast));++ toast.addEventListener('mouseleave', () => resumeToast(toast));++ toast.addEventListener('focusin', () => pauseToast(toast));++ toast.addEventListener('focusout', () => resumeToast(toast));++ attachSwipe(toast);++ _toastContainer.appendChild(toast);++ }++ function mergeToast(el, n, p) {++ const st = el.__ovn;++ if (n.id && st.ids.includes(n.id)) return;++ st.count += 1; st.at = Date.now(); st.n = rank(n) >= rank(st.n) ? n : st.n;++ if (n.id) st.ids.push(n.id);++ st.chips = [...new Set(st.chips.concat(p.chips))].slice(0, 8);++ const c = el.querySelector('[data-role="n"]'); if (c) { c.textContent = `×${st.count}`; c.hidden = false; }++ const m = el.querySelector('[data-role="msg"]'); if (m && p.message) m.textContent = p.message;++ const ch = el.querySelector('[data-role="chips"]'); if (ch) ch.innerHTML = chipsHtml({ ...p, chips: st.chips });++ if (el.__ovnDuration > 0) startTimer(el, el.__ovnDuration);++ }++ function startTimer(el, ms) {++ clearTimeout(el.__ovnTimer);++ el.__ovnRemaining = ms; el.__ovnStarted = Date.now();++ const bar = el.querySelector('.openvibe-toast-progress');++ if (bar) { bar.style.transition = 'none'; bar.style.transform = 'scaleX(1)'; const go = () => { bar.style.transition = `transform ${ms}ms linear`; bar.style.transform = 'scaleX(0)'; }; (root.requestAnimationFrame || ((f) => f()))(go); }++ if (_toastContainer && _toastContainer.classList.contains('ovn-held')) { pauseToast(el); return; }++ el.__ovnTimer = setTimeout(() => dismissToast(el), ms);++ }++ function pauseToast(el) {++ if (!el || !el.__ovnTimer) return;++ clearTimeout(el.__ovnTimer); el.__ovnTimer = null;++ el.__ovnRemaining = Math.max(800, (el.__ovnRemaining || 0) - (Date.now() - (el.__ovnStarted || Date.now())));++ const bar = el.querySelector('.openvibe-toast-progress');++ if (bar && root.getComputedStyle) { try { const t = root.getComputedStyle(bar).transform; bar.style.transition = 'none'; bar.style.transform = t && t !== 'none' ? t : 'scaleX(1)'; } catch { /* */ } }++ }++ function resumeToast(el) {++ if (!el || el.__ovnTimer || !(el.__ovnDuration > 0) || el.classList.contains('removing')) return;++ if (_toastContainer && _toastContainer.classList.contains('ovn-held')) return;++ startTimer(el, el.__ovnRemaining || el.__ovnDuration);++ }++ function attachSwipe(el) {++ if (typeof root.PointerEvent !== 'function') return;++ let x0 = null, y0 = null, dx = 0, dy = 0;++ el.addEventListener('pointerdown', (e) => { x0 = e.clientX; y0 = e.clientY; dx = dy = 0; });++ el.addEventListener('pointermove', (e) => {++ if (x0 == null) return;++ dx = e.clientX - x0; dy = e.clientY - y0;++ if (Math.abs(dx) > 8 || dy < -8) { el.style.transition = 'none'; el.style.transform = Math.abs(dx) >= Math.abs(dy) ? `translateX(${dx}px)` : `translateY(${Math.min(0, dy)}px)`; el.style.opacity = String(Math.max(0.2, 1 - Math.max(Math.abs(dx), -dy) / 220)); }++ });++ const end = () => {++ if (x0 == null) return;++ const gone = Math.abs(dx) > 90 || (isPhone() && dy < -40);++ x0 = y0 = null;++ if (gone) { el.__ovnSwiped = true; dismissToast(el); return; }++ el.style.transition = 'transform .2s ease, opacity .2s ease'; el.style.transform = ''; el.style.opacity = '';++ };++ el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end);++ // A swipe is not a click on the card.++ el.addEventListener('click', (e) => { if (el.__ovnSwiped) { e.stopImmediatePropagation(); e.preventDefault(); } }, true);++ }++ function dismissToast(el) {++ if (!el || !el.parentNode || el.classList.contains('removing')) return;++ clearTimeout(el.__ovnTimer);++ el.classList.add('removing');++ setTimeout(() => { el.remove(); flushQueue(); }, 240);++ }++++ // ── The ✦: the Action Center, loaded from beside this file on the first click ──++ let _acLoading = null;++ function actionCenterSrc() {++ if (_config.actionCenterSrc) return _config.actionCenterSrc;++ const m = /^(https?:\/\/[^?#]*\/)notification-ui\.js(?:[?#]|$)/.exec(SELF_SRC);++ return `${m ? m[1] : 'https://openvibe.network/shared/'}action-center.js`;++ }++ function loadActionCenter() {++ if (root.OpenVibeActionCenter) return Promise.resolve(root.OpenVibeActionCenter);++ return _acLoading || (_acLoading = new Promise((ok) => {++ const sc = document.createElement('script');++ sc.src = actionCenterSrc(); sc.async = true;++ sc.onload = () => ok(root.OpenVibeActionCenter || null);++ sc.onerror = () => { _acLoading = null; ok(null); };++ document.head.appendChild(sc);++ }));++ }++ function openActions(anchor) {++ loadActionCenter().then((AC) => { if (AC) AC.toggle({ anchor, token: () => _config.token, apiBase: _config.apiBase }); });++ }++ function createActionsEl() {++ const b = document.createElement('div');++ b.className = 'openvibe-actions-btn';++ b.setAttribute('role', 'button'); b.setAttribute('aria-label', 'Agents'); b.setAttribute('aria-haspopup', 'dialog'); b.setAttribute('aria-expanded', 'false');++ b.title = 'Agents: everything your agents are doing'; b.tabIndex = 0;++ b.innerHTML = svg(GLYPH.spark);++ b.addEventListener('click', () => openActions(b));++ b.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openActions(b); } });++ return b;++ }++++ // ── Bell / badge ────────────────────────────────────────++ 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(GLYPH.bell)}<span class="badge hidden">0</span>`;++ bell.addEventListener('click', () => togglePanel());++ bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } });++ _bellEl = bell;++ return bell;++ }++ 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');++ }++ 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 });++ if (_unreadCount !== prev && listShowing()) loadTabCounts();++ }++++ // ── List views (panel + inbox share this) ───────────────++ const ICON = {++ search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',++ filter: '<path d="M4 6.5h16M7 12h10M10 17.5h4"/>',++ checkAll: '<path d="M2.5 12.5l4.5 4.5L17 7"/><path d="M11.5 16.5l.5.5L22 7"/>',++ bellOn: GLYPH.bell,++ bellOff: '<path d="M6.2 16.2V11a5.8 5.8 0 0 1 9.6-4.4M17.8 10.4v5.8l1.6 2H6"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0M3.5 3.5l17 17"/>',++ gear: GLYPH.gear,++ open: '<path d="M14 4h6v6M20 4l-9 9M19 14v5a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 4 19V7a1.5 1.5 0 0 1 1.5-1.5H10"/>',++ read: '<path d="M5 12.6l4.6 4.6L19 7.6"/>',++ close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',++ empty: '<path d="M18 37V26a14 14 0 0 1 28 0v11l4 5H14z"/><path d="M27 49a5 5 0 0 0 10 0"/><path d="M50 14l4-4M53 22h5M44 8l1-5"/>',++ };++++ function buildView(root, { mode }) {++ const v = { root, mode, tab: 'all', unread: false, q: '', items: [], offset: 0, hasMore: false, loading: false, seq: 0, ids: new Set() };++ root.innerHTML = `++ <div class="openvibe-nv-head">++ <h3>Notifications <span class="count zero">0</span></h3>++ <div class="openvibe-nv-actions">++ <button type="button" data-act="read-all" title="Mark all as read">${svg(ICON.checkAll)}<span>Mark all read</span></button>++ <button type="button" data-act="push" title="Push alerts on this device">${svg(ICON.bellOff)}<span>Push alerts</span></button>++ <a href="${esc(inboxUrl().replace(/\/notifications$/, '/notifications#prefs'))}" data-act="prefs" title="Notification settings">${svg(ICON.gear)}<span>Settings</span></a>++ ${mode === 'panel' ? `<a href="${esc(inboxUrl())}" data-act="inbox" title="Open the full inbox">${svg(ICON.open)}<span>Open inbox</span></a><button type="button" data-act="close" title="Close">${svg(ICON.close)}<span>Close</span></button>` : ''}++ </div>++ </div>++ <div class="openvibe-nv-note" data-role="note" style="display:none"></div>++ <div class="openvibe-nv-tools">++ <div class="openvibe-nv-find">++ <div class="openvibe-nv-search">${svg(ICON.search)}<input type="search" placeholder="Search notifications" aria-label="Search notifications" autocomplete="off" spellcheck="false"><button type="button" class="clear" aria-label="Clear search">×</button></div>++ <button type="button" class="openvibe-nv-unread" aria-pressed="false" title="Show only unread">${svg(ICON.filter)}Unread</button>++ </div>++ <div class="openvibe-nv-tabs" role="tablist" aria-label="Notifications">++ ${TABS.map(([id, label]) => `<button type="button" role="tab" data-tab="${id}" aria-selected="${id === 'all'}" tabindex="${id === 'all' ? '0' : '-1'}">${esc(label)}<span class="n" data-n="${id}"></span></button>`).join('')}++ </div>++ </div>++ <div class="openvibe-nv-list" data-role="list" role="tabpanel" aria-label="All"></div>++ <div class="openvibe-nv-foot"><span data-role="status"></span>${mode === 'dock' ? `<button type="button" class="ovn-foot-read" data-act="read-all-foot">${svg(ICON.checkAll)}Mark all read</button>` : ''}${mode === 'panel' || mode === 'dock' ? `<a href="${esc(inboxUrl())}">Full inbox →</a>` : ''}</div>`;++ const list = root.querySelector('[data-role="list"]');++ const input = root.querySelector('input[type="search"]');++ let qTimer = null;++ input.addEventListener('input', () => {++ root.querySelector('.openvibe-nv-search').classList.toggle('has-q', !!input.value);++ clearTimeout(qTimer); qTimer = setTimeout(() => { v.q = input.value.trim(); reload(v); }, 260);++ });++ root.querySelector('.openvibe-nv-search .clear').addEventListener('click', () => { input.value = ''; v.q = ''; root.querySelector('.openvibe-nv-search').classList.remove('has-q'); reload(v); });++ // Tabs: a click or the arrow keys (Home, End) pick one; the list reloads with Network's ?tab=.++ const tabs = [...root.querySelectorAll('.openvibe-nv-tabs [role="tab"]')];++ const selectTab = (btn, focus) => {++ tabs.forEach((b) => { const on = b === btn; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });++ if (focus) btn.focus();++ try { btn.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } catch { /* */ }++ v.tab = btn.dataset.tab;++ list.setAttribute('aria-label', (TABS.find(([id]) => id === v.tab) || ['', 'All'])[1]);++ reload(v);++ };++ tabs.forEach((btn) => btn.addEventListener('click', () => selectTab(btn)));++ const tabRow = root.querySelector('.openvibe-nv-tabs');++ const fade = () => { tabRow.classList.toggle('is-more', tabRow.scrollLeft + tabRow.clientWidth < tabRow.scrollWidth - 4); };++ tabRow.addEventListener('scroll', fade, { passive: true });++ v.fade = fade;++ root.querySelector('.openvibe-nv-tabs').addEventListener('keydown', (e) => {++ const i = tabs.indexOf(document.activeElement);++ if (i < 0) return;++ const j = e.key === 'ArrowRight' ? (i + 1) % tabs.length : e.key === 'ArrowLeft' ? (i - 1 + tabs.length) % tabs.length : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : -1;++ if (j >= 0) { e.preventDefault(); selectTab(tabs[j], true); }++ });++ const unreadBtn = root.querySelector('.openvibe-nv-unread');++ unreadBtn.addEventListener('click', () => { v.unread = !v.unread; unreadBtn.setAttribute('aria-pressed', String(v.unread)); reload(v); });++ root.querySelector('[data-act="read-all"]').addEventListener('click', () => markAllRead());++ const footRead = root.querySelector('[data-act="read-all-foot"]'); if (footRead) footRead.addEventListener('click', () => markAllRead());++ root.querySelector('[data-act="push"]').addEventListener('click', () => togglePush(v));++ const closeBtn = root.querySelector('[data-act="close"]');++ if (closeBtn) closeBtn.addEventListener('click', () => closePanel());++ // Infinite scroll: load the next page when the user nears the bottom.++ list.addEventListener('scroll', () => {++ if (v.loading || !v.hasMore) return;++ if (list.scrollTop + list.clientHeight >= list.scrollHeight - 120) loadMore(v);++ });++ list.addEventListener('click', (e) => {++ if (e.target.closest('[data-act="ac"]')) { const anchor = _bellEl; closePanel(); openActions(anchor); return; }++ const item = e.target.closest('.openvibe-notif-item');++ if (!item || (item.__ovnSwipedAt && Date.now() - item.__ovnSwipedAt < 450)) return;++ const id = item.dataset.id;++ const ids = (item.dataset.ids || id).split(',').filter(Boolean);++ const act = e.target.closest('[data-row]');++ if (act) {++ e.stopPropagation();++ if (act.dataset.row === 'dismiss') ids.forEach(x => dismiss(v, x));++ else if (act.dataset.row === 'read') markRead(ids);++ else if (act.dataset.row === 'copy') copyText(act.dataset.cmd || '', act);++ return;++ }++ const n = v.items.find(x => String(x.id) === id);++ const unreadIds = ids.filter(x => { const it = v.items.find(y => String(y.id) === x); return it && !isRead(it); });++ if (unreadIds.length) markRead(unreadIds);++ const u = n && present(n).url;++ if (u) navigateTo(u);++ });++ list.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.classList && e.target.classList.contains('ovn-row')) { e.preventDefault(); e.target.click(); } });++ // A thumbnail or avatar that does not load leaves its row (no inline handlers: a site's CSP may forbid them).++ list.addEventListener('error', (e) => {++ const img = e.target;++ if (!img || img.tagName !== 'IMG') return;++ const row = img.closest('.ovn-row');++ if (img.classList.contains('thumb') && row) row.classList.remove('has-thumb');++ img.remove();++ }, true);++ attachRowSwipe(v, list);++ refreshPushButton(v);++ _views.set(root, v);++ paintTabCounts();++ return v;++ }++ /**++ * Touch: a row follows the finger sideways. Past a third of its width (at most 96 px), right marks it read and left++ * clears it (Network's dismiss); short of that it springs back. A vertical move is the list scrolling, not a swipe.++ */++ function attachRowSwipe(v, list) {++ if (typeof root.PointerEvent !== 'function') return;++ let s = null;++ list.addEventListener('pointerdown', (e) => {++ if (e.pointerType === 'mouse') return;++ const row = e.target.closest && e.target.closest('.ovn-row');++ if (!row || e.target.closest('button, a')) return;++ s = { row, item: row.parentNode, x0: e.clientX, y0: e.clientY, dx: 0, on: false, id: e.pointerId };++ });++ list.addEventListener('pointermove', (e) => {++ if (!s || e.pointerId !== s.id) return;++ const dx = e.clientX - s.x0, dy = e.clientY - s.y0;++ if (!s.on) {++ if (Math.abs(dy) > 10 && Math.abs(dy) > Math.abs(dx)) { s = null; return; }++ if (Math.abs(dx) < 12) return;++ s.on = true; s.item.classList.add('swiping');++ try { s.row.setPointerCapture(e.pointerId); } catch { /* */ }++ }++ // Already read: a short give to the right, nothing to do.++ s.dx = dx > 0 && !s.item.classList.contains('unread') ? Math.min(dx, 24) : dx;++ s.item.dataset.swipe = s.dx > 0 ? 'read' : 'clear';++ s.row.style.transform = `translateX(${s.dx}px)`;++ });++ const end = () => {++ if (!s) return;++ const { row, item, dx, on } = s; s = null;++ item.classList.remove('swiping');++ if (!on) return;++ item.__ovnSwipedAt = Date.now();++ const ids = (item.dataset.ids || item.dataset.id || '').split(',').filter(Boolean);++ const w = row.offsetWidth || 320;++ const far = Math.min(96, w / 3);++ const settle = () => setTimeout(() => { if (!item.classList.contains('swiping')) delete item.dataset.swipe; }, 220);++ if (dx < -far) { row.style.transform = `translateX(${-w}px)`; setTimeout(() => ids.forEach((x) => dismiss(v, x)), 180); return; }++ row.style.transform = '';++ if (dx > far && item.classList.contains('unread')) markRead(ids);++ settle();++ };++ list.addEventListener('pointerup', end);++ list.addEventListener('pointercancel', end);++ }++ function copyText(text, btn) {++ const done = () => { if (btn) { const was = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = was; }, 1400); } };++ try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, () => {}); return; } } catch { /* */ }++ done();++ }++++ function renderItems(v, { append = false } = {}) {++ const list = v.root.querySelector('[data-role="list"]');++ // The same notice repeated back to back ("X is live!" six times; three browser checks failing) reads as one++ // row with a count and the sites it covers.++ const groups = [];++ for (const n of v.items) {++ const p = present(n);++ const g = groups[groups.length - 1];++ if (g && g.p.key === p.key) {++ g.ids.push(String(n.id)); if (!isRead(n)) g.unread = true;++ g.chips = [...new Set(g.chips.concat(p.chips))].slice(0, 8);++ if (!g.cmd && p.command) g.cmd = p.command;++ } else groups.push({ n, p, ids: [String(n.id)], unread: !isRead(n), chips: p.chips.slice(), cmd: p.command });++ }++ let lastDay = append ? v.lastDay : null;++ const html = groups.map(({ n, p, ids, unread, chips, cmd }) => {++ try {++ const day = dayLabel(n.created_at);++ const head = day !== lastDay ? `<div class="openvibe-nv-day">${esc(day)}</div>` : '';++ lastDay = day;++ const when = esc(timeAgo(n.created_at));++ const label = p.label ? `<span class="dot"></span><span>${esc(p.label)}</span>` : '';++ const thumb = p.kind === 'ops' ? null : thumbOf(n);++ return `${head}<div class="openvibe-notif-item ${unread ? 'unread' : ''}" data-id="${esc(n.id)}" data-ids="${esc(ids.join(','))}" style="--ovn-tone:${TONE[p.tone] || TONE.accent}">++ <div class="ovn-under" aria-hidden="true"><span class="read">${svg(ICON.read)}Read</span><span class="clear">Clear${svg(ICON.close)}</span></div>++ <div class="ovn-row${thumb ? ' has-thumb' : ''}" tabindex="0">++ ${unread ? '<span class="unread-dot" role="img" aria-label="Unread"></span>' : ''}++ ${glyphHtml(n, p)}++ <div class="content">++ <div class="title"><span class="t">${esc(p.title)}</span>${ids.length > 1 ? `<span class="count" title="${ids.length} times">×${ids.length}</span>` : ''}</div>++ ${p.message ? `<div class="msg">${esc(p.message)}</div>` : ''}++ ${chipsHtml({ ...p, chips })}++ ${cmd ? `<button type="button" class="copy" data-row="copy" data-cmd="${esc(cmd)}">Copy fix command</button>` : ''}++ <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${when}</span>${label}<div class="row-actions">${unread ? `<button type="button" data-row="read" title="Mark as read" aria-label="Mark as read">${svg(ICON.read)}</button>` : ''}<button type="button" data-row="dismiss" title="Clear" aria-label="Clear">${svg(ICON.close)}</button></div></div>++ </div>++ ${thumb ? `<img class="thumb" src="${esc(thumb)}" alt="" loading="lazy">` : ''}++ </div>++ </div>`;++ } catch { return ''; }++ }).join('');++ v.lastDay = lastDay;++ if (!append) list.innerHTML = v.tab === 'agents' ? `<button type="button" class="ovn-ac-link" data-act="ac">${svg(GLYPH.spark)}<span>Everything your agents are doing: running, waiting for you, done</span></button>` : '';++ if (!v.items.length) {++ const [t0, s0] = TAB_EMPTY[v.tab] || TAB_EMPTY.all;++ const acLink = v.tab === 'agents' ? `<button type="button" class="ovn-ac-link" data-act="ac">${svg(GLYPH.spark)}<span>Everything your agents are doing: running, waiting for you, done</span></button>` : '';++ const title = v.q ? 'Nothing matches that search' : (v.unread ? 'You\'re all caught up' : t0);++ const sub = v.q ? 'Try other words.' : (v.unread ? 'Nothing unread here.' : s0);++ list.innerHTML = `${acLink}<div class="openvibe-nv-empty"><svg viewBox="0 0 64 64" aria-hidden="true">${ICON.empty}</svg><b>${esc(title)}</b>${esc(sub)}</div>`;++ } else {++ const old = list.querySelector('.openvibe-nv-more'); if (old) old.remove();++ list.insertAdjacentHTML('beforeend', html);++ if (v.hasMore) list.insertAdjacentHTML('beforeend', `<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>`);++ const more = list.querySelector('[data-act="more"]'); if (more) more.addEventListener('click', () => loadMore(v));++ }++ const st = v.root.querySelector('[data-role="status"]');++ if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.unread ? ' unread' : ''}` : '';++ }++++ async function fetchPage(v, offset) {++ const params = new URLSearchParams({ limit: String(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);++ return apiJson(`/api/notifications?${params}`);++ }++ async function reload(v) {++ const seq = ++v.seq;++ loadTabCounts();++ if (v.fade) setTimeout(v.fade, 0);++ v.loading = true; v.offset = 0; v.items = []; v.ids = new Set(); v.hasMore = false; v.total = null; v.lastDay = null;++ const list = v.root.querySelector('[data-role="list"]');++ list.innerHTML = '<div class="openvibe-nv-more"><span class="spinner"></span></div>';++ try {++ const data = await fetchPage(v, 0);++ 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;++ renderItems(v);++ } catch (e) {++ if (seq !== v.seq) return;++ list.innerHTML = `<div class="openvibe-nv-empty"><svg viewBox="0 0 64 64" aria-hidden="true">${ICON.empty}</svg><b>${e.status === 401 ? 'Sign in to see your notifications' : 'Could not load notifications'}</b>${e.status === 401 ? '' : 'Check your connection and try again.'}</div>`;++ } finally { if (seq === v.seq) v.loading = false; }++ }++ async function loadMore(v) {++ if (v.loading || !v.hasMore) return;++ const seq = v.seq; v.loading = true;++ const more = v.root.querySelector('.openvibe-nv-more'); if (more) more.innerHTML = '<span class="spinner"></span>';++ try {++ const data = await fetchPage(v, v.offset);++ if (seq !== v.seq) return;++ const fresh = (data.notifications || []).filter(n => !v.ids.has(String(n.id)));++ fresh.forEach(n => v.ids.add(String(n.id)));++ v.items = v.items.concat(fresh); v.hasMore = !!data.has_more; v.offset += (data.notifications || []).length; v.total = data.total ?? v.total;++ // Re-render only the tail to keep the scroll position.++ const old = v.root.querySelector('.openvibe-nv-more'); if (old) old.remove();++ if (fresh.length) { const tail = { ...v, items: fresh }; renderItems(tail, { append: true }); v.lastDay = tail.lastDay; }++ else if (v.hasMore) { const list = v.root.querySelector('[data-role="list"]'); list.insertAdjacentHTML('beforeend', '<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>'); list.querySelector('[data-act="more"]').addEventListener('click', () => loadMore(v)); }++ const st = v.root.querySelector('[data-role="status"]');++ if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.unread ? ' unread' : ''}` : '';++ } catch { /* keep what we have */ }++ finally { v.loading = false; }++ }++ function applyReadLocally(v, ids, all) {++ const set = new Set((ids || []).map(String));++ for (const n of v.items) if (all || set.has(String(n.id))) n.is_read = 1;++ v.root.querySelectorAll('.openvibe-notif-item.unread').forEach(el => {++ const elIds = (el.dataset.ids || el.dataset.id || '').split(',');++ if (all || elIds.some(x => set.has(x))) { el.classList.remove('unread'); el.querySelector('.unread-dot')?.remove(); el.querySelector('[data-row="read"]')?.remove(); }++ });++ }++++ // The counts on the tabs (Network GET /api/notifications/unread-by-tab, 4.30.0): read when a list loads, after a read++ // or a clear, and when the badge changes with a list showing; a Network without the route shows none.++ let _tabCounts = null, _tabAt = 0;++ function loadTabCounts(force) {++ if (!hasSession() || !_views.size) return;++ if (!force && Date.now() - _tabAt < 4000) return;++ _tabAt = Date.now();++ apiJson('/api/notifications/unread-by-tab').then((d) => { _tabCounts = (d && d.tabs) || null; paintTabCounts(); }).catch(() => { _tabCounts = null; paintTabCounts(); });++ }++ function paintTabCounts() {++ for (const v of _views.values()) {++ for (const [id, label] of TABS) {++ const el = v.root.querySelector(`[data-n="${id}"]`);++ if (!el) continue;++ const n = id === 'all' || !_tabCounts ? 0 : Math.max(0, Number(_tabCounts[id]) || 0);++ el.textContent = n ? (n > 99 ? '99+' : String(n)) : '';++ el.parentNode.setAttribute('aria-label', n ? `${label}, ${n} unread` : label);++ }++ }++ }++ function listShowing() { return [..._views.values()].some((v) => v.mode !== 'panel' || (_panelEl && _panelEl.classList.contains('open'))); }++++ // ── Mutations ───────────────────────────────────────────++ async function markRead(ids) {++ ids = (ids || []).map(String).filter(Boolean);++ if (!ids.length) return;++ for (const v of _views.values()) applyReadLocally(v, ids, false);++ broadcast({ type: 'read', ids });++ try {++ const data = ids.length === 1++ ? await apiJson(`/api/notifications/${encodeURIComponent(ids[0])}/read`, { method: 'POST' })++ : await apiJson('/api/notifications/read-batch', { method: 'POST', body: { ids } });++ if (data && typeof data.unread === 'number') setBadge(data.unread, { quiet: true }), broadcast({ type: 'unread', count: data.unread });++ else pollUnread();++ } catch { pollUnread(); }++ loadTabCounts(true);++ }++ async function markAllRead() {++ for (const v of _views.values()) applyReadLocally(v, [], true);++ setBadge(0);++ if (_tabCounts) { _tabCounts = Object.fromEntries(Object.keys(_tabCounts).map((k) => [k, 0])); paintTabCounts(); }++ broadcast({ type: 'read', all: true });++ try { await apiJson('/api/notifications/read-all', { method: 'POST', body: {} }); } catch { pollUnread(); }++ loadTabCounts(true);++ }++ async function dismiss(v, id) {++ const n = v.items.find(x => String(x.id) === String(id));++ v.items = v.items.filter(x => String(x.id) !== String(id));++ const row = v.root.querySelector(`.openvibe-notif-item[data-id="${CSS.escape(String(id))}"]`);++ if (row) {++ const rest = (row.dataset.ids || '').split(',').filter(x => x && x !== String(id));++ if (rest.length) { row.dataset.ids = rest.join(','); row.dataset.id = rest[0]; const c = row.querySelector('.title .count'); if (c) { if (rest.length > 1) c.textContent = `×${rest.length}`; else c.remove(); } }++ else row.remove();++ }++ if (v.total != null) v.total = Math.max(0, v.total - 1);++ if (!v.items.length) renderItems(v);++ try {++ await apiJson(`/api/notifications/${encodeURIComponent(id)}/dismiss`, { method: 'POST' });++ if (n && !isRead(n)) { setBadge(Math.max(0, _unreadCount - 1), { quiet: true }); loadTabCounts(true); }++ } catch { /* */ }++ }++++ // ── Polling / toasts ────────────────────────────────────++ let _polling = false;++ async function pollUnread() {++ if (_polling || !hasSession()) return;++ _polling = true;++ _lastPoll = Date.now();++ try {++ const data = await apiJson('/api/notifications/unread-count');++ const prev = _unreadCount;++ const count = Number(data.count) || 0;++ setBadge(count);++ if (count > prev && _lastSeenAt) {++ // Something new arrived: fetch what's newer than the last thing we saw.++ const params = new URLSearchParams({ unread_only: '1', limit: '5', since: _lastSeenAt });++ const nd = await apiJson(`/api/notifications?${params}`);++ // Only what is unread and newer than the last thing seen, whatever the server answered.++ const since = _lastSeenAt;++ const fresh = (nd.notifications || []).filter(n => n && !n.is_read && (!since || String(n.created_at || '') > since)).slice().reverse();++ for (const n of fresh) { showToast(n); playSound(n.priority); }++ // Any open list should reflect the new items too.++ for (const v of _views.values()) if (v.mode === 'inbox' || (_panelEl && _panelEl.classList.contains('open'))) reload(v);++ }++ const newest = await newestCreatedAt();++ if (newest) _lastSeenAt = newest;++ } catch { /* offline / signed out — try again next tick */ }++ finally { _polling = false; }++ }++ async function newestCreatedAt() {++ try { const d = await apiJson('/api/notifications/newest'); const c = d.notification && d.notification.created_at; return c ? String(c) : null; } catch { return null; }++ }++ function startPolling() {++ stopPolling();++ if (!hasSession()) return;++ newestCreatedAt().then(c => { _lastSeenAt = c || new Date().toISOString(); pollUnread(); });++ _pollTimer = setInterval(() => { if (!liveOpen() || Date.now() - _lastPoll >= REALTIME_POLL) pollUnread(); }, POLL_INTERVAL);++ }++ function stopPolling() { if (_pollTimer) clearInterval(_pollTimer); _pollTimer = null; }++++ // ── 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. */++ function soon(all) {++ _soonAll = _soonAll || all;++ if (_soon) return;++ _soon = setTimeout(() => {++ _soon = null;++ if (_polling) { soon(false); return; } // a poll in flight may predate the event: read again after it++ const lists = _soonAll; _soonAll = false;++ pollUnread();++ if (lists) for (const v of _views.values()) reload(v);++ }, 300);++ }++ 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`;++ }++ let _liveLoading = null;++ function loadLive() {++ if (root.OVNotificationLive) return Promise.resolve(root.OVNotificationLive);++ return _liveLoading || (_liveLoading = new Promise((ok) => {++ const sc = document.createElement('script');++ sc.src = liveSrc(); sc.async = true;++ sc.onload = () => ok(root.OVNotificationLive || null);++ sc.onerror = () => { _liveLoading = null; ok(null); };++ document.head.appendChild(sc);++ }));++ }++ function startRealtime() {++ if (!_config.realtime || !hasSession() || typeof root.EventSource !== 'function') return;++ if (_live) { _live.restart(); return; }++ loadLive().then((L) => {++ if (!L || _live || !_config.realtime || !hasSession()) return;++ _live = L.create({++ ticketUrl: `${_config.apiBase}/api/v1/realtime/ticket`,++ token: () => _config.token,++ credentials: isCrossOrigin() ? 'omit' : 'include',++ onNotification: () => soon(false),++ onGap: () => soon(true),++ });++ _live.start();++ });++ }++ function stopRealtime() { if (_live) _live.stop(); }++ document.addEventListener('visibilitychange', () => { if (!document.hidden) pollUnread(); });++++ // ── Panel ───────────────────────────────────────────────++ function createPanel() {++ const panel = document.createElement('div');++ panel.className = 'openvibe-notif-panel';++ panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Notifications');++ document.body.appendChild(panel);++ buildView(panel, { mode: 'panel' });++ attachSheetDrag(panel);++ document.addEventListener('click', (e) => {++ if (panel.classList.contains('open') && !panel.contains(e.target) && !(_bellEl && _bellEl.contains(e.target))) closePanel();++ });++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && panel.classList.contains('open')) closePanel(); });++ _panelEl = panel;++ return panel;++ }++ function closePanel() {++ if (_panelEl && _panelEl.contains(document.activeElement) && _bellEl) { try { _bellEl.focus({ preventScroll: true }); } catch { /* */ } }++ _panelEl?.classList.remove('open'); _bellEl?.classList.remove('open'); _bellEl?.setAttribute('aria-expanded', 'false');++ if (_panelEl) _panelEl.style.transform = '';++ document.documentElement.classList.remove('ovn-sheet-open');++ syncLayer();++ }++ /** Phones: the sheet's head drags down; past 110 px it closes, short of that it springs back. */++ function attachSheetDrag(panel) {++ const head = panel.querySelector('.openvibe-nv-head');++ if (!head || typeof root.PointerEvent !== 'function') return;++ let y0 = null, dy = 0;++ head.addEventListener('pointerdown', (e) => {++ if (!isPhone() || e.pointerType === 'mouse' || e.target.closest('button, a')) return;++ y0 = e.clientY; dy = 0; panel.style.transition = 'none';++ try { head.setPointerCapture(e.pointerId); } catch { /* */ }++ });++ head.addEventListener('pointermove', (e) => { if (y0 == null) return; dy = Math.max(0, e.clientY - y0); panel.style.transform = `translateY(${dy}px)`; });++ const end = () => {++ if (y0 == null) return;++ y0 = null;++ panel.style.transition = 'transform .2s ease';++ if (dy > 110) closePanel(); else panel.style.transform = '';++ setTimeout(() => { panel.style.transition = ''; }, 220);++ };++ head.addEventListener('pointerup', end);++ head.addEventListener('pointercancel', end);++ }++ function togglePanel() {++ if (!_panelEl) createPanel();++ const opening = !_panelEl.classList.contains('open');++ if (opening && window.OpenVibePanels && !_panelEl.__registered) { _panelEl.__registered = true; window.OpenVibePanels.register({ el: _panelEl, openClass: 'open', id: 'notifications', close: () => { if (_panelEl.classList.contains('open')) togglePanel(); } }); window.OpenVibePanels.closeAll(_panelEl); }++ if (opening) {++ // Sit directly under the site's navbar, whatever its height (sticky bars, banners, safe areas).++ const bar = _bellEl && _bellEl.closest('nav, header, .navbar');++ const bottom = bar ? bar.getBoundingClientRect().bottom : (_bellEl ? _bellEl.getBoundingClientRect().bottom : 56);++ _panelEl.style.setProperty('--ovn-top', Math.max(8, Math.round(bottom) + 6) + 'px');++ if (!_panelEl.__esc) { _panelEl.__esc = (e) => { if (e.key === 'Escape' && _panelEl.classList.contains('open')) togglePanel(); }; document.addEventListener('keydown', _panelEl.__esc); }++ }++ if (!opening) { closePanel(); return; }++ _panelEl.classList.add('open');++ _bellEl?.classList.add('open');++ _bellEl?.setAttribute('aria-expanded', 'true');++ document.documentElement.classList.toggle('ovn-sheet-open', isPhone());++ syncLayer();++ reload(_views.get(_panelEl)); refreshPushButton(_views.get(_panelEl)); pollUnread();++ // Focus the chosen tab, so the keyboard starts in the panel (Escape and the bell bring it back).++ setTimeout(() => { try { _panelEl.querySelector('[role="tab"][aria-selected="true"]')?.focus({ preventScroll: true }); } catch { /* */ } }, 0);++ }++++ // ── Web Push ────────────────────────────────────────────++ function pushSupported() { return typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window && window.isSecureContext; }++ function urlBase64ToUint8Array(b64) {++ const padding = '='.repeat((4 - (b64.length % 4)) % 4);++ const base64 = (b64 + padding).replace(/-/g, '+').replace(/_/g, '/');++ const raw = window.atob(base64); const out = new Uint8Array(raw.length);++ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);++ return out;++ }++ async function currentSubscription() {++ if (!pushSupported()) return null;++ try { const reg = await navigator.serviceWorker.getRegistration(_config.swPath); return reg ? await reg.pushManager.getSubscription() : null; } catch { return null; }++ }++ async function pushStatus() {++ const supported = pushSupported();++ const sub = supported ? await currentSubscription() : null;++ return { supported, permission: supported ? Notification.permission : 'unsupported', subscribed: !!sub };++ }++ async function enablePush() {++ if (!pushSupported()) throw new Error('This browser does not support push notifications');++ if (!hasSession()) throw new Error('Sign in first');++ const perm = await Notification.requestPermission();++ if (perm !== 'granted') throw new Error(perm === 'denied' ? 'Notifications are blocked for this site in your browser settings' : 'Permission not granted');++ const reg = await navigator.serviceWorker.register(_config.swPath, { scope: '/' });++ await navigator.serviceWorker.ready;++ const { publicKey } = await apiJson('/api/push/vapid-key');++ if (!publicKey) throw new Error('Push is not configured on the server');++ let sub = await reg.pushManager.getSubscription();++ if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) });++ await apiJson('/api/push/subscribe', { method: 'POST', body: { subscription: { ...sub.toJSON(), userAgent: navigator.userAgent.slice(0, 200) } } });++ try { localStorage.setItem('ov_push_enabled', '1'); } catch { /* */ }++ return true;++ }++ async function disablePush() {++ const sub = await currentSubscription();++ if (sub) {++ try { await apiJson('/api/push/unsubscribe', { method: 'POST', body: { endpoint: sub.endpoint } }); } catch { /* */ }++ try { await sub.unsubscribe(); } catch { /* */ }++ }++ try { localStorage.removeItem('ov_push_enabled'); } catch { /* */ }++ return true;++ }++ async function refreshPushButton(v) {++ const btn = v && v.root.querySelector('[data-act="push"]');++ if (!btn) return;++ const st = await pushStatus();++ if (!st.supported) { btn.style.display = 'none'; return; }++ btn.style.display = '';++ btn.classList.toggle('on', st.subscribed);++ btn.innerHTML = st.subscribed ? `${svg(ICON.bellOn)}<span>Push on</span>` : `${svg(ICON.bellOff)}<span>Enable push</span>`;++ btn.title = st.subscribed ? 'Push alerts are on for this device: select to turn off' : (st.permission === 'denied' ? 'Notifications are blocked in your browser settings' : 'Get alerts on this device even when the site is closed');++ btn.disabled = st.permission === 'denied' && !st.subscribed;++ }++ async function togglePush(v) {++ const note = v.root.querySelector('[data-role="note"]');++ const say = (msg, ms = 4000) => { if (!note) return; note.textContent = msg; note.style.display = ''; clearTimeout(note._t); note._t = setTimeout(() => { note.style.display = 'none'; }, ms); };++ try {++ const st = await pushStatus();++ if (st.subscribed) { await disablePush(); say('Push alerts are off for this device.'); }++ else { await enablePush(); say('Push alerts are on: you\'ll get them here even when this site is closed.'); }++ } catch (e) { say(e.message || 'Could not change push setting', 6000); }++ for (const view of _views.values()) refreshPushButton(view);++ }++++ // ── Public API ──────────────────────────────────────────++ const OpenVibeNotifications = {++ init(opts = {}) {++ Object.assign(_config, opts);++ _quietAt = 0; loadQuiet(); // quiet hours before the first notice can arrive++ injectStyles();++ if (opts.preferences) Object.assign(_preferences, opts.preferences);++ channel();++ startPolling();++ startRealtime();++ // If the user enabled push on this device before, make sure the worker is registered.++ try { if (pushSupported() && localStorage.getItem('ov_push_enabled') === '1') navigator.serviceWorker.register(_config.swPath, { scope: '/' }).catch(() => {}); } catch { /* */ }++ },++ destroy() {++ stopPolling();++ stopRealtime();++ _toastContainer?.remove(); _panelEl?.remove();++ _toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null; _queue.length = 0;++ },++ /** Create and inject the bell icon. Returns the DOM element. */++ createBell(container) {++ injectStyles();++ const bell = createBellEl();++ if (container && _config.actions !== false && !container.querySelector('.openvibe-actions-btn')) container.appendChild(createActionsEl());++ if (container) container.appendChild(bell);++ pollUnread();++ return bell;++ },++ /** Open the Action Center (the ✦) from a site's own control. */++ openActions(anchor) { injectStyles(); openActions(anchor || null); },++ /** Full-page inbox (same list, filters, search, infinite scroll) rendered into a container. */++ renderInbox(container, opts = {}) {++ injectStyles();++ if (!container) return null;++ container.classList.add('openvibe-notif-inbox');++ const v = buildView(container, { mode: 'inbox' });++ if (opts.tab) { v.tab = opts.tab; container.querySelectorAll('[data-tab]').forEach(b => { b.classList.toggle('active', b.dataset.tab === opts.tab); b.setAttribute('aria-selected', String(b.dataset.tab === opts.tab)); }); }++ reload(v);++ return v;++ },++ /** The Dock's Activity tab: the bell's list in the Dock's container (no frame, no title, no close). */++ renderDock(container) {++ injectStyles();++ if (!container) return null;++ container.classList.add('openvibe-notif-dock');++ const v = buildView(container, { mode: 'dock' });++ reload(v);++ return v;++ },++ /** Programmatically show a toast (real-time events). Marks nothing on the server. */++ push(notification) {++ showToast(notification);++ playSound(notification.priority);++ if (!notification.id) return;++ setBadge(_unreadCount + 1);++ },++ /** Re-fetch the badge and any open lists (call after an in-page action). */++ refresh() { pollUnread(); for (const v of _views.values()) reload(v); },++ markRead(ids) { return markRead(Array.isArray(ids) ? ids : [ids]); },++ markAllRead,++ setPreferences(prefs) { Object.assign(_preferences, prefs); },++ setToken(token) {++ _config.token = token;++ if (token || !isCrossOrigin()) { startPolling(); startRealtime(); } else { stopPolling(); stopRealtime(); setBadge(0, { quiet: true }); }++ },++ /** The realtime feed's state ({ state: 'off' | 'ticket' | 'connecting' | 'open' | 'backoff' | 'hidden' | 'blocked' | 'failed' | 'signed-out' | 'unavailable', … }), or null when off. */++ realtimeState() { return _live ? _live.state() : null; },++ /** How a notification reads (title, message, chips, tone, glyph, group key): the same for toasts and the list. */++ present,++ togglePanel, closePanel,++ enablePush, disablePush, pushStatus,++ get unreadCount() { return _unreadCount; },++ };++++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNotifications;++ else root.OpenVibeNotifications = OpenVibeNotifications;++++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);nnew file mode 100644@@@ -0,0 +1,259 @@++/**++ * ov-mark.js — the OpenVibe "OV" brand mark as a drop-in.++ * <span class="ov-mark"></span> → 32px animated mark++ * <span class="ov-mark" data-size="56"> → any size; add data-static="1" for no motion++ * <span class="ov-mark" data-variant="live"> → the site's own twist on the motion (see VARIANTS)++ * Self-contained: injects its own CSS once, works on any page (Live, Network, static pages).++ * The mark is one stroke, the "open loop" (4.25.0; the owner, 2026-10-28: "the open vibe logo isn't even that great…++ * come up with new ideas… more modern… better effects"): an O opened at the top by the V, so it reads OV and "open" at++ * once. The stroke runs from the site's accent towards violet; it draws itself in on a visit's first view, a short light++ * runs along it, and the site's dot sits in the opening (Live's is red and on air). Transform/opacity/stroke motion only.++ *++ * Motion budget: SVG animations restyle and re-lay-out on the main thread every frame, so an++ * endlessly moving mark kept idle pages 12-29% busy (browser check, OpenVibe.Host). A mark now++ * moves only for its intro (the first INTRO_MS of a 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 activity (data-state busy/ok,++ * island.js). Otherwise its CSS animations are paused where they are and its SMIL clock stopped,++ * so it keeps its look. 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).++ *++ * 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, a pumpkin's stem, leaf and ribs in October, a cap 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.++ */++(function () {++ 'use strict';++ if (window.__ovMark) return; window.__ovMark = true;++ let n = 0;++ const CSS = `++.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;color:var(--accent,#8b5cf6);flex:none;vertical-align:middle;line-height:0;--ovm-to:color-mix(in oklab,currentColor 50%,#e879f9)}++.ov-mark svg{width:100%;height:100%;overflow:visible;transform-origin:50% 50%}++.ov-mark .g{fill:currentColor;opacity:.12;transform-box:fill-box;transform-origin:center;animation:ovmGlow 3.4s ease-in-out infinite}++.ov-mark .lp{fill:none;stroke-width:5.2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:100;animation:ovmDraw 1.2s cubic-bezier(.3,.8,.2,1) both}++.ov-mark .sw{fill:none;stroke:#fff;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:7 93;opacity:.9;animation:ovmSweep 3s linear infinite}++.ov-mark .d{fill:var(--ovm-dot,var(--text-primary,#fff));transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}++/* Per-site variants: one family, each site with its own rhythm so the network reads as one brand with many rooms.++ Colours stay with the theme (currentColor); only the dot and the motion change. */++.ov-mark[data-variant=live]{--ovm-dot:#ef4444}++.ov-mark[data-variant=live] .d{animation:ovmOnAir 1.4s ease-in-out infinite}++.ov-mark[data-variant=tools] .sw{animation-duration:2s}++.ov-mark[data-variant=media] .sw,.ov-mark[data-variant=video] .sw{stroke-dasharray:3 6;animation-duration:6s}++.ov-mark[data-variant=games] svg{animation:ovmBounce 1.6s cubic-bezier(.3,1.4,.4,1) infinite}++.ov-mark[data-variant=games] .d{animation-duration:1.6s}++.ov-mark[data-variant=community] .sw{animation-direction:reverse}++.ov-mark[data-variant=network] .g{animation-duration:2.4s}++.ov-mark[data-variant=chat] .d{animation:ovmDot 1s ease-in-out infinite}++.ov-mark[data-variant=stream] .sw{stroke-dasharray:20 80;animation-duration:2.4s}++.ov-mark[data-variant=codes] .lp{animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) infinite alternate}++.ov-mark[data-variant=blog] .sw,.ov-mark[data-variant=wiki] .sw,.ov-mark[data-variant=news] .sw{animation-duration:6s}++.ov-mark[data-variant=vip]{--ovm-dot:#facc15}++.ov-mark[data-variant=tips]{--ovm-dot:#22c55e}++.ov-mark[data-variant=deals],.ov-mark[data-variant=coupons]{--ovm-dot:#f59e0b}++.ov-mark[data-variant=trade] .sw{animation-timing-function:cubic-bezier(.4,0,.2,1)}++.ov-mark[data-variant=host] svg{animation:none}++@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.75}50%{transform:scale(1.6);opacity:1}}++@keyframes ovmBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.5px)}}++.ov-mark:hover svg,a:hover>.ov-mark svg,button:hover>.ov-mark svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}++.ov-mark:hover .sw,a:hover>.ov-mark .sw{animation-duration:1s}++.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .lp,.ov-mark[data-static] .sw,.ov-mark[data-static] .d{animation:none!important}++.ov-mark[data-static] .sw{display:none}++.ov-mark[data-ov]:not([data-ovm-run]) svg,.ov-mark[data-ov]:not([data-ovm-run]) .g,.ov-mark[data-ov]:not([data-ovm-run]) .sw,.ov-mark[data-ov]:not([data-ovm-run]) .d{animation-play-state:paused!important}++.ov-mark[data-ov]:not([data-ovm-run]) .sw{opacity:0;transition:opacity .4s}++.ov-mark[data-ov][data-variant=codes]:not([data-ovm-run]) .lp{animation:none!important;stroke-dashoffset:0}++.ov-mark .p{fill:none;stroke:currentColor;stroke-width:3.4;stroke-linecap:round;transform:rotate(-90deg);transform-box:fill-box;transform-origin:center;stroke-dasharray:138.2;stroke-dashoffset:calc(138.2px * (1 - var(--ovm-p,0)));opacity:0;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s}++.ov-mark[data-progress] .p{opacity:1}++.ov-mark[data-progress] .sw{opacity:0}++.ov-mark[data-state=busy] .sw{animation-duration:1s}++.ov-mark[data-state=ok]{color:var(--success,#22c55e)!important}++.ov-mark[data-state=error]{color:var(--danger,#ef4444)!important}++.ov-mark[data-state=ok] svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}++@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}++@keyframes ovmDraw{from{stroke-dashoffset:100}to{stroke-dashoffset:0}}++@keyframes ovmSweep{from{stroke-dashoffset:100}to{stroke-dashoffset:0}}++@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.45);opacity:1}}++@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.15)}to{transform:rotate(360deg) scale(1)}}++.ov-mark .sh,.ov-mark .sw2{display:none}++html[data-ov-season-mark=halloween] .ov-mark .sh,html[data-ov-season-mark=winter] .ov-mark .sw2{display:inline}++html[data-ov-season-mark=halloween] .ov-mark .d{display:none}++.ov-mark .ss{fill:none;stroke:#5fae3c;stroke-width:3.2;stroke-linecap:round}++.ov-mark .sl{fill:#7fd04e;transform-box:fill-box;transform-origin:0 70%}++.ov-mark .sc{fill:none;stroke:#f4fbff;stroke-width:4.6;stroke-linecap:round}++.ov-mark .sd{fill:#f4fbff}++html[data-ov-season-mark=halloween] .ov-mark{--ovm-to:#f97316}++html[data-ov-season-mark] .ov-mark[data-ovm-run] .sl{animation:ovmLeaf .9s cubic-bezier(.3,1.6,.5,1) both}++.ov-mark[data-ovm-flourish] .sl{animation:ovmWiggle 1.2s ease-in-out 1}++.ov-mark[data-ovm-flourish] .sd{animation:ovmTwinkle 1.4s ease-in-out 1}++.ov-mark[data-ovm-flourish] .sw{animation:ovmSweep .9s linear 1;opacity:.9}++.ovm-fall{position:fixed;width:0;height:0;pointer-events:none;z-index:10010}++.ovm-fall i{position:absolute;left:-4px;top:-4px;width:9px;height:9px;border-radius:0 70% 0 70%;opacity:0;animation:ovmFall 3s cubic-bezier(.3,.1,.5,1) forwards}++html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:50%}++@keyframes ovmLeaf{from{transform:scale(0) rotate(-50deg)}to{transform:none}}++@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 svg,.ov-mark .g,.ov-mark .lp,.ov-mark .sw,.ov-mark .d,.ov-mark .sl,.ov-mark .sd{animation:none!important}.ov-mark .sw,.ovm-fall{display:none}}++html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;++ // The open loop: from the opening's left end round the bottom to its right end, then down the V and back up.++ // pathLength=100 lets the draw-in and the running light be written in hundredths of the stroke.++ const LOOP = 'M15.2 8.8A17.5 17.5 0 1 0 32.8 8.8L24 30.5Z';++ function svg(id) {++ return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs>++<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" style="stop-color:var(--ovm-to)"/></linearGradient></defs>++<circle class="g" cx="24" cy="24" r="22"/>++<circle class="p" cx="24" cy="24" r="22"/>++<path class="lp" d="${LOOP}" pathLength="100" stroke="url(#${id}g)"/>++<path class="sw" d="${LOOP}" pathLength="100"/>++<circle class="d" cx="24" cy="7.4" r="2.7"/>++<g class="sh"><path class="ss" d="M24 9.6c.2-2.8 1-4.8 2.7-6.1"/><path class="sl" d="M26.4 4.6c2.3-2.5 5.8-2.7 8.3-1.2-1.9 2.7-5.4 3.5-8.3 1.2z"/></g>++<g class="sw2"><path class="sc" d="M6.6 15.4A19.4 19.4 0 0 1 14.3 7.2"/><path class="sc" d="M33.7 7.2A19.4 19.4 0 0 1 41.4 15.4"/><circle class="sd" cx="10.4" cy="9" r="1.4"/><circle class="sd" cx="38.2" cy="9.6" r="1.2"/></g></svg>`;++ }++ const INTRO_MS = 8000, LINGER_MS = 1200;++ const state = new WeakMap(); // mark → { introEnd, lingerEnd, hover, focus, onScreen, timer }++ let io = null, rm = null;++ // One intro per visit (tab): the deadline is kept in sessionStorage so the next page of a++ // multi-page site finishes the same intro instead of starting another.++ const INTRO_KEY = 'ov-mark-intro-end';++ function introDeadline(now) {++ let end = 0;++ try { end = Number(sessionStorage.getItem(INTRO_KEY)) || 0; } catch { /* no storage */ }++ 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';++ }++ 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 s = el.getAttribute('data-state');++ const run = may && (now < st.introEnd || st.hover || st.focus || now < st.lingerEnd || s === 'busy' || s === 'ok');++ if (run !== el.hasAttribute('data-ovm-run')) { if (run) el.setAttribute('data-ovm-run', ''); else el.removeAttribute('data-ovm-run'); }++ try {++ if (run) svgEl.unpauseAnimations();++ else {++ // Never run yet: rest on the frame where the V's light sweep is centred.++ if (svgEl.getCurrentTime() < 0.5) svgEl.setCurrentTime(1.6);++ svgEl.pauseAnimations();++ }++ } catch { /* no SMIL */ }++ clearTimeout(st.timer); st.timer = 0;++ const next = Math.min(...[st.introEnd, st.lingerEnd].filter((t) => t > now));++ if (run && next !== Infinity) st.timer = setTimeout(() => update(el), next - now + 20);++ }++ // A mark taken out of the page stops and is let go (a navbar re-render replaces its mark);++ // mount() picks it up again if it comes back.++ function forget(el) {++ const st = state.get(el); if (!st) return;++ clearTimeout(st.timer); state.delete(el);++ if (io) try { io.unobserve(el); } catch { /* */ }++ el.removeAttribute('data-ovm-run');++ try { el.querySelector('svg').pauseAnimations(); } catch { /* */ }++ }++ function updateAll() { document.querySelectorAll('.ov-mark[data-ov]').forEach(update); }++ // ── The season ──++ const season = () => document.documentElement.getAttribute('data-ov-season-mark');++ let flourishTimer = 0;++ function scheduleFlourish() {++ clearTimeout(flourishTimer); flourishTimer = 0;++ if (!season()) return;++ flourishTimer = setTimeout(() => {++ if (season() && !reduced() && !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);++ });++ }++ scheduleFlourish();++ }, (4 + Math.random() * 4) * 60000);++ }++ const FALL_KEY = 'ov-season-fall';++ function fallOnce(el) {++ const id = season(); if (!id || reduced() || document.hidden || !el.isConnected) return;++ 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;++ const box = document.createElement('div'); box.className = 'ovm-fall'; box.setAttribute('aria-hidden', 'true');++ const x0 = r.left + r.width / 2, y0 = r.top + r.height * 0.6;++ box.style.left = x0 + 'px'; box.style.top = y0 + 'px';++ // The leaves land on the bottom edge of the bar the mark sits in and fade there, so they never cross a page's++ // heading (they fell 120-280 px over it on phones, 10-30). A mark outside a bar gets a short fall.++ const bar = el.closest('.openvibe-navbar,header,nav');++ 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++) {++ 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 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]}`;++ box.appendChild(s);++ }++ document.body.appendChild(box);++ setTimeout(() => box.remove(), 5200);++ }++ const bound = new WeakSet();++ function track(el) {++ state.set(el, { introEnd: 0, lingerEnd: 0, hover: false, focus: false, onScreen: io ? null : true, timer: 0 });++ if (!bound.has(el)) {++ bound.add(el);++ const host = el.closest('a,button') || el;++ const set = (k, v) => () => {++ const st = state.get(el); if (!st) return;++ st[k] = v; if (!v) st.lingerEnd = Date.now() + LINGER_MS;++ update(el);++ };++ host.addEventListener('pointerenter', set('hover', true)); host.addEventListener('pointerleave', set('hover', false));++ host.addEventListener('focusin', set('focus', true)); host.addEventListener('focusout', set('focus', false));++ }++ if (io) io.observe(el);++ update(el);++ }++ function mount(root) {++ (root || document).querySelectorAll('.ov-mark').forEach(el => {++ if (!el.hasAttribute('data-ov')) {++ el.setAttribute('data-ov', '1');++ const size = parseInt(el.getAttribute('data-size'), 10);++ if (size) { el.style.width = size + 'px'; el.style.height = size + 'px'; }++ el.innerHTML = svg('ovm' + (++n) + '_');++ }++ if (!state.has(el) && el.isConnected) track(el);++ });++ }++ function init() {++ if (!document.getElementById('ov-mark-css')) { const st = document.createElement('style'); st.id = 'ov-mark-css'; st.textContent = CSS; document.head.appendChild(st); }++ try {++ io = new IntersectionObserver((entries) => entries.forEach((e) => {++ const st = state.get(e.target); if (!st) return;++ st.onScreen = e.isIntersecting; update(e.target);++ }));++ } catch { io = null; }++ try { rm = window.matchMedia('(prefers-reduced-motion: reduce)'); rm.addEventListener('change', updateAll); } catch { rm = null; }++ document.addEventListener('visibilitychange', updateAll);++ mount();++ scheduleFlourish();++ try {++ 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.addedNodes.length) added = true;++ r.removedNodes.forEach((node) => {++ if (node.nodeType !== 1) return;++ if (state.has(node) && !node.isConnected) forget(node);++ if (node.querySelectorAll) node.querySelectorAll('.ov-mark[data-ov]').forEach((m) => { if (!m.isConnected) forget(m); });++ });++ }++ if (added) mount();++ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-ov-motion', 'data-ov-season-mark'] });++ } catch { /* */ }++ }++ window.ovMarkMount = mount;++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();++})();@@@ -7,7 +7,7 @@"test": "node test/run.js","pack": "node scripts/pack.js","pack:stores": "node scripts/pack.js --stores",-- "lint:firefox": "npx --yes [email protected] lint --source-dir browser --warnings-as-errors",++ "lint:firefox": "npx --yes [email protected] lint --source-dir browser --warnings-as-errors --ignore-files \"shared/**\"","pack:dev": "node scripts/pack.js --api http://localhost:4850","icons": "node scripts/icons.js"},@@@ -3,16 +3,21 @@* The permission audit for browser/manifest.json. test/manifest.test.js runs it, and so does* scripts/pack.js before it writes a zip. It fails when anything widens:*-- * permissions exactly activeTab + scripting + storage (scripting runs lib/autoapply.js or lib/rating.js in the-- * active tab only, when the person presses "Try these codes on this page" or "Save its rating to-- * OpenVibe Reviews": activeTab is the only host access)-- * host_permissions exactly <API origin>/*++ * permissions exactly activeTab + scripting + storage, plus sidePanel in the Chrome build++ * (scripting runs lib/autoapply.js or lib/rating.js in the active tab only, when the++ * person presses "Try these codes on this page" or "Save its rating to OpenVibe++ * Reviews": activeTab is the only host access; Firefox's sidebar_action, the++ * side panel there, needs no permission of its own)++ * host_permissions exactly the API origin, the Network origin and the Chat origin, all three++ * https://openvibe.<tld> sites (the coupon API, the Dock's state, the Dock's++ * Chats tab)* forbidden keys content_scripts, background, web_accessible_resources,* externally_connectable, optional_permissions, optional_host_permissions,* declarative_net_request, chrome_url_overrides, devtools_page, sandbox,-- * side_panel, omnibox, … (anything not on the allow-list of keys)-- * CSP script-src 'self' only, connect-src only the API origin, no unsafe-*-- * pages popup/options HTML load only local scripts, no inline script++ * omnibox, … (anything not on the allow-list of keys; the side panel's own keys,++ * side_panel and sidebar_action, are on it)++ * CSP script-src 'self' only, connect-src only the OpenVibe origins, no unsafe-*++ * pages popup/options/panel HTML load only local scripts, no inline script* config lib/config.js API_ORIGIN equals the host permission's origin* hand-offs lib/config.js HANDOFF_ORIGINS are https://openvibe.<tld> sites only (the* tabs the Companion may open when you press a button; never requested)@@@ -21,17 +26,35 @@ const fs = require('fs');const path = require('path');const ALLOWED_KEYS = new Set(['manifest_version', 'name', 'short_name', 'version', 'description', 'homepage_url', 'author', 'icons',-- 'action', 'options_ui', 'permissions', 'host_permissions', 'content_security_policy', 'minimum_chrome_version', 'browser_specific_settings']);--const PERMISSIONS = ['activeTab', 'scripting', 'storage'];++ 'action', 'options_ui', 'permissions', 'host_permissions', 'content_security_policy', 'minimum_chrome_version', 'browser_specific_settings',++ 'side_panel', 'sidebar_action']);++const BASE_PERMISSIONS = ['activeTab', 'scripting', 'storage'];++const SIDE_PERMISSIONS = ['sidePanel', 'sidebarAction'];++const PERMISSIONS = BASE_PERMISSIONS.concat(['sidePanel']);--function auditManifest(m, { apiOrigin }) {++function auditManifest(m, { apiOrigin, origins } = {}) {const problems = [];++ const os = (origins && origins.length ? origins : [apiOrigin]).filter(Boolean);if (m.manifest_version !== 3) problems.push('manifest_version must be 3');for (const k of Object.keys(m)) if (!ALLOWED_KEYS.has(k)) problems.push(`manifest key "${k}" is not allowed`);const perms = Array.isArray(m.permissions) ? m.permissions : [];-- if (perms.length !== PERMISSIONS.length || !PERMISSIONS.every((p) => perms.includes(p))) problems.push(`permissions must be exactly ${PERMISSIONS.join(', ')} (got ${perms.join(', ') || 'none'})`);++ const extra = perms.filter((p) => !BASE_PERMISSIONS.includes(p));++ if (perms.length < BASE_PERMISSIONS.length || !BASE_PERMISSIONS.every((p) => perms.includes(p))) {++ problems.push(`permissions must be exactly ${BASE_PERMISSIONS.join(', ')} (got ${perms.join(', ') || 'none'})`);++ }++ if (extra.length > 1 || extra.some((p) => !SIDE_PERMISSIONS.includes(p))) {++ problems.push(`the only extra permission may be sidePanel (Chrome) (got ${extra.join(', ') || 'none'})`);++ }const hosts = Array.isArray(m.host_permissions) ? m.host_permissions : [];-- if (hosts.length !== 1 || hosts[0] !== `${apiOrigin}/*`) problems.push(`host_permissions must be exactly ${apiOrigin}/* (got ${hosts.join(', ') || 'none'})`);++ const want = os.map((o) => o + '/*').sort();++ if (hosts.length !== want.length || hosts.slice().sort().join() !== want.join()) {++ problems.push(`host_permissions must be exactly ${want.join(', ')} (got ${hosts.join(', ') || 'none'})`);++ }++ const panel = m.side_panel || m.sidebar_action;++ if (!panel) problems.push('the side panel needs side_panel (Chrome) or sidebar_action (Firefox)');++ else if ((m.side_panel && m.side_panel.default_path !== 'panel.html') || (m.sidebar_action && m.sidebar_action.default_panel !== 'panel.html')) {++ problems.push('the side panel opens panel.html');++ }if (!m.action || m.action.default_popup !== 'popup.html') problems.push('action.default_popup must be popup.html');if (!m.options_ui || m.options_ui.page !== 'options.html') problems.push('options_ui.page must be options.html');const csp = m.content_security_policy && m.content_security_policy.extension_pages;@@@ -39,7 +62,7 @@ function auditManifest(m, { apiOrigin }) {else {const dirs = Object.fromEntries(csp.split(';').map((d) => d.trim()).filter(Boolean).map((d) => { const [k, ...v] = d.split(/\s+/); return [k, v]; }));if (JSON.stringify(dirs['script-src']) !== JSON.stringify(["'self'"])) problems.push("CSP script-src must be exactly 'self'");-- if (JSON.stringify(dirs['connect-src']) !== JSON.stringify([apiOrigin])) problems.push(`CSP connect-src must be exactly ${apiOrigin}`);++ if (JSON.stringify(dirs['connect-src'].slice().sort()) !== JSON.stringify(os.slice().sort())) problems.push(`CSP connect-src must be exactly ${os.join(' ')}`);if (JSON.stringify(dirs['default-src']) !== JSON.stringify(["'none'"])) problems.push("CSP default-src must be 'none'");if (JSON.stringify(dirs['object-src']) !== JSON.stringify(["'none'"])) problems.push("CSP object-src must be 'none'");if (/unsafe-|\*|data:|blob:|http:(?!\/\/(localhost|127\.0\.0\.1))/.test(csp)) problems.push('CSP must not allow unsafe-*, wildcards, data:, blob: or remote http');@@@ -67,14 +90,18 @@ function auditDir(dir) {const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));const configSrc = fs.readFileSync(path.join(dir, 'lib', 'config.js'), 'utf8');const origin = (configSrc.match(/API_ORIGIN:\s*'([^']+)'/) || [])[1];++ const network = (configSrc.match(/NETWORK_ORIGIN:\s*'([^']+)'/) || [])[1];++ const chat = (configSrc.match(/CHAT_ORIGIN:\s*'([^']+)'/) || [])[1];const handoffs = ((configSrc.match(/HANDOFF_ORIGINS:\s*Object\.freeze\(\[([^\]]*)\]\)/) || [])[1] || '').split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);const problems = [];if (!origin) problems.push('lib/config.js has no API_ORIGIN');if (!handoffs.length) problems.push('lib/config.js has no HANDOFF_ORIGINS');for (const h of handoffs) if (!/^https:\/\/openvibe\.[a-z]+$/.test(h)) problems.push(`hand-off origin ${h} is not an https://openvibe.<tld> site`);-- problems.push(...auditManifest(manifest, { apiOrigin: origin }));-- for (const page of ['popup.html', 'options.html']) problems.push(...auditPage(fs.readFileSync(path.join(dir, page), 'utf8'), page));-- return { manifest, origin, problems };++ const origins = [origin, network, chat].filter(Boolean);++ for (const o of [network, chat].filter(Boolean)) if (!/^https:\/\/openvibe\.[a-z]+$/.test(o)) problems.push(`${o} is not an https://openvibe.<tld> site`);++ problems.push(...auditManifest(manifest, { apiOrigin: origin, origins }));++ for (const page of ['popup.html', 'options.html', 'panel.html']) problems.push(...auditPage(fs.readFileSync(path.join(dir, page), 'utf8'), page));++ return { manifest, origin, origins, problems };}--module.exports = { auditManifest, auditPage, auditDir, PERMISSIONS, ALLOWED_KEYS };++module.exports = { auditManifest, auditPage, auditDir, PERMISSIONS, BASE_PERMISSIONS, ALLOWED_KEYS };@@@ -75,9 +75,17 @@ function listFiles(dir, base = dir) {/** The store manifest for one browser: only drops the other browser's key (the audit runs on the full one). */function forTarget(files, target) {const m = JSON.parse(files.get('manifest.json'));-- if (target === 'chrome') delete m.browser_specific_settings;-- else if (target === 'firefox') delete m.minimum_chrome_version;-- else throw new Error('target must be chrome or firefox');++ if (target === 'chrome') {++ delete m.browser_specific_settings;++ delete m.sidebar_action;++ m.permissions = (m.permissions || []).filter((p) => p !== 'sidebarAction');++ m.permissions.push('sidePanel');++ m.side_panel = { default_path: 'panel.html' };++ } else if (target === 'firefox') {++ delete m.minimum_chrome_version;++ delete m.side_panel;++ m.permissions = (m.permissions || []).filter((p) => p !== 'sidePanel');++ } else throw new Error('target must be chrome or firefox');files.set('manifest.json', Buffer.from(JSON.stringify(m, null, 2) + '\n'));}@@@ -131,4 +139,4 @@ if (require.main === module) {}}--module.exports = { build, zip, crc32 };++module.exports = { build, zip, crc32, forTarget };@@@ -11,20 +11,39 @@ const { check, done } = require('./helpers/harness');const DIR = path.join(__dirname, '..', 'browser');const ORIGIN = 'https://openvibe.coupons';++const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat'];(async () => {const { manifest, origin, problems } = auditDir(DIR);-- await check('the shipped manifest passes the audit: activeTab + scripting + storage, one host permission (the API), MV3', async () => {++ await check('the shipped manifest passes the audit: activeTab + scripting + storage + sidePanel, three OpenVibe host permissions, MV3', async () => {assert.deepStrictEqual(problems, []);assert.strictEqual(origin, ORIGIN);assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'scripting', 'storage']);-- assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*']);++ assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*', 'https://openvibe.network/*', 'https://openvibe.chat/*']);++ assert.strictEqual(manifest.sidebar_action.default_panel, 'panel.html', 'the Dock lives in the side panel');for (const k of ['content_scripts', 'background', 'web_accessible_resources', 'externally_connectable', 'optional_permissions', 'optional_host_permissions']) {assert.ok(!(k in manifest), `${k} must not be present`);}});++ await check('the side panel is a key in both browsers\' manifests', async () => {++ const { forTarget } = require('../scripts/pack');++ const ff = new Map([['manifest.json', Buffer.from(JSON.stringify(manifest))]]);++ forTarget(ff, 'firefox');++ const firefox = JSON.parse(ff.get('manifest.json').toString('utf8'));++ assert.ok(!('side_panel' in firefox), 'Firefox has no Chrome key');++ assert.strictEqual(firefox.sidebar_action.default_panel, 'panel.html');++ assert.ok(!firefox.permissions.includes('sidePanel'), 'Firefox needs no side-panel permission');++ const ch = new Map([['manifest.json', Buffer.from(JSON.stringify(manifest))]]);++ forTarget(ch, 'chrome');++ const chrome = JSON.parse(ch.get('manifest.json').toString('utf8'));++ assert.ok(chrome.side_panel, 'Chrome gets side_panel');++ assert.ok(!('sidebar_action' in chrome), 'and no Firefox key');++ assert.ok(chrome.permissions.includes('sidePanel'), 'Chrome asks for its side-panel permission');++ assert.ok(!chrome.permissions.includes('sidebarAction'));++ });++await check('the audit fails if anything widens', async () => {const widen = [(m) => m.permissions.push('tabs'),@@@ -52,18 +71,18 @@ const ORIGIN = 'https://openvibe.coupons';for (const [i, mutate] of widen.entries()) {const m = JSON.parse(JSON.stringify(manifest));mutate(m);-- assert.ok(auditManifest(m, { apiOrigin: ORIGIN }).length > 0, `widening #${i} was not caught: ${mutate}`);++ assert.ok(auditManifest(m, { apiOrigin: ORIGIN, origins: ORIGINS }).length > 0, `widening #${i} was not caught: ${mutate}`);}});await check('the API origin agrees across lib/config.js, host_permissions and the CSP connect-src', async () => {const csp = manifest.content_security_policy.extension_pages;-- assert.match(csp, /connect-src https:\/\/openvibe\.coupons;/);-- assert.strictEqual(auditManifest(manifest, { apiOrigin: 'https://evil.example.net' }).length > 0, true);++ assert.match(csp, /connect-src https:\/\/openvibe\.coupons https:\/\/openvibe\.network https:\/\/openvibe\.chat;/);++ assert.strictEqual(auditManifest(manifest, { apiOrigin: 'https://evil.example.net' }).length > 0, true, 'another origin is caught');});await check('pages load only local scripts: no inline script, no inline handlers, no remote code', async () => {-- for (const p of ['popup.html', 'options.html']) assert.deepStrictEqual(auditPage(fs.readFileSync(path.join(DIR, p), 'utf8'), p), []);++ for (const p of ['popup.html', 'options.html', 'panel.html']) assert.deepStrictEqual(auditPage(fs.readFileSync(path.join(DIR, p), 'utf8'), p), []);assert.ok(auditPage('<script>alert(1)</script>', 'x').length);assert.ok(auditPage('<script src="https://cdn.example.net/x.js"></script>', 'x').length);assert.ok(auditPage('<button onclick="x()">', 'x').length);@@@ -71,7 +90,9 @@ const ORIGIN = 'https://openvibe.coupons';await check('the code never uses cookies, dynamic code or HTML injection; only api.js fetches; only platform.js runs a script in a page', async () => {const files = [];-- (function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (p.endsWith('.js')) files.push(p); } })(DIR);++ // browser/shared/ is the released OpenVibe.Shared Dock, bundled as it is: it is audited in its own++ // repository (OpenVibe.Shared), not here.++ (function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) { if (e.name !== 'shared') walk(p); } else if (p.endsWith('.js')) files.push(p); } })(DIR);const forbidden = [/\binnerHTML\b/, /\bouterHTML\b/, /insertAdjacentHTML/, /document\.write/, /\beval\s*\(/, /new Function\s*\(/, /\bcookies\b/, /document\.cookie/,/\bhistory\.(search|getVisits)/, /webRequest/, /storage\.sync/, /sendMessage|onMessageExternal|runtime\.connect/, /XMLHttpRequest/, /importScripts/];for (const f of files) {@@@ -79,7 +100,7 @@ const ORIGIN = 'https://openvibe.coupons';const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"])\/\/.*$/gm, '$1');for (const re of forbidden) assert.doesNotMatch(src, re, `${path.relative(DIR, f)} uses ${re}`);const rel = path.relative(DIR, f);-- if (rel !== path.join('lib', 'api.js')) assert.doesNotMatch(src.replace(/fetch: self\.fetch\.bind\(self\)/g, ''), /\bfetch\s*\(/, `${rel} makes a request itself`);++ if (rel !== path.join('lib', 'api.js') && rel !== path.join('lib', 'network-api.js')) assert.doesNotMatch(src.replace(/fetch: self\.fetch\.bind\(self\)/g, ''), /\bfetch\s*\(/, `${rel} makes a request itself`);}const tabUse = files.filter((f) => /\btabs\.(query|create|update)\b/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs (it opens only hand-off pages)');@@@ -45,7 +45,7 @@ function unzip(buf) {const src = path.join(__dirname, '..', 'browser');for (const [name, data] of files) assert.ok(data.equals(fs.readFileSync(path.join(src, name))), name);assert.ok(files.has('manifest.json') && files.has('popup.html') && files.has('lib/api.js') && files.has('icons/icon-128.png'));-- assert.deepStrictEqual(auditManifest(JSON.parse(files.get('manifest.json')), { apiOrigin: 'https://openvibe.coupons' }), []);++ assert.deepStrictEqual(auditManifest(JSON.parse(files.get('manifest.json')), { apiOrigin: 'https://openvibe.coupons', origins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat'] }), []);});await check('the build is reproducible', async () => {@@@ -59,8 +59,8 @@ function unzip(buf) {assert.match(r.out, /-dev\.zip$/);const files = unzip(fs.readFileSync(r.out));const m = JSON.parse(files.get('manifest.json'));-- assert.deepStrictEqual(m.host_permissions, ['http://localhost:4850/*']);-- assert.match(m.content_security_policy.extension_pages, /connect-src http:\/\/localhost:4850;/);++ assert.deepStrictEqual(m.host_permissions, ['http://localhost:4850/*', 'https://openvibe.network/*', 'https://openvibe.chat/*']);++ assert.match(m.content_security_policy.extension_pages, /connect-src http:\/\/localhost:4850 https:\/\/openvibe\.network https:\/\/openvibe\.chat;/);assert.match(m.name, /development/);assert.match(files.get('lib/config.js').toString(), /API_ORIGIN: 'http:\/\/localhost:4850'/);assert.doesNotMatch(files.get('manifest.json').toString() + files.get('lib/config.js').toString(), /openvibe\.coupons\//);@@@ -76,6 +76,14 @@ function unzip(buf) {for (const [name, data] of files) if (name !== 'manifest.json') assert.ok(data.equals(fs.readFileSync(path.join(src, name))), name);const want = { ...full };delete want[dropped];++ if (target === 'chrome') {++ delete want.sidebar_action;++ want.permissions = want.permissions.filter((p) => p !== 'sidebarAction').concat(['sidePanel']);++ want.side_panel = { default_path: 'panel.html' };++ } else {++ delete want.side_panel;++ want.permissions = want.permissions.filter((p) => p !== 'sidePanel');++ }assert.deepStrictEqual(JSON.parse(files.get('manifest.json')), want);assert.ok(fs.readFileSync(build({ outDir: tmp, target }).out).equals(fs.readFileSync(r.out)));}nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++/**++ * The Dock in the side panel: the panel page loads with a stubbed Network, mounts the Dock against++ * it, and says what is missing when there is no sign-in.++ */++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const path = require('path');++const { check, done } = require('./helpers/harness');++const { createPanel } = require('../browser/panel.js');++const { createNetworkClient } = require('../browser/lib/network-api.js');++++const DIR = path.join(__dirname, '..', 'browser');++const TOKEN = 'ov_token_' + 'x'.repeat(40);++++/** The bits of a document the panel touches. */++function fakeDoc() {++ const node = (id) => ({ id, textContent: '', hidden: true, addEventListener(type, fn) { if (type === 'click') this.onclick = fn; } });++ const nodes = { gate: node('gate'), 'gate-title': node('gate-title'), 'gate-text': node('gate-text'), 'gate-button': node('gate-button') };++ return { nodes, getElementById: (id) => nodes[id] || null };++}++++/** A stand-in for the Network's GET /api/v1/me/dock. */++async function startNetwork({ accept = TOKEN } = {}) {++ const requests = [];++ const server = http.createServer((req, res) => {++ requests.push({ url: req.url, auth: req.headers.authorization || null });++ const ok = req.headers.authorization === 'Bearer ' + accept;++ res.writeHead(ok ? 200 : 401, { 'content-type': 'application/json' });++ res.end(JSON.stringify(ok++ ? { state: { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: new Date().toISOString() }, counts: { running: 1, waiting: 0, unread: 0 } }++ : { error: 'unauthorized' }));++ });++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const origin = 'http://127.0.0.1:' + server.address().port;++ return { origin, requests, close: () => server.close() };++}++++function fakeDock() {++ const dock = { mounted: null, opened: 0, mount(opts) { dock.mounted = opts; }, open() { dock.opened++; } };++ return dock;++}++++(async () => {++ const net1 = await startNetwork();++ try {++ await check('the panel loads the Dock with the Network\'s state', async () => {++ const doc = fakeDoc();++ const dock = fakeDock();++ const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => TOKEN });++ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => TOKEN } }, net, dock, sibling: (n) => 'chrome-extension://abc/shared/' + n });++ const out = await panel.start();++ assert.strictEqual(out.mounted, true, 'the Dock is mounted');++ assert.strictEqual(dock.opened, 1, 'and opened');++ assert.strictEqual(dock.mounted.token, TOKEN, "with the person's sign-in");++ assert.strictEqual(dock.mounted.apiBase, 'https://openvibe.network', "the Network, where the Dock's state is");++ assert.strictEqual(dock.mounted.chatBase, 'https://openvibe.chat', 'and Chat, where the Chats tab reads');++ assert.strictEqual(dock.mounted.sibling('dock.js'), 'chrome-extension://abc/shared/dock.js', 'the bundled Dock, from this extension');++ assert.strictEqual(doc.nodes.gate.hidden, true, 'the gate steps aside');++ assert.ok(net1.requests.some((r) => r.url === '/api/v1/me/dock' && r.auth === 'Bearer ' + TOKEN), 'the Network is asked with that sign-in');++ });++++ await check('without a sign-in the panel says what is missing', async () => {++ const doc = fakeDoc();++ const dock = fakeDock();++ const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => null });++ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => null } }, net, dock });++ const out = await panel.start();++ assert.strictEqual(out.mounted, false, 'no Dock without a sign-in');++ assert.strictEqual(doc.nodes.gate.hidden, false, 'the gate is shown');++ assert.match(doc.nodes['gate-title'].textContent, /OpenVibe\.Network/);++ assert.strictEqual(doc.nodes['gate-button'].hidden, false, 'with the way to fix it');++ });++++ await check('a sign-in the Network refuses is not used', async () => {++ const doc = fakeDoc();++ const dock = fakeDock();++ const bad = 'wrong_token_' + 'y'.repeat(40);++ const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => bad });++ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => bad } }, net, dock });++ const out = await panel.start();++ assert.strictEqual(out.mounted, false, 'no Dock with a sign-in the Network refused');++ assert.strictEqual(doc.nodes.gate.hidden, false, 'the gate says so');++ assert.match(doc.nodes['gate-title'].textContent, /said no/);++ });++ } finally {++ net1.close();++ }++++ await check('the panel bundles the released Dock and loads nothing remote', async () => {++ for (const f of ['shared/dock.js', 'shared/action-center.js', 'shared/notification-ui.js', 'shared/ov-mark.js', 'shared/kit.css']) {++ assert.ok(fs.existsSync(path.join(DIR, f)), 'the panel ships ' + f);++ }++ const html = fs.readFileSync(path.join(DIR, 'panel.html'), 'utf8');++ assert.ok(!/src="https?:/i.test(html), 'no remote script');++ assert.ok(!/href="https?:/i.test(html), 'no remote stylesheet');++ assert.match(html, /shared\/dock\.js/);++ });++++ done();++})();
@@ -75,7 +75,7 @@ the Companion never rewrites links or adds referral tags.
@@ -75,7 +75,7 @@ the Companion never rewrites links or adds referral tags.
| | |
| | |
|---|---|
|---|---|
-| **Permissions** | `activeTab`, `scripting` and `storage`. The host permission is `https://openvibe.coupons/*` only. `scripting` is used for four buttons: "Try these codes on this page" injects the packaged `lib/autoapply.js`, "Save its rating to OpenVibe Reviews" injects `lib/rating.js`, and "Keep on my Rent board" and "Check the rent" inject `lib/listing.js`, into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. |
+| **Permissions** | `activeTab`, `scripting`, `storage`, and `sidePanel` in the Chrome build (Firefox's `sidebar_action` needs no permission of its own). The host permissions are the three OpenVibe origins: `https://openvibe.coupons/*` (the coupon API), `https://openvibe.network/*` (the Dock's state) and `https://openvibe.chat/*` (the Dock's Chats tab). `scripting` is used for four buttons: "Try these codes on this page" injects the packaged `lib/autoapply.js`, "Save its rating to OpenVibe Reviews" injects `lib/rating.js`, and "Keep on my Rent board" and "Check the rent" inject `lib/listing.js`, into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. |
| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. |
| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. |
| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). The page's content only when you press "Try these codes on this page": `lib/autoapply.js` finds the coupon box, its Apply button and the order total, types the codes in, and returns to the popup which codes changed the total. It makes no request, reads no cookies or storage, and sends nothing anywhere; `test/manifest.test.js` scans it for that. On a review site, "Save its rating to OpenVibe Reviews" runs `lib/rating.js`, which only reads: the page's name, its rating, the scale, how many ratings, and the city from its structured data. "Keep on my Rent board" and "Check the rent" run `lib/listing.js`, which only reads: the price and its currency, the city and region (never the street) and the bedrooms, from the structured data, else the page's price tag. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. |
| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). The page's content only when you press "Try these codes on this page": `lib/autoapply.js` finds the coupon box, its Apply button and the order total, types the codes in, and returns to the popup which codes changed the total. It makes no request, reads no cookies or storage, and sends nothing anywhere; `test/manifest.test.js` scans it for that. On a review site, "Save its rating to OpenVibe Reviews" runs `lib/rating.js`, which only reads: the page's name, its rating, the scale, how many ratings, and the city from its structured data. "Keep on my Rent board" and "Check the rent" run `lib/listing.js`, which only reads: the price and its currency, the city and region (never the street) and the bedrooms, from the structured data, else the page's price tag. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. |
| **Sent by the extension** | To `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. |
| **Sent by the extension** | To `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. |
@@ -104,11 +104,28 @@ Because the extension exposes nothing to web pages (no content scripts, no
@@ -104,11 +104,28 @@ Because the extension exposes nothing to web pages (no content scripts, no
malicious shop page can't use it to reach anyone's data. The server side of the same guarantee is
malicious shop page can't use it to reach anyone's data. The server side of the same guarantee is
covered in Coupons' own tests: lookups are identical for every caller, and the API ignores cookies.
covered in Coupons' own tests: lookups are identical for every caller, and the API ignores cookies.
+### The Dock in the side panel
+
+The **OpenVibe Dock** — the pill, the card with its 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, 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 needs the person's own OpenVibe sign-in (the token the Network gives every OpenVibe site). The
+Companion had no OpenVibe session of its own — the `cpx_` install token is not one — so the settings
+page asks for the OpenVibe token once, the same way it asks for the Coupons one, and the panel says
+what is missing instead of pretending. Nothing is sent anywhere but the three OpenVibe origins above,
+and the token stays in `storage.local`, on this device.
+
### Layout
### Layout
```
```
browser/
browser/
- manifest.json MV3; Chromium (minimum_chrome_version 111) and Firefox (gecko id, strict_min_version 128)
+ 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 (v4.54.0), bundled: the CSP allows no remote script
popup.html / popup.js DOM wiring only
popup.html / popup.js DOM wiring only
options.html / .js connect (save a checked token), disconnect, grant the API host permission
options.html / .js connect (save a checked token), disconnect, grant the API host permission
lib/config.js API_ORIGIN (the one origin it sends requests to); HANDOFF_ORIGINS (Rent, Watch, Food, Reviews)
lib/config.js API_ORIGIN (the one origin it sends requests to); HANDOFF_ORIGINS (Rent, Watch, Food, Reviews)
@@ -117,7 +134,8 @@ browser/
@@ -117,7 +134,8 @@ browser/
lib/autoapply.js "Try these codes on this page": injected into the active tab only when you press it
lib/autoapply.js "Try these codes on this page": injected into the active tab only when you press it
lib/rating.js "Save its rating to OpenVibe Reviews": reads a review site's rating, injected only when you press it
lib/rating.js "Save its rating to OpenVibe Reviews": reads a review site's rating, injected only when you press it
lib/listing.js "Keep on my Rent board" / "Check the rent": reads a listing's price, city and bedrooms, injected only when you press
lib/listing.js "Keep on my Rent board" / "Check the rent": reads a listing's price, city and bedrooms, injected only when you press
- lib/api.js the only code that makes requests
+ lib/api.js the only code that makes requests to the Coupons API
+ lib/network-api.js the Dock's state on the Network (GET/PUT /api/v1/me/dock), with the person's own token
lib/controller.js popup logic without the DOM (tested in Node)
lib/controller.js popup logic without the DOM (tested in Node)
lib/platform.js chrome.* / browser.*: tabs.query, tabs.create (hand-off pages only), storage.local, permissions
lib/platform.js chrome.* / browser.*: tabs.query, tabs.create (hand-off pages only), storage.local, permissions
lib/format.js wording ("Expiry unknown", "No restrictions stated", …)
lib/format.js wording ("Expiry unknown", "No restrictions stated", …)
@@ -137,7 +155,7 @@ npm test
@@ -137,7 +155,7 @@ npm test
| Requirement | Test |
| Requirement | Test |
|---|---|
|---|---|
-| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, a background worker, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, MV2. The API origin agrees across config, host permission and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js` fetches. | `test/manifest.test.js` |
+| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, a background worker, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, MV2. The API origin agrees across config, host permission and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js` and `lib/network-api.js` fetch. | `test/manifest.test.js` |
| API client against a stub: routes; no `Authorization` without a token; never cookies, referrers or redirects; errors as codes (revoked, scope, 429, 503); timeouts; refused input. | `test/api-client.test.js` |
| API client against a stub: routes; no `Authorization` without a token; never cookies, referrers or redirects; errors as codes (revoked, scope, 429, 503); timeouts; refused input. | `test/api-client.test.js` |
| The extension never sends page content or URLs beyond the hostname. The popup controller runs on checkout URLs full of personal data, and every request the stub receives is inspected. Non-shop pages send nothing. Only `tabs.query` and `storage.local` are touched. Only `cpx_` tokens are stored. | `test/privacy.test.js` |
| The extension never sends page content or URLs beyond the hostname. The popup controller runs on checkout URLs full of personal data, and every request the stub receives is inspected. Non-shop pages send nothing. Only `tabs.query` and `storage.local` are touched. Only `cpx_` tokens are stored. | `test/privacy.test.js` |
| The hand-offs: nothing opens when the popup opens; a button opens one tab on openvibe.rent or openvibe.watch with the cleaned address (no credentials, fragment or tracking parameters); the Coupons API still gets the hostname only; `tabs.open` refuses other origins; rental sites lead with the board. | `test/handoff.test.js` |
| The hand-offs: nothing opens when the popup opens; a button opens one tab on openvibe.rent or openvibe.watch with the cleaned address (no credentials, fragment or tracking parameters); the Coupons API still gets the hostname only; `tabs.open` refuses other origins; rental sites lead with the board. | `test/handoff.test.js` |
@@ -5,26 +5,29 @@
@@ -5,26 +5,29 @@
"wave": "W18",
"wave": "W18",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"adr": "OpenVibe.Contracts ADR-023 (first-party client surfaces)",
"adr": "OpenVibe.Contracts ADR-023 (first-party client surfaces)",
- "version": "0.6.0",
+ "version": "0.7.0",
"surfaces": {
"surfaces": {
"browser": {
"browser": {
"name": "OpenVibe Companion",
"name": "OpenVibe Companion",
- "version": "0.6.0",
+ "version": "0.7.0",
"manifest": 3,
"manifest": 3,
"targets": [
"targets": [
- "chromium >= 111",
+ "chromium >= 114",
"firefox >= 128"
"firefox >= 128"
],
],
- "api": "https://openvibe.coupons (OpenVibe.Coupons public API)",
+ "api": "https://openvibe.coupons (OpenVibe.Coupons public API), https://openvibe.network (the Dock's state), https://openvibe.chat (the Dock's Chats tab)",
"permissions": [
"permissions": [
"activeTab",
"activeTab",
"scripting",
"scripting",
- "storage"
+ "storage",
+ "sidePanel (Chrome only; Firefox needs no permission for sidebar_action)"
],
],
"hostPermissions": [
"hostPermissions": [
- "https://openvibe.coupons/*"
+ "https://openvibe.coupons/*",
+ "https://openvibe.network/*",
+ "https://openvibe.chat/*"
],
],
- "credential": "optional cpx_ install token (coupons.lookup, coupons.report); no Network session",
+ "credential": "optional cpx_ install token (coupons.lookup, coupons.report); the person's own OpenVibe token for the Dock in the side panel (opt-in, in the settings); the Coupons token is never an OpenVibe session",
"publishedToStores": false,
"publishedToStores": false,
"loadedInChrome": "yes: headless Chromium loads the 0.3.0 build (popup and options render, no console errors); lib/autoapply.js passes three fixture checkout pages in headless Chrome",
"loadedInChrome": "yes: headless Chromium loads the 0.3.0 build (popup and options render, no console errors); lib/autoapply.js passes three fixture checkout pages in headless Chrome",
"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",
"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",
@@ -50,10 +53,11 @@
@@ -50,10 +53,11 @@
"keeps a rental listing from any site on the OpenVibe.Rent board with the price, city and bedrooms the page states (read only when pressed), and checks its rent against the city median",
"keeps a rental listing from any site on the OpenVibe.Rent board with the price, city and bedrooms the page states (read only when pressed), and checks its rent against the city median",
"watches a page on OpenVibe.Watch, and compares delivery apps on OpenVibe.Food",
"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)",
"saves a review site's stated rating to OpenVibe.Reviews (read only when pressed, only on those sites)",
- "an optional cpx_ install token, never a Network session; only openvibe.coupons as a host permission",
+ "an optional cpx_ install token, never an OpenVibe session; and, for the Dock in the side panel, the person's own OpenVibe token (opt-in, in the settings)",
+ "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock (v4.54.0) bundled under browser/shared/, reading and writing the same Network state as the Dock on a site, with the same tabs (Agents, Chats, Activity)",
"store-ready: reproducible Chrome and Firefox store zips (npm run pack:stores), listing and privacy answers in store/, Mozilla web-ext lint in CI",
"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",
"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"
"hardware/: the Raspberry Pi streamer and controller scripts, moved from OpenVibe.Live"
],
],
- "updated": "2026-10-23"
+ "updated": "2026-10-30"
}
}
@@ -26,6 +26,12 @@
@@ -26,6 +26,12 @@
FOOD_ORIGIN: 'https://openvibe.food',
FOOD_ORIGIN: 'https://openvibe.food',
REVIEWS_ORIGIN: 'https://openvibe.reviews',
REVIEWS_ORIGIN: 'https://openvibe.reviews',
HANDOFF_ORIGINS: Object.freeze(['https://openvibe.rent', 'https://openvibe.watch', 'https://openvibe.food', 'https://openvibe.reviews']),
HANDOFF_ORIGINS: Object.freeze(['https://openvibe.rent', 'https://openvibe.watch', 'https://openvibe.food', 'https://openvibe.reviews']),
+ // NETWORK_ORIGIN is where the Dock's state lives (GET/PUT /api/v1/me/dock) and CHAT_ORIGIN where the
+ // Dock's Chats tab reads the person's conversations from. Both are OpenVibe's own, and the only
+ // origins the side panel may reach besides the API origin.
+ NETWORK_ORIGIN: 'https://openvibe.network',
+ CHAT_ORIGIN: 'https://openvibe.chat',
+ NETWORK_TOKEN_KEY: 'networkToken',
MAX_TRIES: 10,
MAX_TRIES: 10,
CONNECT_PATH: '/connect-extension',
CONNECT_PATH: '/connect-extension',
TOKEN_KEY: 'installToken',
TOKEN_KEY: 'installToken',
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+/*
+ * The OpenVibe.Network client the side panel uses: the person's Dock state (GET and PUT
+ * /api/v1/me/dock) with their own sign-in as a Bearer token, and never a cookie.
+ *
+ * const net = createNetworkClient({ origin, fetch, getToken });
+ * await net.dock() → { state, counts }
+ * await net.saveDock(p) → { state, counts }
+ * await net.check(token) → is this sign-in accepted?
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.OVNetwork = api;
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ function createNetworkClient(opts) {
+ const origin = String(opts.origin || '').replace(/\/+$/, '');
+ if (!/^https:\/\/[a-z0-9.-]+$/.test(origin) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
+ throw new TypeError('origin must be an https origin (http only for localhost development)');
+ }
+ const doFetch = opts.fetch;
+ const getToken = opts.getToken || async function () { return null; };
+ const timeoutMs = opts.timeoutMs || 8000;
+
+ async function request(method, path, body, tokenOverride) {
+ const token = tokenOverride === undefined ? await getToken() : tokenOverride;
+ const headers = { Accept: 'application/json' };
+ if (token) headers.Authorization = 'Bearer ' + token;
+ if (body) headers['Content-Type'] = 'application/json';
+ const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
+ const timer = ctrl ? setTimeout(function () { ctrl.abort(); }, timeoutMs) : null;
+ let res;
+ try {
+ res = await doFetch(origin + path, {
+ method: method,
+ headers: headers,
+ body: body ? JSON.stringify(body) : undefined,
+ credentials: 'omit',
+ referrerPolicy: 'no-referrer',
+ signal: ctrl ? ctrl.signal : undefined,
+ });
+ } catch (err) {
+ throw new Error('could not reach OpenVibe.Network');
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+ if (res.status === 401 || res.status === 403) throw new Error('OpenVibe.Network refused that sign-in');
+ if (!res.ok) throw new Error('OpenVibe.Network answered ' + res.status);
+ return res.json();
+ }
+
+ return {
+ origin: origin,
+ /** The person's Dock state, as the Network has it. */
+ async dock() { return request('GET', '/api/v1/me/dock'); },
+ /** The Dock's state, as the Network keeps it. */
+ async saveDock(patch) { return request('PUT', '/api/v1/me/dock', patch); },
+ /** Is this sign-in accepted? (the settings page asks before it saves one) */
+ async check(token) {
+ const res = await request('GET', '/api/v1/me/dock', null, token);
+ return !!(res && res.state);
+ },
+ };
+ }
+
+ return { createNetworkClient: createNetworkClient };
+});
@@ -8,6 +8,9 @@
@@ -8,6 +8,9 @@
* tabs.create needs no permission
* tabs.create needs no permission
* storage.getToken/setToken/clearToken the optional cpx_ install token, in storage.local
* storage.getToken/setToken/clearToken the optional cpx_ install token, in storage.local
* (never storage.sync: it stays on this device)
* (never storage.sync: it stays on this device)
+ * storage.getNetworkToken/setNetworkToken/clearNetworkToken
+ * the person's OpenVibe sign-in for the Dock in the side panel, also in
+ * storage.local
* permissions.hasApi/requestApi the host permission for the API origin (Firefox
* permissions.hasApi/requestApi the host permission for the API origin (Firefox
* lets people withhold it; reporting needs it)
* lets people withhold it; reporting needs it)
* scripting.tryCodes(tabId, codes) when the person presses "Try these codes on this page": inject
* scripting.tryCodes(tabId, codes) when the person presses "Try these codes on this page": inject
@@ -28,7 +31,7 @@
@@ -28,7 +31,7 @@
'use strict';
'use strict';
function createPlatform(ext, config) {
function createPlatform(ext, config) {
- const origins = [config.API_ORIGIN + '/*'];
+ const origins = [config.API_ORIGIN + '/*', config.NETWORK_ORIGIN + '/*', config.CHAT_ORIGIN + '/*'];
const call = function (fn, thisArg, args) {
const call = function (fn, thisArg, args) {
// chrome.* in MV3 and browser.* both return promises.
// chrome.* in MV3 and browser.* both return promises.
return fn.apply(thisArg, args);
return fn.apply(thisArg, args);
@@ -84,6 +87,21 @@
@@ -84,6 +87,21 @@
await call(ext.storage.local.set, ext.storage.local, [o]);
await call(ext.storage.local.set, ext.storage.local, [o]);
},
},
async clearToken() { await call(ext.storage.local.remove, ext.storage.local, [config.TOKEN_KEY]); },
async clearToken() { await call(ext.storage.local.remove, ext.storage.local, [config.TOKEN_KEY]); },
+ // The person's own OpenVibe sign-in, for the Dock in the side panel. It is the Network's own
+ // token (the one every OpenVibe site uses), not a new kind of credential, and it stays on
+ // this device like the install token.
+ async getNetworkToken() {
+ const out = await call(ext.storage.local.get, ext.storage.local, [config.NETWORK_TOKEN_KEY]);
+ const t = out && out[config.NETWORK_TOKEN_KEY];
+ return typeof t === 'string' && t.length >= 20 ? t : null;
+ },
+ async setNetworkToken(token) {
+ if (typeof token !== 'string' || token.length < 20) throw new TypeError('that is not an OpenVibe.Network token');
+ const o = {};
+ o[config.NETWORK_TOKEN_KEY] = token;
+ await call(ext.storage.local.set, ext.storage.local, [o]);
+ },
+ async clearNetworkToken() { await call(ext.storage.local.remove, ext.storage.local, [config.NETWORK_TOKEN_KEY]); },
},
},
permissions: {
permissions: {
async hasApi() {
async hasApi() {
@@ -2,8 +2,8 @@
@@ -2,8 +2,8 @@
"manifest_version": 3,
"manifest_version": 3,
"name": "OpenVibe Companion",
"name": "OpenVibe Companion",
"short_name": "OpenVibe",
"short_name": "OpenVibe",
- "version": "0.6.0",
- "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; and one click to keep a rental listing on your OpenVibe.Rent board or watch a page. Reads the tab only when you open it.",
+ "version": "0.7.0",
+ "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; 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",
"homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions",
"author": "OpenVibers",
"author": "OpenVibers",
"icons": {
"icons": {
@@ -24,18 +24,24 @@
@@ -24,18 +24,24 @@
"page": "options.html",
"page": "options.html",
"open_in_tab": false
"open_in_tab": false
},
},
+ "sidebar_action": {
+ "default_panel": "panel.html",
+ "open_in_sidebar": true
+ },
"permissions": [
"permissions": [
"activeTab",
"activeTab",
"scripting",
"scripting",
"storage"
"storage"
],
],
"host_permissions": [
"host_permissions": [
- "https://openvibe.coupons/*"
+ "https://openvibe.coupons/*",
+ "https://openvibe.network/*",
+ "https://openvibe.chat/*"
],
],
"content_security_policy": {
"content_security_policy": {
- "extension_pages": "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src https://openvibe.coupons; object-src 'none'; base-uri 'none'"
+ "extension_pages": "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src https://openvibe.coupons https://openvibe.network https://openvibe.chat; object-src 'none'; base-uri 'none'"
},
},
- "minimum_chrome_version": "111",
+ "minimum_chrome_version": "114",
"browser_specific_settings": {
"browser_specific_settings": {
"gecko": {
"gecko": {
"id": "[email protected]",
"id": "[email protected]",
@@ -20,7 +20,20 @@
@@ -20,7 +20,20 @@
</form>
</form>
<p id="state" class="muted" aria-live="polite"></p>
<p id="state" class="muted" aria-live="polite"></p>
<p><button id="disconnect" type="button">Disconnect</button> <span class="muted">Removes the token from this browser. Revoke it on openvibe.coupons as well.</span></p>
<p><button id="disconnect" type="button">Disconnect</button> <span class="muted">Removes the token from this browser. Revoke it on openvibe.coupons as well.</span></p>
- <p id="permission-row" hidden><button id="grant" type="button">Allow access to openvibe.coupons</button> <span class="muted">Needed to report; the Companion never asks for access to other sites.</span></p>
+ <p id="permission-row" hidden><button id="grant" type="button">Allow access to the OpenVibe sites</button> <span class="muted">Needed to report a code and to read your Dock; the Companion never asks for access to other sites.</span></p>
+ <h2>The Dock in the side panel</h2>
+ <p>The Dock shows what your agents are doing, what your chats wait for and what just happened, on every site, in the side panel. It is the same Dock, and it keeps the same place.</p>
+ <ol>
+ <li>Sign in at <a id="network-link" target="_blank" rel="noopener noreferrer" href="https://openvibe.network/my">openvibe.network</a>.</li>
+ <li>Paste your OpenVibe token here. It is the one your browser already has for OpenVibe (in the page, <code>localStorage.ov_token</code>); it stays on this device and goes only to openvibe.network.</li>
+ </ol>
+ <form id="network-form">
+ <label for="network-token">OpenVibe token</label>
+ <input id="network-token" type="password" autocomplete="off" spellcheck="false" placeholder="eyJ…" size="48">
+ <button type="submit">Save</button>
+ </form>
+ <p id="network-state" class="muted" aria-live="polite"></p>
+ <p><button id="network-disconnect" type="button">Disconnect the Dock</button> <span class="muted">Removes the OpenVibe token from this browser.</span></p>
<h2>What this extension can see</h2>
<h2>What this extension can see</h2>
<ul>
<ul>
<li>The tab you are on, and only when you open the Companion: its address and title.</li>
<li>The tab you are on, and only when you open the Companion: its address and title.</li>
@@ -31,6 +44,7 @@
@@ -31,6 +44,7 @@
</main>
</main>
<script src="lib/config.js"></script>
<script src="lib/config.js"></script>
<script src="lib/api.js"></script>
<script src="lib/api.js"></script>
+<script src="lib/network-api.js"></script>
<script src="lib/platform.js"></script>
<script src="lib/platform.js"></script>
<script src="options.js"></script>
<script src="options.js"></script>
</body>
</body>
@@ -8,7 +8,10 @@
@@ -8,7 +8,10 @@
const config = self.OVConfig;
const config = self.OVConfig;
const platform = self.OVPlatform.createPlatform(ext, config);
const platform = self.OVPlatform.createPlatform(ext, config);
const api = self.OVApi.createApiClient({ origin: config.API_ORIGIN, fetch: self.fetch.bind(self), getToken: platform.storage.getToken, timeoutMs: config.TIMEOUT_MS });
const api = self.OVApi.createApiClient({ origin: config.API_ORIGIN, fetch: self.fetch.bind(self), getToken: platform.storage.getToken, timeoutMs: config.TIMEOUT_MS });
+ const net = self.OVNetwork.createNetworkClient({ origin: config.NETWORK_ORIGIN, fetch: self.fetch.bind(self), getToken: platform.storage.getNetworkToken, timeoutMs: config.TIMEOUT_MS });
const state = document.getElementById('state');
const state = document.getElementById('state');
+ const networkState = document.getElementById('network-state');
+ const networkInput = document.getElementById('network-token');
const input = document.getElementById('token');
const input = document.getElementById('token');
const permissionRow = document.getElementById('permission-row');
const permissionRow = document.getElementById('permission-row');
document.getElementById('connect-link').href = config.API_ORIGIN + config.CONNECT_PATH;
document.getElementById('connect-link').href = config.API_ORIGIN + config.CONNECT_PATH;
@@ -34,6 +37,31 @@
@@ -34,6 +37,31 @@
}
}
});
});
+ async function refreshNetwork() {
+ const token = await platform.storage.getNetworkToken();
+ networkState.textContent = token ? 'Connected (the Dock in the side panel works).' : 'Not connected: the side panel asks for your OpenVibe sign-in.';
+ }
+
+ document.getElementById('network-form').addEventListener('submit', async function (ev) {
+ ev.preventDefault();
+ const token = networkInput.value.trim();
+ if (token.length < 20) { networkState.textContent = 'That does not look like an OpenVibe token.'; return; }
+ networkState.textContent = 'Checking…';
+ try {
+ if (!(await net.check(token))) { networkState.textContent = 'openvibe.network did not accept that token.'; return; }
+ await platform.storage.setNetworkToken(token);
+ networkInput.value = '';
+ await refreshNetwork();
+ } catch (e) {
+ networkState.textContent = 'Could not reach openvibe.network. The token was not saved.';
+ }
+ });
+
+ document.getElementById('network-disconnect').addEventListener('click', async function () {
+ await platform.storage.clearNetworkToken();
+ await refreshNetwork();
+ });
+
document.getElementById('disconnect').addEventListener('click', async function () {
document.getElementById('disconnect').addEventListener('click', async function () {
await platform.storage.clearToken();
await platform.storage.clearToken();
await refresh();
await refresh();
@@ -45,4 +73,5 @@
@@ -45,4 +73,5 @@
});
});
refresh();
refresh();
+ refreshNetwork();
})();
})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+/*
+ * The Dock in the side panel: the browser's panel is the frame, so the Dock fills it (no floating
+ * card, no corners, no pill) and keeps the look it has on a site — the same components, the same
+ * tokens, the same tabs.
+ */
+
+:root {
+ /* The Network's default dark theme (the "vibe" theme of the OpenVibe Kit). */
+ --bg-primary: #0a0f1c;
+ --bg-secondary: #101828;
+ --bg-tertiary: #172238;
+ --bg-card: #131c2e;
+ --bg-hover: #1c2a44;
+ --bg-input: #0d1424;
+ --text-primary: #e6edf7;
+ --text-secondary: #96a7c2;
+ --text-muted: #8192ac;
+ --accent: #3b82f6;
+ --accent-light: #60a5fa;
+ --accent-dark: #1d4ed8;
+ --accent-strong: #3472d8;
+ --on-accent: #ffffff;
+ --on-accent-strong: #ffffff;
+ --border: #1f2d47;
+ --border-light: #2c3d5c;
+ --live-red: #ef4444;
+ --success: #22c55e;
+ --warning: #f59e0b;
+ --danger: #ef4444;
+ --info: #38bdf8;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ --bg-primary: #f5f7fc;
+ --bg-secondary: #eaeff8;
+ --bg-tertiary: #e2e9f4;
+ --bg-card: #ffffff;
+ --bg-hover: #e9eff9;
+ --bg-input: #f1f5fb;
+ --text-primary: #111827;
+ --text-secondary: #3f4a5f;
+ --text-muted: #5b6779;
+ --accent: #2563eb;
+ --accent-light: #60a5fa;
+ --accent-dark: #1d4ed8;
+ --accent-strong: #3472d8;
+ --border: #d9e0ec;
+ --border-light: #e7ecf4;
+ --live-red: #e5484d;
+ --success: #16a34a;
+ --warning: #d97706;
+ --danger: #dc2626;
+ --info: #0284c7;
+ }
+}
+
+html, body { margin: 0; height: 100%; background: var(--bg-primary); color: var(--text-primary); font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
+body.panel { overflow: hidden; }
+
+/* The Dock, full bleed: the panel is the frame. */
+.ovdk, .ovdk[data-corner] {
+ position: static !important;
+ inset: auto !important;
+ left: auto !important; top: auto !important; right: auto !important; bottom: auto !important;
+ width: 100% !important; height: 100% !important;
+ max-width: none !important; max-height: none !important;
+ border-radius: 0 !important; border-bottom: 0 !important;
+ box-shadow: none !important; padding-bottom: 0 !important;
+}
+.ovdk-pill { display: none !important; }
+.ovdk-size { display: none !important; }
+/* The panel is the browser's: it has no window of its own to pop out into, and nothing to close. */
+.ovdk [data-act="pop"], .ovdk [data-act="close"] { display: none !important; }
+
+#gate { padding: 20px; }
+#gate h1 { font-size: 16px; margin: 0 0 8px; }
+#gate p { margin: 0 0 12px; color: var(--text-secondary); }
+#gate button { font: inherit; padding: 8px 14px; border-radius: 10px; border: 1px solid var(--border); background: var(--accent-strong); color: var(--on-accent-strong); cursor: pointer; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>OpenVibe Dock</title>
+<link rel="stylesheet" href="shared/kit.css">
+<link rel="stylesheet" href="panel.css">
+</head>
+<body class="panel">
+<main id="dock" aria-label="OpenVibe Dock"></main>
+<section id="gate" hidden>
+ <h1 id="gate-title">Sign in to OpenVibe.Network</h1>
+ <p id="gate-text">The Dock shows what your agents are doing and what your chats wait for.</p>
+ <button id="gate-button" type="button" hidden>Open the settings</button>
+</section>
+<script src="lib/config.js"></script>
+<script src="lib/platform.js"></script>
+<script src="lib/network-api.js"></script>
+<script src="shared/ov-mark.js"></script>
+<script src="shared/dock.js"></script>
+<script src="panel.js"></script>
+</body>
+</html>
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+/*
+ * The Dock in the side panel: the same Dock every OpenVibe site shows, in the browser's own side
+ * panel (Chrome's side panel, Firefox's sidebar), on any site.
+ *
+ * The Dock is the released OpenVibe.Shared one, bundled under browser/shared/ because the
+ * extension's CSP allows no remote script. The panel gives it the Network origin and the
+ * person's Network sign-in, so it reads and writes the same state as the Dock on a site: the
+ * same tabs (Agents, Chats, Activity) and the same place.
+ *
+ * The extension had no Network sign-in of its own (it holds the Coupons install token, which is
+ * not an OpenVibe sign-in), so the settings page asks for the person's Network token once, the
+ * same way it asks for the Coupons one. Without it the panel says so instead of pretending.
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else { root.OVPanel = api; if (root.document && root.OpenVibeDock) api.boot(root); }
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ function createPanel(opts) {
+ const doc = opts.doc;
+ const gate = doc.getElementById('gate');
+ const gateTitle = doc.getElementById('gate-title');
+ const gateText = doc.getElementById('gate-text');
+ const gateButton = doc.getElementById('gate-button');
+
+ function showGate(title, text, button) {
+ gateTitle.textContent = title;
+ gateText.textContent = text;
+ gateButton.textContent = button || '';
+ gateButton.hidden = !button;
+ gate.hidden = false;
+ }
+
+ /** The Dock, with the Network's state, or the gate that says what is missing. */
+ async function start() {
+ const token = await opts.platform.storage.getNetworkToken();
+ if (!token) {
+ showGate('Sign in to OpenVibe.Network',
+ 'The Dock shows what your agents are doing and what your chats wait for. It needs your OpenVibe.Network sign-in.',
+ 'Open the settings');
+ return { mounted: false, reason: 'no-token' };
+ }
+ try {
+ await opts.net.dock();
+ } catch (err) {
+ showGate('OpenVibe.Network said no',
+ 'That sign-in was not accepted. Sign in to OpenVibe.Network again and paste a fresh token in the settings.',
+ 'Open the settings');
+ return { mounted: false, reason: 'refused' };
+ }
+ opts.dock.mount({
+ token: token,
+ apiBase: opts.config.NETWORK_ORIGIN,
+ chatBase: opts.config.CHAT_ORIGIN,
+ sibling: opts.sibling,
+ });
+ opts.dock.open();
+ gate.hidden = true;
+ return { mounted: true, reason: null };
+ }
+
+ if (gateButton && typeof gateButton.addEventListener === 'function') {
+ gateButton.addEventListener('click', function () { if (opts.openSettings) opts.openSettings(); });
+ }
+ return { start: start, showGate: showGate };
+ }
+
+ /** The page wiring: the real browser APIs and the real Dock. */
+ function boot(root) {
+ const ext = root.browser || root.chrome;
+ const config = root.OVConfig;
+ const platform = root.OVPlatform.createPlatform(ext, config);
+ const net = root.OVNetwork.createNetworkClient({
+ origin: config.NETWORK_ORIGIN,
+ fetch: self.fetch.bind(self),
+ getToken: platform.storage.getNetworkToken,
+ timeoutMs: config.TIMEOUT_MS,
+ });
+ const panel = createPanel({
+ doc: root.document,
+ config: config,
+ platform: platform,
+ net: net,
+ dock: root.OpenVibeDock,
+ sibling: function (name) { return ext.runtime.getURL('shared/' + name); },
+ openSettings: function () { ext.runtime.openOptionsPage(); },
+ });
+ panel.start();
+ return panel;
+ }
+
+ return { createPanel: createPanel, boot: boot };
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,383 @@
@@ -0,0 +1,383 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — the Action Center (✦), every agent task in one place (plan 2026-10-28 §3.10; Shared 4.31.0)
+//
+// notification-ui.js draws the ✦ beside the bell and loads this file, from beside itself, on the first click.
+// OpenVibeActionCenter.toggle({ anchor, token: () => jwt, apiBase }) · open(opts) · close() · isOpen()
+// OpenVibeActionCenter.mount(container, { token, apiBase, buckets, onCounts }) → { refresh(), destroy() }
+// the Dock's Agent tab (dock.js): the items of some buckets in a container of the Dock's, waiting first, the
+// same rows and the same approve and decline, re-read every 15 s while something runs.
+//
+// Reads GET <apiBase>/api/v1/me/actions network.me-actions-result@1 (Bearer, or the cookie on
+// openvibe.network itself)
+// Writes POST <apiBase>/api/v1/me/actions/:service/:id/approve|decline
+//
+// Five tabs: Running (live: re-read every 15 s while open and something runs), Waiting for you (approve or decline
+// here), Scheduled, Done (a receipt: cost, steps, the page), Failed (why, and open it to try again). A service that did
+// not answer is named under the tabs; the rest still show. Desktop: a 420 px popover under the bar. Phones: a full-height
+// sheet with a handle (drag down, ×, or a tap outside closes it). Opaque, on the theme's raised surface, and while it is
+// open toasts wait (aria-modal: notification-ui.js holds them).
+// ═══════════════════════════════════════════════════════════════
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OpenVibeActionCenter) return;
+
+ const BUCKETS = [['running', 'Running'], ['waiting', 'Waiting for you'], ['scheduled', 'Scheduled'], ['done', 'Done'], ['failed', 'Failed']];
+ const SHORT = { running: 'Running', waiting: 'Waiting', scheduled: 'Scheduled', done: 'Done', failed: 'Failed' };
+ const EMPTY = {
+ running: ['Nothing running', 'When an agent is working for you, its steps show here as they happen.'],
+ waiting: ['Nothing needs you', 'When an agent asks before it spends, sends or deletes, you approve it here.'],
+ scheduled: ['Nothing scheduled', 'Agents you set to run later, like a morning brief, show here.'],
+ done: ['Nothing finished yet', 'Finished work shows here with what it cost and where it is.'],
+ failed: ['Nothing failed', 'If a task fails, it shows here with why, so you can try again.'],
+ };
+ const SERVICE = { actor: 'Actor', codes: 'Codes', ai: 'AI', network: 'Network', run: 'Run' };
+ const REFRESH_MS = 15000;
+ const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
+ const G = {
+ spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',
+ code: '<path d="M8.5 7.5L4 12l4.5 4.5M15.5 7.5L20 12l-4.5 4.5M13.5 5.5l-3 13"/>',
+ ai: '<rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/><path d="M9.5 3.5v3M14.5 3.5v3M9.5 17.5v3M14.5 17.5v3M3.5 9.5h3M3.5 14.5h3M17.5 9.5h3M17.5 14.5h3"/>',
+ 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"/>',
+ desk: '<rect x="3.5" y="4.5" width="17" height="11" rx="2"/><path d="M9 19.5h6M12 15.5v4"/>',
+ refresh: '<path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4"/>',
+ close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
+ 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"/>',
+ };
+ const KIND_GLYPH = { agent_task: 'spark', code_task: 'code', ai_run: 'ai', approval: 'key', desk_session: 'desk' };
+ const TONE = { running: 'var(--accent, #3b82f6)', waiting: 'var(--warning, #f5a524)', scheduled: 'var(--text-secondary, #a0a0ab)', done: 'var(--success, #30a46c)', failed: 'var(--live-red, #e5484d)' };
+
+ let el = null, opts = null, data = null, tab = null, timer = null, loading = false, seq = 0;
+
+ function esc(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
+ const isPhone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };
+ function apiWith(o, path, init = {}) {
+ const base = String((o && o.apiBase) || 'https://openvibe.network').replace(/\/+$/, '');
+ let cross = true; try { cross = new URL(base).origin !== root.location.origin; } catch { /* */ }
+ const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}) };
+ const t = o && (typeof o.token === 'function' ? o.token() : o.token);
+ if (t) headers.authorization = `Bearer ${t}`;
+ return fetch(base + path, { ...init, headers, credentials: cross ? 'omit' : 'include' }).then(async (r) => {
+ const body = await r.json().catch(() => ({}));
+ if (!r.ok) { const e = new Error(body.detail || body.title || `HTTP ${r.status}`); e.status = r.status; throw e; }
+ return body;
+ });
+ }
+ const api = (path, init) => apiWith(opts, path, init);
+ function ago(iso) {
+ const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return '';
+ const s = (Date.now() - t) / 1000;
+ if (s < 0) { const m = -s / 60; return m < 60 ? `in ${Math.max(1, Math.round(m))} min` : m < 1440 ? `in ${Math.round(m / 60)} h` : `in ${Math.round(m / 1440)} d`; }
+ if (s < 45) return 'just now';
+ if (s < 3600) return `${Math.max(1, Math.floor(s / 60))} min ago`;
+ if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
+ return `${Math.floor(s / 86400)} d ago`;
+ }
+ const money = (v) => (typeof v === 'number' && v > 0 ? (v < 0.01 ? `$${v.toFixed(4).replace(/0+$/, '')}` : `$${v.toFixed(2)}`) : null);
+
+ function styles() {
+ if (document.getElementById('ovac-styles')) return;
+ const st = document.createElement('style');
+ st.id = 'ovac-styles';
+ st.textContent = `
+ @keyframes ovac-in { from { transform: translateY(-8px); opacity: 0; } to { transform: none; opacity: 1; } }
+ @keyframes ovac-up { from { transform: translateY(48px); opacity: .5; } to { transform: none; opacity: 1; } }
+ @keyframes ovac-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
+ .ovac { position: fixed; z-index: 99999; display: none; flex-direction: column; overflow: hidden; top: var(--ovac-top, 62px); right: max(8px, env(safe-area-inset-right, 0px));
+ width: min(420px, calc(100vw - 16px)); max-height: calc(100vh - var(--ovac-top, 62px) - 12px); max-height: calc(100dvh - var(--ovac-top, 62px) - 12px);
+ background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));
+ border-radius: 18px; box-shadow: 0 24px 60px rgba(0,0,0,.45); font-family: inherit; animation: ovac-in .2s cubic-bezier(.2,.8,.2,1); }
+ .ovac.open { display: flex; }
+ .ovac svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+ .ovac-head { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 14px 10px 8px 16px; }
+ .ovac-head h3 { margin: 0; display: flex; align-items: center; gap: 8px; font-size: 17px; font-weight: 750; letter-spacing: -.01em; }
+ .ovac-head h3 svg { width: 19px; height: 19px; color: var(--accent-light, var(--accent, #60a5fa)); }
+ .ovac-acts { display: flex; gap: 2px; }
+ .ovac-acts button { width: 36px; height: 36px; border: 0; border-radius: 10px; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; }
+ .ovac-acts button:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-primary, #ececf1); }
+ .ovac-acts button svg { width: 18px; height: 18px; }
+ .ovac-acts button.spin svg { animation: ovac-spin .8s linear infinite; }
+ @keyframes ovac-spin { to { transform: rotate(360deg); } }
+ .ovac-acts [data-act="close"] { display: none; }
+ .ovac-tabs { display: flex; overflow-x: auto; scrollbar-width: none; padding: 0 6px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }
+ .ovac-tabs::-webkit-scrollbar { display: none; }
+ .ovac-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; }
+ .ovac-tabs button:hover, .ovac-tabs button[aria-selected="true"] { color: var(--text-primary, #ececf1); }
+ .ovac-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); }
+ .ovac-tabs button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -3px; border-radius: 10px; }
+ .ovac-tabs .n { min-width: 18px; padding: 2px 5px; box-sizing: border-box; border-radius: 999px; font-size: 10.5px; font-weight: 750; line-height: 1.2; text-align: center; font-variant-numeric: tabular-nums;
+ color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--accent, #3b82f6) 24%, transparent); }
+ .ovac-tabs [data-tab="waiting"] .n { background: color-mix(in srgb, var(--warning, #f5a524) 30%, transparent); }
+ .ovac-tabs .n:empty { display: none; }
+ .ovac-note { margin: 8px 12px 0; padding: 8px 12px; border-radius: 12px; font-size: 12.5px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); background: color-mix(in srgb, var(--warning, #f5a524) 12%, transparent); }
+ .ovac-list { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 6px 6px 10px; }
+ .ovac-item { display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; padding: 10px 10px 10px 8px; border-radius: 14px; }
+ .ovac-item:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 4%, transparent); }
+ .ovac-glyph { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; color: color-mix(in srgb, var(--ovac-tone) 68%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovac-tone) 15%, transparent); }
+ .ovac-glyph svg { width: 20px; height: 20px; }
+ .ovac-body { min-width: 0; }
+ .ovac-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 13.5px; font-weight: 650; line-height: 1.32; color: inherit; text-decoration: none; }
+ .ovac-title:hover { text-decoration: underline; text-underline-offset: 2px; }
+ .ovac-sum { margin: 2px 0 0; font-size: 12.5px; line-height: 1.42; color: var(--text-secondary, #b6b6c0); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
+ .ovac-item[data-bucket="failed"] .ovac-sum { color: color-mix(in srgb, var(--live-red, #e5484d) 55%, var(--text-primary, #ececf1)); }
+ .ovac-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-top: 6px; font-size: 11.5px; color: var(--text-muted, #8b8b96); }
+ .ovac-state { display: inline-flex; align-items: center; gap: 5px; font-weight: 700; color: color-mix(in srgb, var(--ovac-tone) 55%, var(--text-primary, #ececf1)); }
+ .ovac-state::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--ovac-tone); }
+ .ovac-item[data-bucket="running"] .ovac-state::before { animation: ovac-pulse 1.4s ease-in-out infinite; }
+ .ovac-cost { font-variant-numeric: tabular-nums; font-weight: 650; color: var(--text-secondary, #b6b6c0); }
+ .ovac-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
+ .ovac-btns button, .ovac-btns a { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 0 14px; border-radius: 999px; font: inherit; font-size: 12.5px; font-weight: 700; cursor: pointer; text-decoration: none;
+ border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); }
+ .ovac-btns .primary { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, var(--on-accent, #fff)); }
+ .ovac-btns button:disabled { opacity: .55; cursor: default; }
+ .ovac-btns svg { width: 14px; height: 14px; }
+ .ovac-err { margin: 6px 0 0; font-size: 12px; color: color-mix(in srgb, var(--live-red, #e5484d) 60%, var(--text-primary, #ececf1)); }
+ .ovac-empty { text-align: center; padding: 44px 24px 52px; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }
+ .ovac-empty svg { width: 46px; height: 46px; display: block; margin: 0 auto 12px; color: var(--accent, #3b82f6); opacity: .75; stroke-width: 1.4; }
+ .ovac-empty b { display: block; margin-bottom: 4px; font-size: 15px; color: var(--text-primary, #ececf1); }
+ .ovac-wait { display: flex; justify-content: center; padding: 28px; }
+ .ovac-wait span { width: 20px; height: 20px; border-radius: 50%; border: 2px solid var(--border, rgba(255,255,255,.12)); border-top-color: var(--accent, #3b82f6); animation: ovac-spin .8s linear infinite; }
+ .ovac-foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #8b8b96); }
+ .ovac-foot a { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; text-decoration: none; }
+ @media (max-width: 640px) {
+ .ovac { top: calc(env(safe-area-inset-top, 0px) + 10px); left: 0; right: 0; bottom: 0; width: auto; max-height: none; border-radius: 22px 22px 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); animation-name: ovac-up; animation-duration: .28s; }
+ .ovac-head { padding-top: 20px; touch-action: none; }
+ .ovac-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))); }
+ .ovac-acts [data-act="close"] { display: grid; }
+ .ovac-acts button { width: 40px; height: 40px; }
+ }
+ html.ovac-sheet-open, html.ovac-sheet-open body { overflow: hidden; }
+ .ovac-mounted { display: flex; flex-direction: column; min-height: 0; color: var(--text-primary, #ececf1); }
+ .ovac-mounted .ovac-list { padding: 4px 6px 10px; }
+ .ovac-mounted svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+ .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);
+ }
+
+ function build() {
+ styles();
+ el = document.createElement('div');
+ el.className = 'ovac';
+ el.setAttribute('role', 'dialog');
+ el.setAttribute('aria-label', 'Agents');
+ el.hidden = true;
+ el.innerHTML = `
+ <div class="ovac-head"><h3>${svg(G.spark)}Agents</h3>
+ <div class="ovac-acts"><button type="button" data-act="refresh" title="Refresh" aria-label="Refresh">${svg(G.refresh)}</button><button type="button" data-act="close" title="Close" aria-label="Close">${svg(G.close)}</button></div></div>
+ <div class="ovac-tabs" role="tablist" aria-label="Agents">${BUCKETS.map(([id]) => `<button type="button" role="tab" data-tab="${id}" aria-selected="false" tabindex="-1">${SHORT[id]}<span class="n" data-n="${id}"></span></button>`).join('')}</div>
+ <div class="ovac-note" hidden></div>
+ <div class="ovac-list" role="tabpanel" aria-live="polite"></div>
+ <div class="ovac-foot"><span data-role="when"></span><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;
+ document.body.appendChild(el);
+ const tabs = [...el.querySelectorAll('[role="tab"]')];
+ const pick = (b, focus) => { setTab(b.dataset.tab); if (focus) b.focus(); };
+ tabs.forEach((b) => b.addEventListener('click', () => pick(b)));
+ el.querySelector('.ovac-tabs').addEventListener('keydown', (e) => {
+ const i = tabs.indexOf(document.activeElement); if (i < 0) return;
+ const j = e.key === 'ArrowRight' ? (i + 1) % tabs.length : e.key === 'ArrowLeft' ? (i - 1 + tabs.length) % tabs.length : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : -1;
+ if (j >= 0) { e.preventDefault(); pick(tabs[j], true); }
+ });
+ el.querySelector('[data-act="refresh"]').addEventListener('click', () => load());
+ el.querySelector('[data-act="close"]').addEventListener('click', () => close());
+ el.querySelector('.ovac-list').addEventListener('click', (e) => {
+ const b = e.target.closest('button[data-do]'); if (!b) return;
+ decide(b.closest('.ovac-item'), b.dataset.do, b);
+ });
+ document.addEventListener('click', (e) => { if (isOpen() && !el.contains(e.target) && !(opts && opts.anchor && opts.anchor.contains(e.target))) close(); });
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isOpen()) close(); });
+ // Phones: the sheet's head drags down to close.
+ const head = el.querySelector('.ovac-head');
+ let y0 = null, dy = 0;
+ head.addEventListener('pointerdown', (e) => { if (!isPhone() || e.pointerType === 'mouse' || e.target.closest('button')) return; y0 = e.clientY; dy = 0; el.style.transition = 'none'; });
+ head.addEventListener('pointermove', (e) => { if (y0 == null) return; dy = Math.max(0, e.clientY - y0); el.style.transform = `translateY(${dy}px)`; });
+ const end = () => { if (y0 == null) return; y0 = null; el.style.transition = 'transform .2s ease'; if (dy > 110) close(); else el.style.transform = ''; setTimeout(() => { el.style.transition = ''; }, 220); };
+ head.addEventListener('pointerup', end); head.addEventListener('pointercancel', end);
+ if (root.OpenVibePanels) { try { root.OpenVibePanels.register({ el, openClass: 'open', id: 'action-center', close: () => close() }); } catch { /* */ } }
+ }
+
+ function setTab(id) {
+ tab = id;
+ el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === id; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
+ el.querySelector('.ovac-list').setAttribute('aria-label', (BUCKETS.find(([b]) => b === id) || ['', ''])[1]);
+ paint();
+ }
+
+ function itemHTML(it) {
+ const tone = TONE[it.bucket] || TONE.running;
+ const meta = [
+ `<span class="ovac-state">${esc(it.bucket === 'waiting' ? 'Needs you' : SHORT[it.bucket] || it.state)}</span>`,
+ `<span>${esc([SERVICE[it.service] || it.service, it.agent].filter(Boolean).join(' · '))}</span>`,
+ money(it.cost_usd) ? `<span class="ovac-cost">${money(it.cost_usd)}</span>` : '',
+ typeof it.steps === 'number' && it.steps > 0 ? `<span>${it.steps} step${it.steps === 1 ? '' : 's'}</span>` : '',
+ `<span>${esc(it.bucket === 'scheduled' && it.next_run_at ? `runs ${ago(it.next_run_at)}` : ago(it.updated_at))}</span>`,
+ ].join('');
+ const decides = it.bucket === 'waiting' && it.actions.includes('approve');
+ const btns = decides
+ ? `<div class="ovac-btns"><button type="button" class="primary" data-do="approve">Approve</button><button type="button" data-do="decline">Decline</button><a href="${esc(it.href)}">${svg(G.open)}Details</a></div>`
+ : it.bucket === 'failed' && it.actions.includes('retry') ? `<div class="ovac-btns"><a href="${esc(it.href)}">${svg(G.refresh)}Try again</a></div>` : '';
+ const text = it.bucket === 'failed' && it.error ? it.error : it.summary;
+ return `<article class="ovac-item" data-bucket="${esc(it.bucket)}" data-service="${esc(it.service)}" data-id="${esc(it.id)}" style="--ovac-tone:${tone}">
+ <span class="ovac-glyph">${svg(G[KIND_GLYPH[it.kind]] || G.spark)}</span>
+ <div class="ovac-body"><a class="ovac-title" href="${esc(it.href)}">${esc(it.title)}</a>${text ? `<p class="ovac-sum">${esc(text)}</p>` : ''}
+ <div class="ovac-meta">${meta}</div>${btns}</div></article>`;
+ }
+
+ function paint() {
+ if (!el) return;
+ const list = el.querySelector('.ovac-list');
+ const note = el.querySelector('.ovac-note');
+ if (!data) { list.innerHTML = '<div class="ovac-wait"><span></span></div>'; note.hidden = true; return; }
+ if (data.error) {
+ list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${data.error.status === 401 ? 'Sign in to see your agents' : 'Could not load your agents'}</b>${data.error.status === 401 ? '' : 'Check your connection and try again.'}</div>`;
+ note.hidden = true;
+ return;
+ }
+ for (const [id] of BUCKETS) {
+ const n = data.counts[id] || 0;
+ const span = el.querySelector(`[data-n="${id}"]`);
+ span.textContent = n ? (n > 99 ? '99+' : String(n)) : '';
+ span.parentNode.setAttribute('aria-label', n ? `${SHORT[id]}, ${n}` : SHORT[id]);
+ }
+ const missing = Object.entries(data.sources || {}).filter(([, s]) => s === 'timeout' || s === 'unavailable').map(([k]) => SERVICE[k] || k);
+ note.hidden = !missing.length;
+ note.textContent = missing.length ? `${missing.join(' and ')} did not answer, so some items may be missing. Refresh to try again.` : '';
+ const items = data.buckets[tab] || [];
+ if (!items.length) {
+ const [t, s] = EMPTY[tab] || EMPTY.running;
+ list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${esc(t)}</b>${esc(s)}</div>`;
+ } else list.innerHTML = items.map(itemHTML).join('');
+ const when = el.querySelector('[data-role="when"]');
+ when.textContent = data.generated_at ? `Updated ${ago(data.generated_at)}` : '';
+ }
+
+ async function load({ quiet = false } = {}) {
+ if (loading) return;
+ loading = true;
+ const my = ++seq;
+ const btn = el && el.querySelector('[data-act="refresh"]');
+ if (btn) btn.classList.add('spin');
+ if (!quiet && !data) paint();
+ try {
+ const doc = await api('/api/v1/me/actions');
+ if (my !== seq) return;
+ data = doc;
+ // First open: the tab with something in it, waiting first.
+ if (!tab) tab = ['waiting', 'running', 'scheduled', 'done', 'failed'].find((b) => (doc.counts[b] || 0) > 0) || 'running';
+ setTab(tab);
+ } catch (e) {
+ if (my !== seq) return;
+ data = { error: e };
+ if (!tab) setTab('running'); else paint();
+ } finally {
+ loading = false;
+ if (btn) btn.classList.remove('spin');
+ schedule();
+ }
+ }
+ function schedule() {
+ clearTimeout(timer);
+ if (isOpen() && data && !data.error && (data.counts.running || 0) > 0) timer = setTimeout(() => load({ quiet: true }), REFRESH_MS);
+ }
+
+ async function decide(item, what, btn, call = api, reload = () => load({ quiet: true })) {
+ if (!item || (what !== 'approve' && what !== 'decline')) return;
+ item.querySelectorAll('.ovac-btns button').forEach((b) => { b.disabled = true; });
+ const old = item.querySelector('.ovac-err'); if (old) old.remove();
+ try {
+ await call(`/api/v1/me/actions/${encodeURIComponent(item.dataset.service)}/${encodeURIComponent(item.dataset.id)}/${what}`, { method: 'POST', body: '{}' });
+ btn.textContent = what === 'approve' ? 'Approved' : 'Declined';
+ setTimeout(reload, 600);
+ } catch (e) {
+ item.querySelectorAll('.ovac-btns button').forEach((b) => { b.disabled = false; });
+ item.querySelector('.ovac-body').insertAdjacentHTML('beforeend', `<p class="ovac-err" role="alert">${esc(e.message || 'That did not work. Try again.')}</p>`);
+ }
+ }
+
+ function isOpen() { return !!(el && el.classList.contains('open')); }
+ function open(o) {
+ opts = { ...(opts || {}), ...(o || {}) };
+ if (!el) build();
+ if (root.OpenVibePanels) { try { root.OpenVibePanels.closeAll(el); } catch { /* */ } }
+ let bottom = 56;
+ try {
+ const bar = opts.anchor && opts.anchor.closest && opts.anchor.closest('nav, header, .navbar');
+ bottom = (bar || opts.anchor || null) ? (bar || opts.anchor).getBoundingClientRect().bottom : 56;
+ } catch { /* no layout: under a 56 px bar */ }
+ el.style.setProperty('--ovac-top', `${Math.max(8, Math.round(bottom) + 6)}px`);
+ el.hidden = false;
+ el.setAttribute('aria-modal', 'true');
+ el.classList.add('open');
+ el.style.transform = '';
+ document.documentElement.classList.toggle('ovac-sheet-open', isPhone());
+ if (opts.anchor) { opts.anchor.setAttribute('aria-expanded', 'true'); opts.anchor.classList.add('open'); }
+ load();
+ setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]')?.focus({ preventScroll: true }); } catch { /* */ } }, 0);
+ }
+ function close() {
+ if (!el) return;
+ const inside = el.contains(document.activeElement);
+ el.classList.remove('open');
+ el.removeAttribute('aria-modal');
+ el.hidden = true;
+ el.style.transform = '';
+ clearTimeout(timer);
+ document.documentElement.classList.remove('ovac-sheet-open');
+ if (opts && opts.anchor) { opts.anchor.setAttribute('aria-expanded', 'false'); opts.anchor.classList.remove('open'); if (inside) { try { opts.anchor.focus({ preventScroll: true }); } catch { /* */ } } }
+ }
+ function toggle(o) { if (isOpen()) close(); else open(o); }
+
+ /**
+ * A list of some buckets in someone else's container (the Dock's Agent tab): waiting first, then running, each
+ * bucket under a small heading when there is more than one. Its own data, timer and errors; the popover's are
+ * untouched. onCounts(counts) hears every answer, so the Dock's tab and pill can follow it.
+ */
+ function mount(container, o = {}) {
+ styles();
+ const mo = { ...o };
+ const want = Array.isArray(mo.buckets) && mo.buckets.length ? mo.buckets.filter((b) => SHORT[b]) : ['waiting', 'running'];
+ const call = (path, init) => apiWith(mo, path, init);
+ let doc = null, t = 0, gone = false, busy = false;
+ container.classList.add('ovac-mounted');
+ container.innerHTML = '<div class="ovac-list" aria-live="polite"><div class="ovac-wait"><span></span></div></div>';
+ const list = container.firstChild;
+ const draw = () => {
+ if (gone) return;
+ if (doc && doc.error) {
+ list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${doc.error.status === 401 ? 'Sign in to see your agents' : 'Could not load your agents'}</b>${doc.error.status === 401 ? '' : 'Check your connection and try again.'}</div>`;
+ return;
+ }
+ if (!doc) return;
+ const groups = want.map((b) => [b, (doc.buckets && doc.buckets[b]) || []]).filter(([, items]) => items.length);
+ if (!groups.length) {
+ const [h, p] = mo.empty || ['Nothing is running', 'When an agent works for you, it shows here with its steps, and anything it asks you shows first.'];
+ list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${esc(h)}</b>${esc(p)}</div>`;
+ return;
+ }
+ list.innerHTML = groups.map(([b, items]) => (groups.length > 1 ? `<h4 class="ovac-group" data-bucket="${b}">${esc(b === 'waiting' ? 'Needs you' : SHORT[b])}<span>${items.length}</span></h4>` : '')
+ + items.map(itemHTML).join('')).join('');
+ };
+ const refresh = async () => {
+ if (gone || busy) return;
+ busy = true; clearTimeout(t);
+ try { doc = await call('/api/v1/me/actions'); } catch (e) { doc = { error: e }; }
+ busy = false;
+ if (gone) return;
+ draw();
+ if (doc && !doc.error && typeof mo.onCounts === 'function') { try { mo.onCounts(doc.counts || {}); } catch { /* */ } }
+ if (doc && !doc.error && (doc.counts.running || 0) > 0) t = setTimeout(refresh, REFRESH_MS);
+ };
+ list.addEventListener('click', (e) => {
+ const b = e.target.closest('button[data-do]'); if (!b) return;
+ decide(b.closest('.ovac-item'), b.dataset.do, b, call, refresh);
+ });
+ refresh();
+ return { refresh, destroy() { gone = true; clearTimeout(t); container.classList.remove('ovac-mounted'); container.innerHTML = ''; } };
+ }
+
+ root.OpenVibeActionCenter = { open, close, toggle, isOpen, mount, _state: () => ({ data, tab }) };
+})(typeof window !== 'undefined' ? window : globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,799 @@
@@ -0,0 +1,799 @@
+// ═══════════════════════════════════════════════════════════════
+// 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), 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). The agent's live screen is K-D3.
+// 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.
+// ═══════════════════════════════════════════════════════════════
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OpenVibeDock) return;
+
+ const W = [320, 720], H = [360, 900], DEF = { width: 400, height: 560 };
+ const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
+ const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
+ const G = {
+ spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',
+ bell: '<path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2h-14z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>',
+ 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"/>',
+ 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"/>',
+ };
+ const TABS = [['agent', 'Agents', G.spark], ['chats', 'Chats', G.chat], ['activity', 'Activity', G.bell]];
+ 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;
+ // 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; } };
+ 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}`);
+
+ function call(path, init = {}) {
+ const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');
+ const t = typeof o.token === 'function' ? o.token() : o.token;
+ const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
+ return fetch(base + path, { ...init, headers, credentials: 'omit' }).then((r) => (r.ok ? r.json() : null));
+ }
+ const notify = () => { store(doc); paintPill(); };
+
+ /** A change to the state: painted now, sent to the Network a beat later (several changes go as one PUT). */
+ function save(patch) {
+ const st = state();
+ const next = { ...st, ...patch, place: patch.place ? { ...st.place, ...patch.place } : st.place };
+ doc = { ...(doc || {}), state: next, counts: counts() };
+ pending = { ...pending, ...patch, ...(patch.place ? { place: { ...(pending.place || {}), ...patch.place } } : {}) };
+ notify();
+ clearTimeout(saveT);
+ saveT = setTimeout(flush, 700);
+ }
+ async function flush() {
+ const body = pending; pending = {};
+ if (!Object.keys(body).length) return;
+ try {
+ let r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(body) });
+ // A Network whose state contract does not know "popped" yet refuses the whole body; ask again without it,
+ // so the Dock's place and its tab still reach the Network.
+ if (!r && 'popped' in body) {
+ const rest = { ...body }; delete rest.popped;
+ r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(rest) });
+ }
+ if (r && r.state && !Object.keys(pending).length) { doc = { ...doc, state: r.state, counts: r.counts || counts() }; notify(); badges(); }
+ } catch { /* offline, or no route yet: the copy stands and the next change tries again */ }
+ }
+
+ 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); }
+ sc.addEventListener('load', () => ok(test()));
+ sc.addEventListener('error', () => ok(false));
+ });
+ }
+
+ function styles() {
+ if (document.getElementById('ovdk-styles')) return;
+ 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));
+ 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; }
+ .ovdk-pill[data-corner$="left"] { right: auto; left: max(16px, env(safe-area-inset-left, 0px)); }
+ .ovdk-pill[data-corner^="top"] { bottom: auto; top: calc(66px + env(safe-area-inset-top, 0px)); }
+ .ovdk-pill[data-tone="waiting"] { --t: var(--warning, #f5a524); }
+ .ovdk-pill:hover { border-color: color-mix(in srgb, var(--t) 45%, var(--border, #333340)); }
+ .ovdk-pill:active { scale: .96; }
+ .ovdk-pill:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 3px; }
+ .ovdk-m { position: relative; display: grid; place-items: center; width: 30px; height: 30px; }
+ .ovdk-m::before, .ovdk-m::after { content: ''; position: absolute; top: -1px; right: -2px; width: 9px; height: 9px; border-radius: 50%; background: var(--t); }
+ .ovdk-m::after { box-shadow: 0 0 0 2.5px var(--bg); }
+ .ovdk-pill[data-tone="waiting"] .ovdk-m::before { animation: ovdk-ping 1.8s ease-out infinite; }
+ .ovdk-pill[data-tone="idle"] .ovdk-m::before, .ovdk-pill[data-tone="idle"] .ovdk-m::after, .ovdk-t:empty { display: none; }
+ .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;
+ 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));
+ border-radius: 22px; box-shadow: 0 30px 80px -24px rgba(0,0,0,.62), 0 2px 10px -2px rgba(0,0,0,.25); font-family: inherit; transform-origin: 100% 100%; }
+ .ovdk[hidden] { display: none; }
+ .ovdk[data-corner$="left"] { right: auto; left: max(var(--ovdk-gap), env(safe-area-inset-left, 0px)); }
+ .ovdk[data-corner^="top"] { bottom: auto; top: calc(66px + env(safe-area-inset-top, 0px)); }
+ .ovdk[data-corner="bottom-left"] { transform-origin: 0 100%; }
+ .ovdk[data-corner="top-right"] { transform-origin: 100% 0; }
+ .ovdk[data-corner="top-left"] { transform-origin: 0 0; }
+ .ovdk.is-in { animation: ovdk-grow .3s cubic-bezier(.2,.9,.25,1.08); }
+ .ovdk.is-moving { transition: none !important; box-shadow: 0 40px 90px -20px rgba(0,0,0,.7); cursor: grabbing; }
+ @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; }
+ .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; }
+ .ovdk-tabs button { position: relative; display: inline-flex; align-items: center; gap: 7px; height: 36px; padding: 0 14px 0 11px; border: 0; border-radius: 999px; background: transparent;
+ 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"] 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;
+ color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); }
+ .ovdk-tabs .n[data-tone="waiting"] { background: color-mix(in srgb, var(--warning, #f5a524) 34%, transparent); }
+ .ovdk-tabs .n:empty { display: none; }
+ .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-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; }
+ .ovdk-scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
+ .ovdk-foot { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12.5px; }
+ .ovdk-foot a, .ovdk-foot button { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0; border: 0; background: none; font: inherit; font-weight: 700; cursor: pointer; text-decoration: none; color: var(--accent-light, var(--accent, #60a5fa)); }
+ .ovdk-foot button { color: var(--text-secondary, #b6b6c0); }
+ .ovdk-foot button:hover { color: var(--text-primary, #ececf1); }
+ .ovdk-foot svg { width: 15px; height: 15px; }
+ .ovdk-wait { display: flex; justify-content: center; padding: 36px; }
+ .ovdk-wait span { width: 20px; height: 20px; border-radius: 50%; border: 2px solid var(--border, rgba(255,255,255,.12)); border-top-color: var(--accent, #3b82f6); animation: ovdk-spin .8s linear infinite; }
+ @keyframes ovdk-spin { to { transform: rotate(360deg); } }
+ .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[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; }
+ .ovdk[data-corner="top-right"] .ovdk-size::after { top: auto; bottom: 9px; border-top: 0; border-bottom: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 0; border-bottom-left-radius: 3px; }
+ .ovdk[data-corner="top-left"] .ovdk-size { top: auto; bottom: 0; left: auto; right: 0; }
+ .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 11px; gap: 6px; } .ovdk-tabs button svg { display: none; } }
+ .ovdk-muted { color: var(--text-muted, #8b8b96); }
+ .ovdk-i { display: inline-grid; place-items: center; }
+ .ovdk-i svg { width: 18px; height: 18px; }
+ .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; }
+ .ovdk-chat:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 5%, transparent); }
+ .ovdk-chat:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: -2px; }
+ .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-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; }
+ .is-unread .ovdk-chat-b b, .is-unread .ovdk-chat-p { color: var(--text-primary, #ececf1); font-weight: 750; }
+ .ovdk-chat-m { display: grid; justify-items: end; gap: 4px; font-size: 11.5px; color: var(--text-muted, #8b8b96); font-variant-numeric: tabular-nums; }
+ .ovdk-unread { min-width: 20px; padding: 1px 6px; box-sizing: border-box; border-radius: 999px; text-align: center; font-size: 11px; font-weight: 800;
+ color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }
+ .ovdk-thread-h { flex: none; display: flex; align-items: center; gap: 6px; padding: 6px 10px 6px 6px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }
+ .ovdk-thread-h b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; }
+ .ovdk-back { width: 40px; height: 40px; flex: none; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; display: grid; place-items: center; }
+ .ovdk-back:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); }
+ .ovdk-msgs { list-style: none; margin: 0; padding: 12px; display: flex; flex-direction: column; gap: 3px; }
+ .ovdk-msg { display: grid; justify-items: start; max-width: 84%; margin-top: 8px; }
+ .ovdk-msg.is-cont { margin-top: 0; }
+ .ovdk-msg.is-mine { align-self: flex-end; justify-items: end; }
+ .ovdk-who { margin: 0 0 3px 10px; font-size: 11.5px; font-weight: 700; color: var(--text-muted, #8b8b96); }
+ .ovdk-text { padding: 8px 12px; border-radius: 16px; font-size: 14px; line-height: 1.42; white-space: pre-wrap; overflow-wrap: anywhere;
+ background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); }
+ .ovdk-msg.is-mine .ovdk-text { color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }
+ .ovdk-send { flex: none; display: flex; align-items: flex-end; gap: 8px; margin: 8px 10px 10px; padding: 4px 4px 4px 12px; border-radius: 16px;
+ 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;
+ 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.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-size { display: none; }
+ .ovdk-tabs { flex: 1; } .ovdk-tabs button { flex: 1; justify-content: center; }
+ }
+ 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;
+ border-radius: 0 !important; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)) !important; box-shadow: none !important; }
+ .ovdk-pip .ovdk-head { padding-top: 10px !important; cursor: grab !important; }
+ .ovdk-pip .ovdk-head::before { display: none !important; }
+ .ovdk-pip .ovdk-size { display: none !important; }
+ `;
+ document.head.appendChild(st);
+ }
+
+ function build() {
+ styles();
+ el = document.createElement('section');
+ el.className = 'ovdk';
+ el.setAttribute('role', 'dialog');
+ el.setAttribute('aria-label', 'Dock');
+ el.hidden = true;
+ el.innerHTML = `
+ <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="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>
+ </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);
+
+ const tabs = [...el.querySelectorAll('[role="tab"]')];
+ tabs.forEach((b) => b.addEventListener('click', () => setTab(b.dataset.tab, true)));
+ el.querySelector('.ovdk-tabs').addEventListener('keydown', (e) => {
+ const i = tabs.indexOf(document.activeElement); if (i < 0) return;
+ const j = e.key === 'ArrowRight' ? (i + 1) % tabs.length : e.key === 'ArrowLeft' ? (i - 1 + tabs.length) % tabs.length : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : -1;
+ if (j >= 0) { e.preventDefault(); setTab(tabs[j].dataset.tab, true); tabs[j].focus(); }
+ });
+ el.querySelector('[data-act="close"]').addEventListener('click', () => close());
+ 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.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;
+ 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();
+ });
+ root.addEventListener('resize', () => { if (isOpen()) layout(); });
+ bindMove();
+ bindSize();
+ paintPop();
+ }
+
+ // ── Where it sits ────────────────────────────────────────
+ function layout() {
+ const p = place();
+ el.dataset.corner = CORNERS.includes(p.corner) ? p.corner : 'bottom-right';
+ if (phone()) { el.style.width = ''; el.style.height = ''; clear(0); return; }
+ const w = clamp(p.width || DEF.width, W[0], Math.max(W[0], Math.min(W[1], root.innerWidth - 32)));
+ const h = clamp(p.height || DEF.height, H[0], Math.max(H[0], Math.min(H[1], root.innerHeight - 96)));
+ el.style.width = `${w}px`; el.style.height = `${h}px`;
+ // Desktop toasts (bottom right) stack above the card when there is room, so neither hides the other.
+ clear(el.dataset.corner === 'bottom-right' && root.innerHeight - h > 320 ? h + 12 : 0);
+ }
+ const clear = (px) => document.documentElement.style.setProperty('--ov-dock-clear', `${px}px`);
+
+ /**
+ * The head drags the card, the tabs too once the pointer has moved 6 px (a tap still picks the tab; pin and close
+ * never drag). On release it snaps to the nearest corner (a FLIP, so it glides there).
+ */
+ function bindMove() {
+ const head = el.querySelector('.ovdk-head');
+ let s = null, swallow = false;
+ // Armed on the press; the window hears the moves, so a quick drag that leaves the head before 6 px still counts.
+ const move = (e) => {
+ if (!s || e.pointerId !== s.id) return;
+ s.dx = e.clientX - s.x; s.dy = e.clientY - s.y;
+ if (!s.live) {
+ if (Math.abs(s.dx) + Math.abs(s.dy) < 6) return;
+ s.live = true;
+ if (!phone()) el.classList.add('is-moving');
+ }
+ if (phone()) { if (e.pointerType !== 'mouse') el.style.transform = `translateY(${Math.max(0, s.dy)}px)`; return; }
+ el.style.transform = `translate(${s.dx}px, ${s.dy}px)`;
+ };
+ const end = (e) => {
+ if (!s || (e && e.pointerId !== s.id)) return;
+ const { r, dx, dy, live } = s; s = null;
+ root.removeEventListener('pointermove', move); root.removeEventListener('pointerup', end); root.removeEventListener('pointercancel', end);
+ if (!live) return;
+ swallow = true; setTimeout(() => { swallow = false; }, 0);
+ el.classList.remove('is-moving');
+ if (phone()) {
+ el.style.transition = 'transform .2s ease';
+ if (dy > 110) close(); else el.style.transform = '';
+ setTimeout(() => { el.style.transition = ''; }, 220);
+ return;
+ }
+ const cx = r.left + dx + r.width / 2, cy = r.top + dy + r.height / 2;
+ const corner = `${cy < root.innerHeight / 2 ? 'top' : 'bottom'}-${cx < root.innerWidth / 2 ? 'left' : 'right'}`;
+ const from = { x: r.left + dx, y: r.top + dy };
+ el.style.transform = '';
+ save({ place: { desktop: { ...place(), corner } } });
+ layout();
+ const to = el.getBoundingClientRect();
+ if (reduced()) return;
+ el.style.transform = `translate(${from.x - to.left}px, ${from.y - to.top}px)`;
+ el.getBoundingClientRect();
+ el.style.transition = 'transform .28s cubic-bezier(.2,.9,.25,1.05)';
+ el.style.transform = '';
+ setTimeout(() => { el.style.transition = ''; }, 300);
+ };
+ head.addEventListener('pointerdown', (e) => {
+ if (e.button || e.target.closest('.ovdk-acts')) 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);
+ });
+ // A drag that began on a tab is a drag, not a pick.
+ head.addEventListener('click', (e) => { if (swallow) { swallow = false; e.preventDefault(); e.stopPropagation(); } }, true);
+ }
+
+ /** The free corner (the one away from the anchor) resizes the card. */
+ function bindSize() {
+ const grip = el.querySelector('.ovdk-size');
+ let s = null;
+ grip.addEventListener('pointerdown', (e) => {
+ if (e.button || phone()) return;
+ e.preventDefault();
+ const r = el.getBoundingClientRect();
+ s = { x: e.clientX, y: e.clientY, w: r.width, h: r.height, id: e.pointerId, c: el.dataset.corner };
+ try { grip.setPointerCapture(e.pointerId); } catch { /* */ }
+ });
+ grip.addEventListener('pointermove', (e) => {
+ if (!s || e.pointerId !== s.id) return;
+ const dx = e.clientX - s.x, dy = e.clientY - s.y;
+ const w = clamp(s.w + (s.c.endsWith('right') ? -dx : dx), W[0], Math.min(W[1], root.innerWidth - 32));
+ const h = clamp(s.h + (s.c.startsWith('bottom') ? -dy : dy), H[0], Math.min(H[1], root.innerHeight - 96));
+ el.style.width = `${Math.round(w)}px`; el.style.height = `${Math.round(h)}px`;
+ s.nw = Math.round(w); s.nh = Math.round(h);
+ });
+ const end = (e) => {
+ if (!s || (e && e.pointerId !== s.id)) return;
+ const { nw, nh } = s; s = null;
+ if (nw && nh) { save({ place: { desktop: { ...place(), width: nw, height: nh } } }); layout(); }
+ };
+ grip.addEventListener('pointerup', end); grip.addEventListener('pointercancel', end);
+ }
+
+ // ── Tabs ─────────────────────────────────────────────────
+ function badges() {
+ if (!el) return;
+ const c = counts();
+ const agent = (c.waiting || 0) + (c.running || 0);
+ const n = (k, v, tone) => { const b = el.querySelector(`[data-n="${k}"]`); b.textContent = v ? (v > 99 ? '99+' : String(v)) : ''; b.dataset.tone = tone || ''; };
+ 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-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);
+ }
+ function setTab(id, remember) {
+ tab = TABS.some(([t]) => t === id) ? id : 'activity';
+ if (tab !== 'chats') clearTimeout(chats.timer);
+ 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; });
+ if (remember && state().tab !== tab) save({ tab });
+ fill(tab);
+ }
+
+ async function fill(id) {
+ const panel = el.querySelector(`[data-panel="${id}"]`);
+ 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; }
+ mounts[id] = { refresh: null };
+ panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
+ if (id === 'agent') {
+ const ok = await load('action-center.js', () => !!(root.OpenVibeActionCenter && root.OpenVibeActionCenter.mount));
+ if (!ok) return failed(panel, id);
+ panel.innerHTML = `<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>`;
+ const m = root.OpenVibeActionCenter.mount(panel.firstChild, {
+ token: o.token, apiBase: o.apiBase, buckets: ['waiting', 'running'],
+ 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, anchor: (pill && !pill.hidden) ? pill : anchor }); } catch { /* */ }
+ });
+ return;
+ }
+ const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));
+ if (!ok) return failed(panel, id);
+ const N = root.OpenVibeNotifications;
+ if (!N.__ovNavInit) { try { N.init({ token: typeof o.token === 'function' ? o.token() : o.token, apiBase: o.apiBase || 'https://openvibe.network' }); N.__ovNavInit = true; } catch { /* */ } }
+ panel.innerHTML = '';
+ N.renderDock(panel);
+ mounts[id] = { refresh: () => { try { N.refresh(); } catch { /* */ } } };
+ }
+ function failed(panel, id) {
+ mounts[id] = null;
+ panel.innerHTML = `<div class="ovdk-none">That part of the Dock did not load. Check your connection, then open it again.</div>`;
+ }
+
+ // ── Chats (Dock plan K-D2) ───────────────────────────────
+ // 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 chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');
+ const me = () => String(o.me || '');
+ 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; };
+ function chatCall(path, init = {}) {
+ const t = typeof o.token === 'function' ? o.token() : o.token;
+ const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
+ return fetch(chatBase() + path, { ...init, 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; }
+ 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);
+ }
+ 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`;
+ };
+ function avatar(p, group) {
+ const a = h('span', 'ovdk-av');
+ const url = p && /^https:\/\//.test(String(p.avatar_url || '')) ? p.avatar_url : '';
+ if (url && !group) { const img = h('img'); img.src = url; img.alt = ''; img.loading = 'lazy'; img.referrerPolicy = 'no-referrer'; a.append(img); }
+ else a.textContent = group ? '#' : String((p && (p.display_name || p.username)) || '?').trim().charAt(0).toUpperCase() || '?';
+ return a;
+ }
+ 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);
+ 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;
+ panel.textContent = '';
+ const scroll = h('div', 'ovdk-scroll');
+ if (!chats.list.length) {
+ 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) => {
+ const li = h('li'), b = h('button', 'ovdk-chat');
+ b.type = 'button';
+ const unread = Number(c.unread_count) || 0;
+ const mine = (c.participants || []).some((p) => p.id === c.last_sender_id && p.username === me());
+ const body = h('span', 'ovdk-chat-b');
+ body.append(h('b', '', titleOf(c)), h('span', 'ovdk-chat-p', c.last_message ? `${mine ? 'You: ' : ''}${String(c.last_message).replace(/\s+/g, ' ').slice(0, 140)}` : 'No messages yet'));
+ 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.setAttribute('aria-label', `${titleOf(c)}${unread ? `, ${unread} unread` : ''}`);
+ if (unread) li.className = 'is-unread';
+ b.addEventListener('click', () => openChat(String(c.id), true));
+ li.append(b); ul.append(li);
+ });
+ 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.append(h('span', 'ovdk-text', String(m.message || '')));
+ li.title = new Date(m.created_at).toLocaleString();
+ return li;
+ }
+ async function openChat(id, remember) {
+ clearTimeout(chats.timer);
+ const panel = chats.panel; if (!panel) return;
+ chats.open = String(id); chats.msgs = []; chats.last = 0;
+ if (remember) save({ chat: chats.open });
+ const conv = (chats.list || []).find((c) => String(c.id) === chats.open);
+ panel.textContent = '';
+ const head = h('div', 'ovdk-thread-h');
+ 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);
+ // 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');
+ const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);
+ const form = h('form', 'ovdk-send');
+ const box = h('textarea'); box.rows = 1; box.maxLength = 4000; box.placeholder = 'Message'; box.setAttribute('aria-label', 'Message');
+ 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;
+ 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`; };
+ 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 })); } });
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const text = box.value.trim();
+ if (!text || chats.busy) return;
+ chats.busy = true; go.disabled = true; err.hidden = true;
+ try {
+ await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages`, { method: 'POST', body: JSON.stringify({ message: text }) });
+ box.value = ''; fit();
+ await pull(list, scroll);
+ } catch (x) {
+ err.textContent = x.status === 429 ? 'Slow down a little, then send it again.' : x.status === 403 ? 'You can’t send messages in this conversation.' : 'That did not send. Try again.';
+ err.hidden = false;
+ } finally { chats.busy = false; go.disabled = false; box.focus(); }
+ });
+ list.append(h('li', 'ovdk-wait', null));
+ list.firstChild.innerHTML = '<span></span>';
+ await pull(list, scroll, true);
+ setTimeout(() => { try { box.focus({ preventScroll: true }); } 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.')); }
+ return;
+ }
+ if (id !== chats.open) 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); });
+ if (first && !chats.msgs.length) list.append(h('li', 'ovdk-none', 'No messages yet. Say hello.'));
+ 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);
+ }
+
+ // ── The pill ─────────────────────────────────────────────
+ let pill = null, readT = 0, bound = false;
+ 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;
+ }
+ function paintPill() {
+ if (!pill) return;
+ const c = counts(), st = state(), w = c.waiting || 0, r = c.running || 0;
+ const corner = place().corner;
+ // Popped out: the page keeps the pill, and it says where the Dock went.
+ if (popped) {
+ pill.hidden = false;
+ pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';
+ pill.dataset.tone = 'idle';
+ pill.querySelector('.ovdk-t').textContent = 'In a window';
+ pill.setAttribute('aria-label', 'The Dock is in a window of its own \u2014 click to bring it back');
+ return;
+ }
+ const show = !isOpen() && !!(w || r || st.pinned);
+ pill.hidden = !show;
+ pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';
+ pill.dataset.tone = w ? 'waiting' : r ? 'running' : 'idle';
+ pill.querySelector('.ov-mark').setAttribute('data-state', r && !w ? 'busy' : '');
+ pill.querySelector('.ovdk-t').textContent = w ? 'Needs you' : r ? (r > 1 ? `${r} working` : 'Working') : '';
+ pill.setAttribute('aria-label', [w ? `${w} need${w > 1 ? '' : 's'} you` : '', r ? `${r} working` : ''].filter(Boolean).join(', ') || 'Open the Dock');
+ // Desktop toasts (bottom right, notification-ui.js) stack above a pill in their corner; an open Dock sets its own.
+ if (!isOpen()) clear(show && pill.dataset.corner === 'bottom-right' && !phone() ? 58 : 0);
+ }
+ function read() {
+ clearTimeout(readT);
+ if (!pill || document.hidden) return;
+ const t = typeof o.token === 'function' ? o.token() : o.token;
+ if (!t) return;
+ fetch(`${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/me/dock`, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' })
+ .then((r) => { if (r.status !== 404) return r.ok ? r.json() : null; store({ off: Date.now() + 864e5 }); if (pill) pill.hidden = true; return null; })
+ .then((next) => {
+ if (!next || !next.state || !pill) return;
+ update(next);
+ if (!isOpen() && next.state.open && !phone()) open();
+ const c = next.counts || {};
+ if (c.running || c.waiting) readT = setTimeout(read, 30000);
+ }).catch(() => { /* offline: the copy stands */ });
+ }
+ /** 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) {
+ clearTimeout(readT);
+ if (pill) { pill.remove(); pill = null; }
+ if (isOpen()) close();
+ doc = null;
+ return;
+ }
+ o = { ...o, ...opts };
+ styles();
+ if (!pill) {
+ const kept = store();
+ if (kept && kept.off > Date.now()) return;
+ if (kept && kept.state && !doc) doc = kept;
+ pill = document.createElement('button');
+ pill.type = 'button'; pill.id = 'ovdk-pill'; pill.className = 'ovdk-pill'; pill.hidden = true;
+ pill.innerHTML = '<span class="ovdk-m"><span class="ov-mark" data-size="26"></span></span><span class="ovdk-t"></span>';
+ pill.addEventListener('click', () => { if (popped) backToPage(); else open(); });
+ document.body.appendChild(pill);
+ paintPill();
+ }
+ if (!bound) { bound = true; document.addEventListener('visibilitychange', () => { if (!document.hidden) read(); }); }
+ read();
+ }
+
+ // ── Pop out (K-D4) ────────────────────────────────────────
+ // On desktop Chromium the panel can move into a window of its own (Document Picture-in-Picture). The same DOM and
+ // the same tabs go with it; the window is dressed with the Dock's styles and the theme's colours, so the Dock looks
+ // there as it does here. The page keeps the pill, which says where the Dock went; clicking it brings the panel back.
+ const PIP_W = 360, PIP_H = 520;
+ const pip = () => (root.documentPictureInPicture && typeof root.documentPictureInPicture.requestWindow === 'function') ? root.documentPictureInPicture : null;
+ // The theme's tokens, so the window is painted in the same colours (the names only: the values are read from the page).
+ const TOKENS = 'accent accent-light accent-strong bg bg-card bg-elevated bg-hover bg-input bg-primary bg-secondary bg-tertiary border border-light danger danger-strong gold live-red on-accent on-accent-strong success text-muted text-primary text-secondary warning'.split(' ');
+
+ function paintPop() {
+ const b = el.querySelector('[data-act="pop"]');
+ 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);
+ }
+
+ /** The window gets the Dock's styles, the theme's colours and the page's own type: the Dock looks the same there. */
+ function dress(win) {
+ const d = win.document;
+ if (!d.documentElement) d.appendChild(d.createElement('html'));
+ if (!d.head) d.documentElement.appendChild(d.createElement('head'));
+ if (!d.body) d.documentElement.appendChild(d.createElement('body'));
+ d.title = 'OpenVibe Dock';
+ for (const id of ['ovdk-styles', 'ovac-styles', 'openvibe-notif-styles', 'ov-mark-css']) {
+ const s = document.getElementById(id);
+ if (!s) continue;
+ const c = d.createElement('style');
+ c.id = id; c.textContent = s.textContent;
+ d.head.appendChild(c);
+ }
+ const cs = getComputedStyle(document.documentElement);
+ for (const n of TOKENS) { const v = cs.getPropertyValue('--' + n); if (v) d.documentElement.style.setProperty('--' + n, v.trim()); }
+ const b = getComputedStyle(document.body);
+ const s = d.body.style;
+ s.fontFamily = b.fontFamily; s.fontSize = b.fontSize; s.fontWeight = b.fontWeight; s.lineHeight = b.lineHeight;
+ s.background = b.backgroundColor;
+ d.documentElement.style.colorScheme = cs.colorScheme || '';
+ d.documentElement.classList.add('ovdk-pip');
+ }
+
+ /** The panel moves into a window of its own. Only from a click: the browser wants a gesture, and a Dock that opened
+ * a window by itself would be a window nobody asked for. */
+ async function popOut() {
+ if (popped || !pip()) return;
+ let win = null;
+ try { win = await pip().requestWindow({ width: PIP_W, height: PIP_H }); } catch { return; }
+ if (!win || !win.document) return;
+ pipWin = win;
+ popped = true;
+ dress(win);
+ win.document.body.appendChild(el);
+ win.addEventListener('pagehide', () => { if (popped && !pipClosing) backToPage(); });
+ paintPop();
+ save({ open: true, popped: true });
+ paintPill();
+ }
+
+ /** The panel comes back to the page: the window was closed, or the pill was pressed. */
+ function backToPage() {
+ if (!popped) return;
+ popped = false;
+ const w = pipWin; pipWin = null;
+ pipClosing = true;
+ try { if (w && !w.closed) w.close(); } catch { /* the window is already gone */ }
+ pipClosing = false;
+ if (el.parentNode !== document.body) document.body.appendChild(el);
+ el.hidden = false;
+ layout();
+ paintPop();
+ save({ popped: false });
+ paintPill();
+ }
+
+ // ── Open and close ───────────────────────────────────────
+ function isOpen() { return !!(el && !el.hidden); }
+ function open(opts = {}) {
+ o = { ...o, ...opts };
+ if (opts.doc) doc = opts.doc;
+ if (!el) build();
+ lastFocus = document.activeElement;
+ const c = counts();
+ const want = opts.tab || (c.waiting || c.running ? 'agent' : state().tab || 'activity');
+ layout();
+ badges();
+ 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');
+ setTab(want, false);
+ readUnread();
+ if (!state().open || state().tab !== tab) save({ open: true, tab }); else notify();
+ setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]').focus({ preventScroll: true }); } catch { /* */ } }, 0);
+ }
+ function close() {
+ if (!isOpen()) return;
+ 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;
+ el.style.transform = '';
+ el.removeAttribute('aria-modal');
+ document.documentElement.classList.remove('ovdk-sheet-open');
+ clear(0);
+ clearTimeout(chats.timer); clearTimeout(chats.unreadT);
+ save(wasPopped ? { open: false, popped: false } : { open: false });
+ if (inside) {
+ const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);
+ try { if (back) back.focus({ preventScroll: true }); } catch { /* */ }
+ }
+ }
+ /** A fresh copy from the Network (navbar.js reads it while something runs): counts and anything changed elsewhere. */
+ function update(next) {
+ if (!next || !next.state) return;
+ doc = Object.keys(pending).length ? { ...next, state: state() } : next;
+ store(doc);
+ paintPill();
+ if (!el) return;
+ badges();
+ paintPin();
+ if (isOpen()) layout();
+ }
+
+ root.OpenVibeDock = { mount, open, close, isOpen, update, _state: () => ({ doc, tab, pending }) };
+})(typeof window !== 'undefined' ? window : globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,275 @@
@@ -0,0 +1,275 @@
+/* openvibe-shared/kit.css — the OpenVibe Kit's look (kit.js builds the markup; docs/kit.md describes every piece).
+ *
+ * Phone first: a page fits the screen it is on. The head and its tabs stick under the navbar, the dock (a conversation's
+ * composer, a form's save) sticks above the navbar's app bar, rows are 44 px or more to the touch, nothing scrolls
+ * sideways but a strip that is meant to (tabs, numbers, chips). From 600 px (a tablet) lists breathe and numbers sit in
+ * a row; from 1024 px (a computer) .ovk-split puts a side panel beside the content.
+ *
+ * Colours come from the site's theme (theme-loader.js: --bg-*, --text-*, --accent, --accent-strong, --border, --success,
+ * --danger…), so the kit wears every site's theme and every theme's light and dark; the fallbacks are the network's
+ * default dark. Every class starts with ovk- and nothing here styles bare elements, so the kit can sit beside a site's
+ * own CSS. */
+
+:root {
+ --ovk-bg: var(--bg-primary, #0b0f17);
+ --ovk-panel: var(--bg-card, var(--bg-secondary, #121826));
+ --ovk-panel-2: var(--bg-hover, var(--bg-tertiary, #1a2233));
+ --ovk-input: var(--bg-input, var(--bg-tertiary, #0f1522));
+ --ovk-line: var(--border, rgba(255, 255, 255, .1));
+ --ovk-text: var(--text-primary, #e8ecf3);
+ --ovk-muted: var(--text-muted, var(--text-secondary, #93a0b4));
+ --ovk-accent: var(--accent, #3b82f6);
+ --ovk-fill: var(--accent-strong, var(--accent, #3b82f6));
+ --ovk-on-fill: var(--on-accent-strong, var(--on-accent, #fff));
+ --ovk-ok: var(--success, #22c55e);
+ --ovk-warn: var(--warning, #f59e0b);
+ --ovk-bad: var(--danger, var(--error, #ef4444));
+ --ovk-live: var(--live-red, #ef4444);
+ --ovk-radius: 14px;
+ --ovk-radius-sm: 10px;
+ --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-font: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
+}
+
+/* Heading and paragraph rules double their class (.ovk-section.ovk-section > h2): a site's own ".site h2" has the same
+ * specificity as ".ovk-section > h2" and loads later, so it used to restyle the kit's headings (Report, 2026-10-29). */
+
+/* ── Basics ── */
+.ovk-i { width: 1.15em; height: 1.15em; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; vertical-align: -.2em; }
+.ovk-sr { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
+.ovk-muted { color: var(--ovk-muted); }
+.ovk-inline { display: inline; margin: 0; }
+.ovk-page { box-sizing: border-box; width: 100%; max-width: 960px; margin: 0 auto; padding: 0 16px 24px; color: var(--ovk-text); font-family: var(--ovk-font); }
+.ovk-page--wide { max-width: 1280px; }
+
+/* ── Buttons ── */
+.ovk-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; box-sizing: border-box; min-height: var(--ovk-tap); padding: 10px 18px; border-radius: 12px; border: 1px solid transparent; font: inherit; font-weight: 700; font-size: .95rem; line-height: 1.2; text-decoration: none; cursor: pointer; transition: filter .12s, border-color .12s, background .12s; }
+.ovk-btn--primary { background: var(--ovk-fill); color: var(--ovk-on-fill); }
+.ovk-btn--primary:hover { filter: brightness(1.08); }
+.ovk-btn--quiet { background: var(--ovk-panel); color: var(--ovk-text); border-color: var(--ovk-line); }
+.ovk-btn--quiet:hover { border-color: var(--ovk-accent); }
+.ovk-btn--ghost { background: transparent; color: var(--ovk-accent); padding-left: 8px; padding-right: 8px; }
+.ovk-btn--danger { background: transparent; color: var(--ovk-bad); border-color: color-mix(in srgb, var(--ovk-bad) 45%, transparent); }
+.ovk-btn--danger:hover { background: color-mix(in srgb, var(--ovk-bad) 12%, transparent); }
+.ovk-btn--small { min-height: 36px; padding: 6px 13px; border-radius: 999px; font-size: .88rem; }
+.ovk-btn:focus-visible, .ovk-icon-btn:focus-visible, .ovk-go:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: 2px; }
+.ovk-btn[disabled] { opacity: .55; cursor: default; filter: none; }
+.ovk-icon-btn { display: inline-grid; place-items: center; box-sizing: border-box; width: 40px; height: 40px; flex: none; padding: 0; border-radius: 12px; border: 1px solid transparent; background: transparent; color: var(--ovk-muted); font: inherit; cursor: pointer; text-decoration: none; list-style: none; }
+.ovk-icon-btn::-webkit-details-marker { display: none; }
+.ovk-icon-btn:hover { color: var(--ovk-text); background: var(--ovk-panel-2); border-color: var(--ovk-line); }
+.ovk-icon-btn[hidden] { display: none; }
+.ovk-icon-btn.is-done { color: var(--ovk-ok); }
+.ovk-icon-btn--danger:hover { color: var(--ovk-bad); }
+.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: .7rem; font-weight: 800; letter-spacing: .03em; text-transform: uppercase; color: var(--ovk-muted); white-space: nowrap; }
+.ovk-badge--accent { color: var(--ovk-accent); border-color: color-mix(in srgb, var(--ovk-accent) 50%, transparent); }
+.ovk-badge--ok { color: var(--ovk-ok); border-color: color-mix(in srgb, var(--ovk-ok) 50%, transparent); }
+.ovk-badge--warn { color: var(--ovk-warn); border-color: color-mix(in srgb, var(--ovk-warn) 50%, transparent); }
+.ovk-badge--bad { color: var(--ovk-bad); border-color: color-mix(in srgb, var(--ovk-bad) 50%, transparent); }
+.ovk-badge--live { color: #fff; background: var(--ovk-live); border-color: transparent; }
+.ovk-dot { display: inline-grid; place-items: center; flex: none; min-width: 20px; height: 20px; padding: 0 6px; box-sizing: border-box; border-radius: 999px; background: var(--ovk-fill); color: var(--ovk-on-fill); font-size: .72rem; font-weight: 800; }
+
+/* ── The head: back, title and one line, icon actions; its tabs under it; sticky under the navbar ── */
+.ovk-head { position: sticky; top: var(--ovk-top); z-index: 40; margin: 0 -16px 12px; padding: 6px 16px 0; background: color-mix(in srgb, var(--ovk-bg) 88%, transparent); -webkit-backdrop-filter: saturate(1.4) blur(14px); backdrop-filter: saturate(1.4) blur(14px); border-bottom: 1px solid transparent; transition: border-color .2s, box-shadow .2s; }
+.ovk-scrolled .ovk-head { border-bottom-color: var(--ovk-line); box-shadow: 0 10px 24px -18px rgba(0, 0, 0, .7); }
+.ovk-head--static { position: static; background: transparent; -webkit-backdrop-filter: none; backdrop-filter: none; }
+.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; }
+.ovk-head-tools { display: flex; align-items: center; gap: 2px; margin-right: -8px; }
+.ovk-head-tools form { margin: 0; }
+.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; }
+.ovk-tabs a:hover { color: var(--ovk-text); background: var(--ovk-panel-2); }
+.ovk-tabs a[aria-current="page"] { color: var(--ovk-text); background: var(--ovk-panel-2); box-shadow: inset 0 0 0 1px var(--ovk-line); }
+.ovk-tabs .ovk-i { width: 16px; height: 16px; }
+.ovk-tab-n { min-width: 18px; padding: 0 5px; border-radius: 999px; background: var(--ovk-panel); color: var(--ovk-muted); font-size: .72rem; font-weight: 800; text-align: center; }
+
+/* ── Lists: rows that read like a phone's (a mark, the name and time on one line, one line under it) ── */
+.ovk-list-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 18px 0 8px; }
+.ovk-list-head.ovk-list-head h2 { margin: 0; color: var(--ovk-muted); font-family: inherit; font-size: .76rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
+.ovk-list-head a { display: inline-flex; align-items: center; min-height: 32px; color: var(--ovk-accent); font-size: .84rem; font-weight: 700; text-decoration: none; }
+/* 44 px to the touch: tab pills (36 px) and list links grow on touch screens and narrow windows. */
+@media (pointer: coarse), (max-width: 759px) { .ovk-tabs a, .ovk-list-head a { min-height: 44px; } }
+.ovk-list { list-style: none; display: grid; grid-template-columns: minmax(0, 1fr); margin: 0 0 14px; padding: 0; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-panel); overflow: hidden; }
+.ovk-list--flush { margin: 0; border: 0; border-radius: 0; border-top: 1px solid var(--ovk-line); background: transparent; }
+.ovk-row { margin: 0; color: var(--ovk-text); }
+.ovk-row + .ovk-row { border-top: 1px solid var(--ovk-line); }
+.ovk-row > a, .ovk-row--static { display: flex; align-items: center; gap: 12px; min-height: 56px; box-sizing: border-box; padding: 8px 14px; color: inherit; text-decoration: none; min-width: 0; }
+.ovk-row > a:hover { background: var(--ovk-panel-2); }
+.ovk-row > a:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: -2px; }
+.ovk-row--static { padding-right: 8px; }
+.ovk-row-main { flex: 1; min-width: 0; display: grid; gap: 1px; }
+.ovk-row-top { display: flex; align-items: center; gap: 8px; min-width: 0; }
+.ovk-row-top strong { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; }
+.ovk-row-top time, .ovk-row-meta { flex: none; margin-left: auto; color: var(--ovk-muted); font-size: .78rem; font-variant-numeric: tabular-nums; }
+.ovk-row-sub { display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--ovk-muted); font-size: .9rem; }
+.ovk-row-sub > span:first-child { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovk-row--unread .ovk-row-top strong { font-weight: 800; }
+.ovk-row--unread .ovk-row-sub { color: var(--ovk-text); }
+.ovk-row-trail { display: flex; align-items: center; gap: 2px; flex: none; }
+.ovk-row-trail form { display: flex; align-items: center; gap: 2px; margin: 0; }
+.ovk-row-trail select { max-width: 9.5em; padding: 5px 6px; border-radius: 8px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: .86rem; }
+.ovk-mark { --ovk-mark: 40px; flex: none; display: inline-grid; place-items: center; width: var(--ovk-mark); height: var(--ovk-mark); box-sizing: border-box; border-radius: 50%; object-fit: cover; background: hsl(var(--ovk-hue, 215) 45% 38%); color: #fff; font-size: calc(var(--ovk-mark) * .34); font-weight: 800; letter-spacing: .02em; line-height: 1; }
+.ovk-mark--square { border-radius: 28%; }
+.ovk-empty-line { margin: 0 0 14px; padding: 14px; border: 1px dashed var(--ovk-line); border-radius: var(--ovk-radius); color: var(--ovk-muted); }
+
+/* ── 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-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; }
+.ovk-fold { margin: 0 0 10px; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-panel); color: var(--ovk-text); }
+.ovk-fold > summary { display: flex; align-items: center; gap: 10px; min-height: 48px; box-sizing: border-box; padding: 10px 14px; cursor: pointer; list-style: none; font-weight: 650; }
+.ovk-fold > summary::-webkit-details-marker { display: none; }
+.ovk-fold > summary > span { flex: 1; min-width: 0; }
+.ovk-fold > summary > .ovk-i { color: var(--ovk-muted); transition: transform .15s; }
+.ovk-fold[open] > summary > .ovk-i { transform: rotate(90deg); }
+.ovk-fold[open] > summary { border-bottom: 1px solid var(--ovk-line); }
+.ovk-fold > summary:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: -2px; border-radius: var(--ovk-radius); }
+.ovk-fold-body { padding: 12px 14px 14px; }
+.ovk-fold-body > :first-child { margin-top: 0; }
+.ovk-fold-body > :last-child { margin-bottom: 0; }
+.ovk-fold--danger > summary { color: var(--ovk-bad); }
+.ovk-more { position: relative; display: inline-block; }
+.ovk-more > summary { list-style: none; }
+.ovk-pop { position: absolute; right: 0; top: calc(100% + 4px); z-index: 60; min-width: 210px; padding: 6px; box-sizing: border-box; border-radius: 12px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); box-shadow: 0 14px 32px rgba(0, 0, 0, .4); }
+.ovk-pop form { margin: 0; }
+.ovk-pop-item { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--ovk-text); font: inherit; font-weight: 600; text-align: left; text-decoration: none; cursor: pointer; }
+.ovk-pop-item:hover, .ovk-pop-item:focus-visible { background: var(--ovk-panel-2); outline: none; }
+.ovk-pop-item--danger { color: var(--ovk-bad); }
+
+/* ── Settings rows: what it does on the left, its control on the right ── */
+.ovk-set-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 52px; box-sizing: border-box; padding: 8px 14px; border-top: 1px solid var(--ovk-line); color: var(--ovk-text); cursor: pointer; }
+.ovk-section > h2 + .ovk-set-row, .ovk-section > .ovk-note + .ovk-set-row { border-top: 0; }
+div.ovk-set-row { cursor: default; }
+.ovk-set-text { flex: 1; min-width: 0; display: grid; gap: 2px; }
+.ovk-set-text small { color: var(--ovk-muted); font-size: .8rem; }
+.ovk-set-row select { flex: none; max-width: 52%; margin: 0; padding: 7px 8px; border-radius: 10px; 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; }
+.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: 0 1px 3px rgba(0, 0, 0, .35); transition: transform .15s; }
+.ovk-switch:checked { background: var(--ovk-fill); }
+.ovk-switch:checked::after { transform: translateX(18px); }
+.ovk-switch:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: 2px; }
+.ovk-seg { display: inline-flex; flex: none; gap: 2px; padding: 3px; border-radius: 10px; background: var(--ovk-panel-2); }
+.ovk-seg input, .ovk-chip input { position: absolute; opacity: 0; pointer-events: none; }
+.ovk-seg span { display: block; padding: 6px 12px; border-radius: 8px; 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; }
+.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 .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; }
+.ovk-form label.ovk-check { display: flex; align-items: flex-start; gap: 10px; color: var(--ovk-text); font-size: .95rem; }
+.ovk-form input:not([type=checkbox]):not([type=radio]):not([type=range]), .ovk-form select, .ovk-form textarea { width: 100%; box-sizing: border-box; min-height: var(--ovk-tap); padding: 10px 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 1rem; }
+.ovk-form textarea { min-height: 96px; resize: vertical; }
+.ovk-form input:focus-visible, .ovk-form select:focus-visible, .ovk-form textarea:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: 0; border-color: transparent; }
+.ovk-form.ovk-form > p { margin: 0; }
+/* A settings row inside a form stays a row: the label on the left, its control (not stretched) on the right. */
+.ovk-form label.ovk-set-row { display: flex; color: var(--ovk-text); font-size: 1rem; }
+.ovk-form .ovk-set-row select { width: auto; min-height: 0; padding: 7px 8px; font-size: .9rem; }
+.ovk-form .ovk-section { margin: 0; }
+
+/* ── Find (search, add by name) and the dock (a conversation's composer) ── */
+.ovk-find { display: flex; align-items: center; gap: 8px; box-sizing: border-box; margin: 0 0 12px; padding: 4px 4px 4px 14px; border: 1px solid var(--ovk-line); border-radius: 999px; background: var(--ovk-panel); color: var(--ovk-muted); }
+.ovk-find:focus-within { border-color: var(--ovk-accent); }
+.ovk-find--inset { margin: 0 14px 12px; }
+.ovk-find input { flex: 1; min-width: 0; min-height: 38px; margin: 0; padding: 8px 0; border: 0; background: transparent; color: var(--ovk-text); font: inherit; font-size: 1rem; outline: none; -webkit-appearance: none; appearance: none; }
+.ovk-find input::-webkit-search-cancel-button { -webkit-appearance: none; }
+.ovk-find select { flex: none; max-width: 38%; padding: 6px 8px; border: 0; border-radius: 999px; background: var(--ovk-panel-2); color: var(--ovk-text); font: inherit; font-size: .86rem; }
+.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-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); }
+
+/* ── App pages: the conversation fills the screen between the head and the dock, and only it scrolls ── */
+.ovk-app { display: flex; flex-direction: column; min-height: calc(100dvh - var(--ovk-top) - var(--ovk-bottom)); }
+.ovk-app > .ovk-feed { flex: 1 1 auto; }
+.ovk-feed { min-height: 240px; }
+@supports (height: 100dvh) {
+ .ovk-app--fixed { height: calc(100dvh - var(--ovk-top) - var(--ovk-bottom)); min-height: 0; }
+ .ovk-app--fixed > .ovk-feed { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
+ .ovk-app--fixed > .ovk-head { position: static; }
+ .ovk-app--fixed > .ovk-dock { position: static; }
+}
+
+/* ── Numbers, empty states, notices, code, sheets ── */
+.ovk-stats { position: relative; display: flex; gap: 8px; margin: 12px -16px; padding: 0 16px; overflow-x: auto; scrollbar-width: none; }
+.ovk-stats::-webkit-scrollbar { display: none; }
+.ovk-stats > div { display: flex; flex-direction: column-reverse; flex: 0 0 auto; min-width: 104px; box-sizing: border-box; padding: 8px 14px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-panel); }
+.ovk-stats dt { margin: 0; color: var(--ovk-muted); font-size: .76rem; line-height: 1.25; }
+.ovk-stats dt a { color: inherit; text-decoration: none; }
+/* A number that is a link: the whole tile is the target (the label's link stretched over it), never an underline. */
+.ovk-stat--link { position: relative; transition: border-color .15s; }
+.ovk-stat--link dt a::after { content: ""; position: absolute; inset: 0; border-radius: inherit; }
+.ovk-stat--link:hover { border-color: var(--ovk-accent); }
+.ovk-stat--link dt a:focus-visible { outline: none; }
+.ovk-stat--link:focus-within { outline: 2px solid var(--ovk-accent); outline-offset: -2px; }
+.ovk-stats dd { margin: 0; color: var(--ovk-text); font-size: 1.2rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.2; }
+.ovk-empty { display: grid; justify-items: center; gap: 8px; margin: 0 0 14px; padding: 24px 16px; border: 1px dashed var(--ovk-line); border-radius: var(--ovk-radius); color: var(--ovk-muted); text-align: center; }
+.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: 12px; 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); }
+.ovk-notice--error { border-color: color-mix(in srgb, var(--ovk-bad) 50%, transparent); }
+.ovk-notice--error > .ovk-i { color: var(--ovk-bad); }
+.ovk-notice--warn > .ovk-i { color: var(--ovk-warn); }
+.ovk-notice--info > .ovk-i { color: var(--ovk-accent); }
+.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: 10px; 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; }
+.ovk-sheet { width: min(560px, 100vw); max-width: 100vw; max-height: 85dvh; margin: auto; padding: 0; box-sizing: border-box; border: 1px solid var(--ovk-line); border-radius: 18px; background: var(--ovk-panel); color: var(--ovk-text); overflow: hidden; }
+.ovk-sheet::backdrop { background: rgba(0, 0, 0, .55); }
+.ovk-sheet[open] { display: flex; flex-direction: column; }
+.ovk-sheet-head { display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 16px; border-bottom: 1px solid var(--ovk-line); }
+.ovk-sheet-head.ovk-sheet-head h2 { flex: 1; margin: 0; font-size: 1.05rem; }
+.ovk-sheet-body { padding: 14px 16px calc(16px + env(safe-area-inset-bottom, 0px)); overflow-y: auto; }
+
+/* ── Phones (< 600 px): bottom sheets, full-width forms ── */
+
+@media (max-width: 599px) {
+ .ovk-sheet { margin: auto 0 0; width: 100vw; border-radius: 18px 18px 0 0; border-bottom: 0; }
+ .ovk-row > a, .ovk-row--static { padding-left: 12px; }
+ .ovk-head-tools { gap: 0; }
+}
+/* ── Tablets and up (≥ 600 px): numbers in a row, roomier lists ── */
+@media (min-width: 600px) {
+ .ovk-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); margin-left: 0; margin-right: 0; padding: 0; overflow: visible; }
+ .ovk-save .ovk-btn { width: auto; min-width: 200px; }
+ .ovk-dock { margin-left: 0; margin-right: 0; padding-left: 0; padding-right: 0; }
+}
+/* ── Computers (≥ 1024 px): a side panel beside the content ── */
+.ovk-split { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); }
+@media (min-width: 1024px) {
+ .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
+ 44px to a finger and looks the same, and the find bar's field and select are 44px tall. */
+@media (pointer: coarse) {
+ .ovk-btn--small { min-height: 44px; }
+ .ovk-go { position: relative; }
+ .ovk-go::after { content: ''; position: absolute; inset: -3px; }
+ .ovk-find input, .ovk-find select { min-height: 44px; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,1437 @@
@@ -0,0 +1,1437 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — Notification UI Client (shared across every OpenVibe site)
+//
+// One store (openvibe.network) → one bell everywhere. This file renders the bell + its panel, toasts, a full-page
+// inbox, and handles Web Push subscription.
+//
+// Usage: <script src="https://openvibe.network/shared/notification-ui.js"></script>
+// OpenVibeNotifications.init({ token, apiBase, swPath, onAction })
+// OpenVibeNotifications.createBell(mountEl)
+// OpenVibeNotifications.renderInbox(containerEl) // full-page inbox
+// OpenVibeNotifications.renderDock(containerEl) // the Dock's Activity tab (dock.js): the same list, no frame
+//
+// Reads: GET /api/notifications?limit&offset&unread_only&tab&q&since
+// GET /api/notifications/unread-count, /unread-by-tab (the counts on the bell's tabs)
+// Realtime (init({ realtime: true }), off by default): notification-live.js, loaded from beside this file, hears the
+// person's network.notification.created over OpenVibe.Events with a ticket from POST /api/v1/realtime/ticket;
+// each event or gap re-reads the count. Polling stays: every 15 s without a stream, every 2 min with one.
+// Writes: POST /:id/read, /read-batch, /read-all, /:id/dismiss, /api/push/{subscribe,unsubscribe}
+//
+// Notifications 2.0 (2026-10-28, the owner's screenshot of three alert toasts drawn over the open menu):
+// • a toast is an opaque, themed card: a line glyph for its category (never an emoji) or the sender's avatar with
+// the glyph as a badge, a one-line title, at most two lines of text, chips, up to two actions, a timeout bar;
+// • toasts never draw over an open menu, panel or dialog: they wait (timers paused) and show when it closes;
+// • phones show one at a time under the bar (with "+N more" and swipe to dismiss), desktops up to three at the
+// bottom right;
+// • the same notice arriving again within 15 minutes joins the card that is showing (a count, site chips);
+// • operator alerts (OPERATOR_ALERT, or rich_content.kind "ops_alert") read as System cards: "Browser check
+// failed · events · release 80b7955", a status link, and the fix command behind a copy button in the bell —
+// never raw shell text in the body;
+// • the ✦ beside the bell (4.31.0, desktop) opens the Action Center (action-center.js, loaded from beside this file
+// on the first click): every agent task in one place; on phones the bell's Agents tab links to it;
+// • the bell (4.30.0) is a 420 px popover on desktop and a full-height sheet on phones: tabs (All · Mentions · Live ·
+// Deals & alerts · System · Agents) counted by Network's unread-by-tab, an Unread filter, day headers, thumbnails,
+// unread dots and grouped repeats; on touch screens a row swipes right to read and left to clear.
+// ═══════════════════════════════════════════════════════════════
+
+(function (root) {
+ 'use strict';
+
+ const POLL_INTERVAL = 15_000;
+ const REALTIME_POLL = 120_000; // with an open realtime stream the poll is only a safety net (reads made on other sites)
+ const SELF_SRC = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
+ const PAGE_SIZE = 30;
+ const TOAST_DURATION = { low: 0, normal: 5200, high: 8000, critical: 0 }; // 0 = sticky
+ const MAX_TOASTS = 3; // desktop; phones show one and count the rest
+ const GROUP_WINDOW = 15 * 60 * 1000;
+ const SOUNDS = { normal: 'notification.mp3', high: 'notification-high.mp3', critical: 'notification-alarm.mp3' };
+ const CATEGORY_LABELS = {
+ 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' };
+
+ // Line glyphs (24×24, stroked like the open-loop mark): one per kind of notice.
+ const GLYPH = {
+ live: '<circle cx="12" cy="12" r="2.6"/><path d="M6.4 6.4a8 8 0 0 0 0 11.2M17.6 6.4a8 8 0 0 1 0 11.2M9.2 9.2a4 4 0 0 0 0 5.6M14.8 9.2a4 4 0 0 1 0 5.6"/>',
+ people: '<circle cx="9" cy="8.5" r="3.2"/><path d="M3.5 19.5a5.5 5.5 0 0 1 11 0"/><circle cx="17.2" cy="9.4" r="2.4"/><path d="M15.6 14.4a4.5 4.5 0 0 1 5.4 4.6"/>',
+ chat: '<path d="M4.5 5.5h15v10.5H10l-5.5 4z"/>',
+ game: '<path d="M6.5 8.5h11a3.5 3.5 0 0 1 3.5 3.5v2.6a2.9 2.9 0 0 1-5.1 1.9L14.2 14.6H9.8L8.1 16.5A2.9 2.9 0 0 1 3 14.6V12a3.5 3.5 0 0 1 3.5-3.5z"/><path d="M8.3 10.6v3M6.8 12.1h3"/><path d="M15.6 11.4h.01M17.2 12.9h.01"/>',
+ coin: '<circle cx="12" cy="12" r="8.2"/><path d="M14.6 9.6c-.6-.9-1.5-1.3-2.6-1.3-1.5 0-2.6.8-2.6 1.9s1.1 1.6 2.6 1.9 2.6.8 2.6 2-1.1 1.9-2.6 1.9c-1.1 0-2.1-.5-2.6-1.3M12 6.6v1.7M12 15.7v1.7"/>',
+ tag: '<path d="M4 4.5h7.2l8.8 8.8-6.7 6.7-8.8-8.8z"/><circle cx="8.4" cy="8.9" r="1.3"/>',
+ trophy: '<path d="M8 4h8v5a4 4 0 0 1-8 0zM8 6H5.2a3 3 0 0 0 3 3.6M16 6h2.8a3 3 0 0 1-3 3.6M12 13v3.6M8.6 20h6.8"/>',
+ shield: '<path d="M12 3.2l7 3v5.2c0 4.3-2.9 7.8-7 9.6-4.1-1.8-7-5.3-7-9.6V6.2z"/>',
+ gear: '<circle cx="12" cy="12" r="3"/><path d="M12 3.5v2.2M12 18.3v2.2M3.5 12h2.2M18.3 12h2.2M6 6l1.6 1.6M16.4 16.4L18 18M6 18l1.6-1.6M16.4 7.6L18 6"/>',
+ grid: '<rect x="4" y="4" width="6.6" height="6.6" rx="1.6"/><rect x="13.4" y="4" width="6.6" height="6.6" rx="1.6"/><rect x="4" y="13.4" width="6.6" height="6.6" rx="1.6"/><rect x="13.4" y="13.4" width="6.6" height="6.6" rx="1.6"/>',
+ pulse: '<path d="M3 12.5h4.2l2.3-5.5 4.2 10.5 2.3-5h4.9"/>',
+ check: '<path d="M5 12.6l4.6 4.6L19 7.6"/>',
+ bell: '<path d="M6.2 16.2V11a5.8 5.8 0 0 1 11.6 0v5.2l1.6 2H4.6z"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0"/>',
+ 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"/>',
+ };
+ 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)',
+ gold: 'var(--gold, #e3b341)', accent: 'var(--accent, #3b82f6)', muted: 'var(--text-secondary, #a0a0ab)',
+ };
+ const CATEGORY_TONE = { stream: 'live', social: 'accent', chat: 'accent', game: 'accent', economy: 'gold', achievement: 'gold', moderation: 'warn', system: 'muted', service: 'accent', admin: 'accent' };
+ // The bell's tabs. Network defines what each holds (server/notifications/tabs.js, ?tab= and /unread-by-tab).
+ const TABS = [['all', 'All'], ['mentions', 'Mentions'], ['live', 'Live'], ['alerts', 'Deals & alerts'], ['system', 'System'], ['agents', 'Agents']];
+ const TAB_EMPTY = {
+ all: ['No notifications yet', 'When something happens across OpenVibe, it lands here.'],
+ mentions: ['No mentions yet', 'When someone mentions you, replies to you or messages you, it shows here.'],
+ live: ['Nobody has gone live yet', 'When someone you follow goes live, it shows here.'],
+ alerts: ['No alerts yet', 'Deal, coupon, price and trade watches you set tell you here.'],
+ system: ['Nothing from the system', 'Sign-ins, security notices and announcements show here.'],
+ agents: ['No agent work yet', 'When an agent needs your approval or finishes a task, it shows here.'],
+ };
+
+ let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };
+ let _pollTimer = null;
+ let _lastSeenAt = null; // newest created_at we have toasted / seen
+ let _unreadCount = 0;
+ let _toastContainer = null;
+ let _panelEl = null;
+ let _bellEl = null;
+ let _audioCache = {};
+ let _preferences = { enabled: true, sound: true, toasts: true, muted_categories: [] };
+ let _channel = null; // BroadcastChannel for same-origin tab sync
+ let _live = null; // OVNotificationLive feed (realtime on)
+ let _lastPoll = 0;
+ let _soon = null; let _soonAll = false;
+ const _views = new Map(); // container → view state (panel + inboxes)
+ const _queue = []; // toast cards waiting for a free screen (a menu open, or a phone already showing one)
+ let _menuOpen = false; // the OpenVibe menu (search.js) says so through its 'openvibe-menu' event
+
+ // ── Styles ──────────────────────────────────────────────
+ function injectStyles() {
+ if (document.getElementById('openvibe-notif-styles')) return;
+ const style = document.createElement('style');
+ style.id = 'openvibe-notif-styles';
+ style.textContent = `
+ @keyframes ovn-in-up { from { transform: translateY(14px) scale(.98); opacity: 0; } to { transform: none; opacity: 1; } }
+ @keyframes ovn-in-down { from { transform: translateY(-14px) scale(.98); opacity: 0; } to { transform: none; opacity: 1; } }
+ @keyframes ovn-out { to { transform: translateX(24px); opacity: 0; } }
+ @keyframes ovn-badge-pop { 0%,100% { transform: scale(1); } 50% { transform: scale(1.22); } }
+ @keyframes ovn-bell-ring { 0%{ transform: rotate(0); } 12%{ transform: rotate(13deg); } 24%{ transform: rotate(-12deg); } 36%{ transform: rotate(8deg); } 48%{ transform: rotate(-4deg); } 60%{ transform: rotate(0); } }
+ @keyframes ovn-panel-in { from { transform: translateY(-8px); opacity: 0; } to { transform: none; opacity: 1; } }
+ @keyframes ovn-sheet-up { from { transform: translateY(48px); opacity: .5; } to { transform: none; opacity: 1; } }
+ @keyframes ovn-spin { to { transform: rotate(360deg); } }
+
+ /* Toasts. Opaque cards on the theme's raised surface; held (hidden, timers paused) while a menu is open. */
+ .openvibe-toast-container { position: fixed; z-index: 2147482600; display: flex; flex-direction: column; gap: 10px; pointer-events: none;
+ right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ov-dock-clear, 0px)); width: min(380px, calc(100vw - 32px));
+ font-family: inherit; transition: opacity .18s ease; }
+ .openvibe-toast-container.ovn-held { opacity: 0; visibility: hidden; }
+ .openvibe-toast-container.ovn-phone { top: var(--ovn-top, 64px); bottom: auto; left: max(8px, env(safe-area-inset-left, 0px)); right: max(8px, env(safe-area-inset-right, 0px)); width: auto; }
+ .openvibe-toast { pointer-events: auto; position: relative; overflow: hidden; display: grid; grid-template-columns: 38px 1fr; gap: 12px; align-items: start;
+ padding: 12px 34px 12px 12px; border-radius: 16px; cursor: pointer; touch-action: pan-y;
+ background: var(--bg-elevated, var(--bg-card, #1f2129)); color: var(--text-primary, #ececf1);
+ border: 1px solid var(--border, rgba(255,255,255,.1)); box-shadow: 0 18px 40px rgba(0,0,0,.34), 0 2px 6px rgba(0,0,0,.2);
+ animation: ovn-in-up .26s cubic-bezier(.2,.8,.2,1); }
+ .ovn-phone .openvibe-toast { animation-name: ovn-in-down; }
+ .openvibe-toast::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--ovn-tone, var(--accent, #3b82f6)); }
+ .openvibe-toast:hover, .openvibe-toast:focus-visible { border-color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 55%, var(--border, #333)); outline: none; }
+ .openvibe-toast.removing { animation: ovn-out .24s ease forwards; }
+ .openvibe-toast-body { min-width: 0; }
+ .openvibe-toast-title { display: flex; align-items: baseline; gap: 6px; min-width: 0; font-size: 14px; font-weight: 650; line-height: 1.3; }
+ .openvibe-toast-title .t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .openvibe-toast-title .n { flex: none; font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 50%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 16%, transparent); }
+ .openvibe-toast-sub { margin-top: 1px; font-size: 11.5px; color: var(--text-muted, #8b8b96); display: flex; gap: 6px; align-items: center; }
+ .openvibe-toast-msg { margin-top: 4px; font-size: 13px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
+ .openvibe-toast-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
+ .openvibe-toast-actions button, .openvibe-toast-actions a { font: inherit; font-size: 12.5px; font-weight: 650; padding: 7px 13px; border-radius: 999px; cursor: pointer; text-decoration: none; line-height: 1;
+ border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); }
+ .openvibe-toast-actions .primary { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, var(--on-accent, #fff)); }
+ .openvibe-toast-close { position: absolute; top: 8px; right: 8px; width: 26px; height: 26px; border-radius: 50%; border: none; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; font-size: 16px; line-height: 1; }
+ .openvibe-toast-close:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-primary, #ececf1); }
+ .openvibe-toast-progress { position: absolute; left: 0; bottom: 0; height: 2px; width: 100%; transform-origin: left; background: var(--ovn-tone, var(--accent, #3b82f6)); opacity: .7; }
+ .openvibe-toast-more { margin-left: auto; flex: none; font: inherit; font-size: 11px; font-weight: 700; padding: 2px 9px; border-radius: 999px; cursor: pointer;
+ border: 1px solid color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 40%, var(--border, #333)); background: transparent; color: var(--text-secondary, #b6b6c0); }
+ .openvibe-toast.priority-critical { border-color: color-mix(in srgb, var(--ovn-tone) 60%, var(--border, #333)); }
+
+ /* The glyph (or avatar with a glyph badge), shared by toasts and the list. */
+ .ovn-glyph { position: relative; width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; flex: none;
+ color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 68%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 15%, transparent); }
+ .ovn-glyph svg { width: 21px; height: 21px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+ .ovn-glyph.ovn-avatar { border-radius: 50%; background: var(--bg-tertiary, #2a2b33); }
+ .ovn-glyph.ovn-avatar img { width: 38px; height: 38px; border-radius: 50%; object-fit: cover; display: block; }
+ .ovn-glyph .ovn-badge { position: absolute; right: -3px; bottom: -3px; width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center;
+ background: var(--ovn-tone, var(--accent, #3b82f6)); color: var(--bg-elevated, var(--bg-card, #1f2129)); box-shadow: 0 0 0 2px var(--bg-elevated, var(--bg-card, #1f2129)); }
+ .ovn-glyph .ovn-badge svg { width: 11px; height: 11px; stroke-width: 2.4; }
+ .ovn-chips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
+ .ovn-chip { font-size: 11px; font-weight: 600; line-height: 1; padding: 4px 8px; border-radius: 999px; color: var(--text-secondary, #b6b6c0); background: var(--bg-tertiary, rgba(255,255,255,.06)); border: 1px solid var(--border, rgba(255,255,255,.08)); }
+ .ovn-chip.sev { color: color-mix(in srgb, var(--ovn-tone) 50%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovn-tone) 14%, transparent); border-color: transparent; text-transform: uppercase; letter-spacing: .03em; font-size: 10px; }
+
+ .openvibe-bell { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; border-radius: 10px; }
+ .openvibe-bell svg { width: 22px; height: 22px; fill: none; stroke: var(--text-secondary, #b0b0b8); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; transition: stroke .2s; }
+ .openvibe-bell:hover svg, .openvibe-bell.open svg { stroke: var(--accent-light, var(--accent, #60a5fa)); }
+ .openvibe-bell:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+ .openvibe-bell .badge { position: absolute; top: 1px; right: 0; min-width: 17px; height: 17px; padding: 0 5px; box-sizing: border-box; background: var(--live-red, #e5484d); color: #fff; border-radius: 999px;
+ font-size: 10px; font-weight: 750; display: flex; align-items: center; justify-content: center; line-height: 1; pointer-events: none; box-shadow: 0 0 0 2px var(--bg-primary, #111);
+ font-variant-numeric: tabular-nums; animation: ovn-badge-pop .36s ease; }
+ .openvibe-bell .badge.hidden { display: none; }
+ .openvibe-bell.ringing svg { animation: ovn-bell-ring .7s ease; transform-origin: 50% 15%; }
+ /* The ✦ beside the bell: the Action Center (desktop; phones reach it from the bell's Agents tab). */
+ .openvibe-actions-btn { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; border-radius: 10px; }
+ .openvibe-actions-btn svg { width: 21px; height: 21px; fill: none; stroke: var(--text-secondary, #b0b0b8); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; transition: stroke .2s; }
+ .openvibe-actions-btn:hover svg, .openvibe-actions-btn.open svg { stroke: var(--accent-light, var(--accent, #60a5fa)); }
+ .openvibe-actions-btn:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+ @media (max-width: 640px) { .openvibe-actions-btn { display: none; } }
+ .ovn-ac-link { display: flex; align-items: center; gap: 10px; width: calc(100% - 12px); margin: 6px; padding: 10px 14px; border-radius: 14px; border: 1px dashed color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, #333));
+ background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); color: var(--text-primary, #ececf1); font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; text-align: left; }
+ .ovn-ac-link svg { width: 18px; height: 18px; flex: none; fill: none; stroke: var(--accent-light, var(--accent, #60a5fa)); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+
+ /* The panel (desktop popover, phone sheet) and the full inbox share one list view. */
+ .openvibe-notif-panel { position: fixed; z-index: 99999; display: none; flex-direction: column; overflow: hidden;
+ top: var(--ovn-top, 62px); right: max(8px, env(safe-area-inset-right, 0px)); width: min(420px, calc(100vw - 16px));
+ max-height: calc(100vh - var(--ovn-top, 62px) - 12px); max-height: calc(100dvh - var(--ovn-top, 62px) - 12px - env(safe-area-inset-bottom, 0px));
+ background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));
+ border-radius: 18px; box-shadow: 0 24px 60px rgba(0,0,0,.45); animation: ovn-panel-in .2s cubic-bezier(.2,.8,.2,1); font-family: inherit; }
+ .openvibe-notif-panel.open { display: flex; }
+ .openvibe-notif-panel, .openvibe-notif-inbox, .openvibe-notif-dock { --ovn-bg: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }
+ .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 .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; }
+ .ovn-foot-read:hover { color: var(--text-primary, #ececf1); }
+ .ovn-foot-read svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+ .openvibe-notif-inbox { display: flex; flex-direction: column; overflow: hidden; min-height: 420px; max-height: 78vh; border-radius: 18px;
+ background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1)); font-family: inherit; }
+
+ .openvibe-nv-head { padding: 14px 14px 10px 16px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+ .openvibe-nv-head h3 { font-size: 17px; font-weight: 750; letter-spacing: -.01em; margin: 0; display: flex; align-items: center; gap: 8px; }
+ .openvibe-nv-head h3 .count { font-size: 11px; font-weight: 750; background: var(--live-red, #e5484d); color: #fff; border-radius: 999px; padding: 2px 7px; font-variant-numeric: tabular-nums; }
+ .openvibe-nv-head h3 .count.zero { display: none; }
+ .openvibe-nv-actions { display: flex; gap: 2px; align-items: center; }
+ .openvibe-nv-actions button, .openvibe-nv-actions a { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; border: none; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; text-decoration: none; }
+ .openvibe-nv-actions button > span, .openvibe-nv-actions a > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
+ .openvibe-nv-actions button:hover, .openvibe-nv-actions a:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-primary, #ececf1); }
+ .openvibe-nv-actions button.on { color: var(--accent-light, var(--accent, #60a5fa)); }
+ .openvibe-nv-actions button:disabled { opacity: .4; cursor: default; }
+ .openvibe-nv-actions svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+
+ .openvibe-nv-tools { padding: 0 12px; display: flex; flex-direction: column; gap: 6px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }
+ .openvibe-nv-search { position: relative; }
+ .openvibe-nv-search input { width: 100%; box-sizing: border-box; padding: 9px 30px 9px 34px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); background: var(--bg-input, var(--bg-tertiary, rgba(255,255,255,.04))); color: var(--text-primary, #ececf1); font: inherit; font-size: 13px; outline: none; }
+ .openvibe-nv-search input:focus { border-color: var(--accent, #3b82f6); }
+ .openvibe-nv-search > svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; fill: none; stroke: var(--text-muted, #8b8b96); stroke-width: 1.8; stroke-linecap: round; pointer-events: none; }
+ .openvibe-nv-search .clear { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted, #8b8b96); cursor: pointer; font-size: 16px; display: none; }
+ .openvibe-nv-search.has-q .clear { display: block; }
+ .openvibe-nv-find { display: flex; gap: 8px; align-items: center; }
+ .openvibe-nv-find .openvibe-nv-search { flex: 1; min-width: 0; }
+ .openvibe-nv-unread { flex: none; display: inline-flex; align-items: center; gap: 6px; height: 37px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; }
+ .openvibe-nv-unread:hover { color: var(--text-primary, #ececf1); }
+ .openvibe-nv-unread[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, var(--on-accent, #fff)); }
+ .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 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); }
+ .openvibe-nv-tabs button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -3px; border-radius: 10px; }
+ .openvibe-nv-tabs .n { min-width: 18px; padding: 2px 5px; box-sizing: border-box; border-radius: 999px; font-size: 10.5px; font-weight: 750; line-height: 1.2; text-align: center; font-variant-numeric: tabular-nums;
+ color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--accent, #3b82f6) 24%, transparent); }
+ .openvibe-nv-tabs .n:empty { display: none; }
+ .openvibe-nv-tabs.is-more { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 36px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 36px), transparent); }
+
+ .openvibe-nv-list { flex: 1; overflow-y: auto; padding: 2px 0 6px; overscroll-behavior: contain; }
+ .openvibe-nv-day { position: sticky; top: 0; z-index: 1; padding: 10px 16px 6px; font-size: 11.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted, #8b8b96); background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }
+ /* A row: the opaque .ovn-row over .ovn-under, which a swipe uncovers (Read on the left, Clear on the right). */
+ .openvibe-notif-item { position: relative; margin: 0 6px; border-radius: 14px; overflow: hidden; cursor: pointer; }
+ .openvibe-notif-item .ovn-row { position: relative; z-index: 1; display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; padding: 10px 14px 10px 16px; border-radius: 14px;
+ background: var(--ovn-bg, var(--bg-elevated, #1d1f27)); transition: background .15s, transform .2s ease; touch-action: pan-y; outline: none; }
+ .openvibe-notif-item .ovn-row.has-thumb { grid-template-columns: 38px minmax(0, 1fr) 56px; }
+ .openvibe-notif-item .ovn-row:focus-visible { box-shadow: inset 0 0 0 2px var(--accent, #3b82f6); }
+ .openvibe-notif-item:hover .ovn-row { background: color-mix(in srgb, var(--text-primary, #ececf1) 5%, var(--ovn-bg, #1d1f27)); }
+ .openvibe-notif-item.unread .ovn-row { background: color-mix(in srgb, var(--accent, #3b82f6) 8%, var(--ovn-bg, #1d1f27)); }
+ .openvibe-notif-item.unread:hover .ovn-row { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, var(--ovn-bg, #1d1f27)); }
+ .openvibe-notif-item.swiping .ovn-row { transition: none; }
+ .openvibe-notif-item .thumb { width: 56px; height: 56px; border-radius: 10px; object-fit: cover; display: block; background: var(--bg-tertiary, #2a2b33); }
+ .openvibe-notif-item .ovn-under { position: absolute; inset: 0; display: none; align-items: center; justify-content: space-between; padding: 0 22px; font-size: 13px; font-weight: 750; }
+ .openvibe-notif-item[data-swipe] .ovn-under { display: flex; }
+ .openvibe-notif-item[data-swipe="read"] .ovn-under { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); }
+ .openvibe-notif-item[data-swipe="clear"] .ovn-under { background: var(--danger-strong, #c42b30); color: #fff; justify-content: flex-end; }
+ .openvibe-notif-item[data-swipe="read"] .ovn-under .clear, .openvibe-notif-item[data-swipe="clear"] .ovn-under .read { display: none; }
+ .openvibe-notif-item .ovn-under span { display: inline-flex; align-items: center; gap: 6px; }
+ .openvibe-notif-item .ovn-under svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
+ .openvibe-notif-item .content { min-width: 0; }
+ .openvibe-notif-item .title { font-size: 13.5px; font-weight: 550; line-height: 1.32; display: flex; align-items: baseline; gap: 6px; }
+ .openvibe-notif-item .title .t { min-width: 0; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+ .openvibe-notif-item.unread .title { font-weight: 700; }
+ .openvibe-notif-item .title .count { flex: none; font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 50%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 15%, transparent); }
+ .openvibe-notif-item .msg { margin-top: 2px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); line-height: 1.42; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
+ .openvibe-notif-item .meta { font-size: 11.5px; color: var(--text-muted, #8b8b96); margin-top: 5px; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
+ .openvibe-notif-item .meta .dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .6; }
+ .openvibe-notif-item .unread-dot { position: absolute; left: 5px; top: 26px; width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); }
+ .openvibe-notif-item .row-actions { margin-left: auto; display: flex; gap: 2px; opacity: 0; transition: opacity .15s; }
+ .openvibe-notif-item:hover .row-actions, .openvibe-notif-item:focus-within .row-actions { opacity: 1; }
+ /* The row's buttons sit on its time line, never over its text; touch screens always show them. */
+ @media (hover: none), (pointer: coarse) { .openvibe-notif-item .row-actions { opacity: 1; } }
+ .openvibe-notif-item .row-actions button { width: 28px; height: 28px; border-radius: 8px; border: none; background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; }
+ .openvibe-notif-item .row-actions button:hover { color: var(--text-primary, #ececf1); background: var(--bg-hover, rgba(255,255,255,.08)); }
+ .openvibe-notif-item .row-actions svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+ .openvibe-notif-item .copy { font: inherit; font-size: 11.5px; font-weight: 650; margin-top: 7px; padding: 5px 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; }
+ .openvibe-notif-item .copy:hover { color: var(--text-primary, #ececf1); }
+ .openvibe-nv-empty { text-align: center; padding: 48px 24px 56px; color: var(--text-muted, #8b8b96); font-size: 13.5px; }
+ .openvibe-nv-empty svg { width: 64px; height: 64px; display: block; margin: 0 auto 12px; fill: none; stroke: var(--accent, #3b82f6); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; opacity: .75; }
+ .openvibe-nv-empty b { display: block; font-size: 15px; color: var(--text-primary, #ececf1); margin-bottom: 4px; }
+ .openvibe-nv-more { display: flex; justify-content: center; padding: 12px; }
+ .openvibe-nv-more button { border: 1px solid var(--border, rgba(255,255,255,.1)); background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12.5px; padding: 7px 16px; border-radius: 999px; cursor: pointer; }
+ .openvibe-nv-more .spinner { width: 18px; height: 18px; border: 2px solid var(--border, rgba(255,255,255,.12)); border-top-color: var(--accent, #3b82f6); border-radius: 50%; animation: ovn-spin .8s linear infinite; }
+ .openvibe-nv-foot { padding: 10px 16px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #8b8b96); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
+ .openvibe-nv-foot a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; font-weight: 650; }
+ .openvibe-nv-note { font-size: 12px; color: var(--text-secondary, #b6b6c0); padding: 8px 16px; background: color-mix(in srgb, var(--warning, #f5a524) 12%, transparent); }
+
+ /* Phones: the panel is a full-height sheet over a scrim; its handle drags down to close, as do × and a tap outside. */
+ .openvibe-nv-actions [data-act="close"] { display: none; }
+ @media (max-width: 640px) {
+ .openvibe-notif-panel { top: calc(env(safe-area-inset-top, 0px) + 10px); left: 0; right: 0; bottom: 0; width: auto; max-height: none; border-radius: 22px 22px 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); animation-name: ovn-sheet-up; animation-duration: .28s; }
+ .openvibe-notif-panel .openvibe-nv-head { position: relative; padding-top: 20px; touch-action: none; }
+ .openvibe-notif-panel .openvibe-nv-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))); }
+ .openvibe-notif-panel .openvibe-nv-actions [data-act="close"] { display: grid; }
+ .openvibe-notif-panel .openvibe-nv-actions [data-act="inbox"] { display: none; }
+ .openvibe-nv-actions button, .openvibe-nv-actions a { width: 40px; height: 40px; }
+ .openvibe-notif-item .row-actions { opacity: 1; }
+ }
+ 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; }
+ }
+ `;
+ document.head.appendChild(style);
+ }
+
+ // ── Helpers ─────────────────────────────────────────────
+ function esc(v) {
+ if (v == null) return '';
+ return String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
+ }
+ function richOf(n) {
+ const rc = n && (n.rich_content ?? n.richContent);
+ if (!rc) return {};
+ if (typeof rc === 'object') return rc;
+ try { return JSON.parse(rc) || {}; } catch { return {}; }
+ }
+ function urlOf(n) { return (n && n.url) || richOf(n).url || null; }
+ function isRead(n) { return !!(n && (n.is_read === 1 || n.is_read === true || n.read === true)); }
+ function parseDate(v) {
+ if (!v) return null;
+ // SQLite "YYYY-MM-DD HH:MM:SS" is UTC without a zone marker.
+ const s = String(v);
+ const d = new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s) ? s.replace(' ', 'T') + 'Z' : s);
+ return isNaN(d.getTime()) ? null : d;
+ }
+ function timeAgo(v) {
+ const d = parseDate(v); if (!d) return '';
+ const diff = (Date.now() - d.getTime()) / 1000;
+ if (diff < 45) return 'just now';
+ if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))} min ago`;
+ if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
+ if (diff < 604800) return `${Math.floor(diff / 86400)} d ago`;
+ return d.toLocaleDateString();
+ }
+ /** "Today", "Yesterday", a weekday this week, else the date: the list's day headers. */
+ function dayLabel(v) {
+ const d = parseDate(v); if (!d) return 'Earlier';
+ const start = new Date(); start.setHours(0, 0, 0, 0);
+ const day = new Date(d.getTime()); day.setHours(0, 0, 0, 0);
+ const days = Math.round((start - day) / 86400000);
+ if (days <= 0) return 'Today';
+ if (days === 1) return 'Yesterday';
+ if (days < 7) { try { return d.toLocaleDateString(undefined, { weekday: 'long' }); } catch { return 'This week'; } }
+ try { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } catch { return 'Earlier'; }
+ }
+ function inboxUrl() {
+ if (_config.inboxUrl) return _config.inboxUrl;
+ try { return new URL('/notifications', _config.apiBase).toString(); } catch { return 'https://openvibe.network/notifications'; }
+ }
+ function navigateTo(url) {
+ if (!url) return;
+ try {
+ const u = new URL(url, window.location.href);
+ window.location.href = u.toString();
+ } catch { window.location.href = url; }
+ }
+ const svg = (paths) => `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
+ function isPhone() {
+ try { return !!(root.matchMedia && root.matchMedia('(max-width: 640px)').matches); } catch { return false; }
+ }
+
+ // ── What a notification says (one presentation for toasts and the list) ─────
+ /** OpenVibeBrowserCheckFailed → "Browser check failed". */
+ function humanize(name) {
+ const s = String(name || '').replace(/^OpenVibe(?=[A-Z])/, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').trim();
+ return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : 'Alert';
+ }
+ function isOpsAlert(n, rich) { return !!n && (n.type === 'OPERATOR_ALERT' || rich.kind === 'ops_alert'); }
+ /**
+ * An operator alert, from its structured fields (rich_content.kind "ops_alert": name, state, severity, sites,
+ * release, summary, command) or, for older notices, read out of its title and message.
+ */
+ function presentOps(n, rich) {
+ const title = String(n.title || '');
+ const m = /^(Alert|Still firing|Resolved):\s*(.+)$/i.exec(title);
+ const state = rich.state || (m ? (m[1].toLowerCase() === 'resolved' ? 'resolved' : (m[1].toLowerCase() === 'still firing' ? 'reminded' : 'firing')) : 'firing');
+ const name = rich.name || (m ? m[2] : title);
+ const raw = String(n.message || '');
+ const command = rich.command || ((/run:\s*(sudo [^;]+?)(?:\.\s|;|\.$|$)/.exec(raw) || [])[1]) || null;
+ const sites = Array.isArray(rich.sites) && rich.sites.length ? rich.sites.slice(0, 6)
+ : [...new Set(((raw.match(/(?:^|\.\s+)([a-z0-9][a-z0-9.-]{1,30}):\s/g) || []).map(s => s.replace(/^\.\s+/, '').replace(/:\s$/, ''))))].slice(0, 6);
+ const release = rich.release || ((/release ([0-9a-f]{7,40})/i.exec(raw) || [])[1] || null);
+ const failing = (/\((\d+) failing check/i.exec(raw) || [])[1];
+ let summary = rich.summary || raw
+ .replace(/run:\s*sudo [^;]+?(?:\.\s|;|\.$|$)/g, '')
+ .replace(/Firing since [^.]+\.?/g, '')
+ .replace(/(?:^|\.\s+)[a-z0-9][a-z0-9.-]{1,30}:\s/g, ' ')
+ .replace(/\s+/g, ' ').trim();
+ if (release || failing) summary = [release ? `Release ${String(release).slice(0, 7)}` : null, failing ? `${failing} failing check${failing === '1' ? '' : 's'}` : null].filter(Boolean).join(' · ');
+ const severity = rich.severity || (n.priority === 'critical' ? 'critical' : n.priority === 'high' ? 'warning' : 'info');
+ const tone = state === 'resolved' ? 'ok' : severity === 'critical' ? 'danger' : 'warn';
+ return {
+ kind: 'ops', key: `ops:${name}:${state === 'resolved' ? 'resolved' : 'open'}`, tone, glyph: state === 'resolved' ? 'check' : 'pulse',
+ title: (state === 'resolved' ? 'Resolved: ' : state === 'reminded' ? 'Still failing: ' : '') + humanize(name),
+ message: summary.slice(0, 220), chips: sites, severity: state === 'resolved' ? 'resolved' : severity, command,
+ label: 'System', url: rich.runbook_url || urlOf(n), actionLabel: 'Open status',
+ };
+ }
+ 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) : [];
+ 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,
+ label: SERVICE_LABELS[n.service] || (n.service && n.service !== 'network' ? n.service : '') || CATEGORY_LABELS[cat] || '',
+ url: urlOf(n), actionLabel: null,
+ };
+ }
+ function avatarOf(n) {
+ const rich = richOf(n);
+ const img = n.sender_avatar || rich.thumbnail || (rich.user && rich.user.avatar_url);
+ return img && /^https?:\/\//.test(String(img)) ? String(img) : null;
+ }
+ /** A picture for the right of a row (rich_content.image, thumbnail_url or thumbnail), unless it is the avatar. */
+ function thumbOf(n) {
+ const rich = richOf(n);
+ const img = rich.image || rich.thumbnail_url || rich.thumbnail;
+ if (!img || !/^https:\/\//.test(String(img))) return null;
+ return String(img) === avatarOf(n) ? null : String(img);
+ }
+ function glyphHtml(n, p) {
+ const tone = TONE[p.tone] || TONE.accent;
+ const img = p.kind === 'ops' ? null : avatarOf(n);
+ if (img) return `<span class="ovn-glyph ovn-avatar" style="--ovn-tone:${tone}"><img src="${esc(img)}" alt="" loading="lazy" onerror="this.remove()"><span class="ovn-badge">${svg(GLYPH[p.glyph] || GLYPH.bell)}</span></span>`;
+ return `<span class="ovn-glyph" style="--ovn-tone:${tone}">${svg(GLYPH[p.glyph] || GLYPH.bell)}</span>`;
+ }
+ function chipsHtml(p, extra = []) {
+ const sev = p.severity ? [`<span class="ovn-chip sev" style="--ovn-tone:${TONE[p.tone] || TONE.accent}">${esc(p.severity)}</span>`] : [];
+ const all = sev.concat((p.chips || []).concat(extra).map(c => `<span class="ovn-chip">${esc(c)}</span>`));
+ return all.length ? `<div class="ovn-chips">${all.join('')}</div>` : '';
+ }
+
+ // ── API ─────────────────────────────────────────────────
+ function isCrossOrigin() {
+ try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }
+ }
+ function apiFetch(path, opts = {}) {
+ const headers = { ...(opts.headers || {}) };
+ if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;
+ if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {
+ headers['Content-Type'] = 'application/json';
+ opts = { ...opts, body: JSON.stringify(opts.body) };
+ }
+ return fetch(`${_config.apiBase}${path}`, { ...opts, headers, credentials: isCrossOrigin() ? 'omit' : 'include' });
+ }
+ async function apiJson(path, opts) {
+ const res = await apiFetch(path, opts);
+ let data = null; try { data = await res.json(); } catch { /* */ }
+ if (!res.ok) { const e = new Error((data && data.error) || `HTTP ${res.status}`); e.status = res.status; e.body = data; throw e; }
+ return data || {};
+ }
+ function hasSession() { return !!_config.token || !isCrossOrigin(); }
+
+ // Quiet hours (OpenVibe.Network GET /api/notifications/quiet-hours, 4.30.0): during the person's own window, in their
+ // time zone, a toast and its sound wait: the bell and the badge still fill, and a critical notice still shows. Read
+ // once when the layer starts and again every ten minutes; a Network that does not answer means no quiet hours.
+ let _quiet = null, _quietAt = 0;
+ function loadQuiet() {
+ if (!hasSession() || Date.now() - _quietAt < 10 * 60000) return;
+ _quietAt = Date.now();
+ apiJson('/api/notifications/quiet-hours').then((q) => { _quiet = q && typeof q.start === 'string' ? q : null; }).catch(() => { _quiet = null; });
+ }
+ const minutesOf = (s) => { const m = /^(\d{1,2}):(\d{2})$/.exec(String(s || '')); return m ? Number(m[1]) * 60 + Number(m[2]) : null; };
+ function inQuiet() {
+ loadQuiet();
+ const q = _quiet;
+ if (!q || !q.enabled) return false;
+ const a = minutesOf(q.start), b = minutesOf(q.end);
+ if (a == null || b == null || a === b) return false;
+ let now;
+ try {
+ const parts = new Intl.DateTimeFormat('en-GB', { timeZone: q.tz || 'UTC', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date());
+ now = Number(parts.find((x) => x.type === 'hour').value) * 60 + Number(parts.find((x) => x.type === 'minute').value);
+ } catch { const d = new Date(); now = d.getUTCHours() * 60 + d.getUTCMinutes(); }
+ return a < b ? now >= a && now < b : now >= a || now < b;
+ }
+ const held = (n) => (n && n.priority) !== 'critical' && inQuiet();
+
+ // ── Cross-tab sync (same origin) ────────────────────────
+ function channel() {
+ if (_channel !== null) return _channel || null;
+ try { _channel = new BroadcastChannel('openvibe-notifications'); _channel.onmessage = (e) => onSyncMessage(e.data || {}); }
+ catch { _channel = false; }
+ return _channel || null;
+ }
+ function broadcast(msg) { try { channel()?.postMessage(msg); } catch { /* */ } }
+ function onSyncMessage(msg) {
+ 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); }
+ }
+
+ // ── Sound ───────────────────────────────────────────────
+ function playSound(priority) {
+ if (!_preferences.sound) return;
+ if (priority !== 'critical' && inQuiet()) return;
+ const file = SOUNDS[priority]; if (!file) return;
+ const url = `${_config.soundBase}/${file}`;
+ try {
+ if (!_audioCache[url]) _audioCache[url] = new Audio(url);
+ const a = _audioCache[url]; a.currentTime = 0; a.volume = priority === 'critical' ? 0.8 : 0.5; a.play().catch(() => {});
+ } catch { /* */ }
+ }
+
+ // ── Layers: toasts never draw over an open menu, panel or dialog ─────────
+ // What counts as open, read from the page itself: this file loads late (after the bell mounts), so an
+ // 'openvibe-menu' event fired before it is missed; the menu's own class and every bar toggle's aria-expanded are not.
+ const OPEN_LAYERS = '.ovs.is-open, .openvibe-navbar [aria-expanded="true"], .openvibe-notif-panel.open, dialog[open], [aria-modal="true"]:not([hidden])';
+ function layerBusy() {
+ if (_menuOpen) return true;
+ try { if (root.OpenVibePanels && typeof root.OpenVibePanels.anyOpen === 'function' && root.OpenVibePanels.anyOpen()) return true; } catch { /* */ }
+ if (document.body && document.body.classList && document.body.classList.contains('ov-panel-open')) return true;
+ // Only what is drawn counts: a closed menu may keep aria-modal while it sits at display: none.
+ try { for (const el of document.querySelectorAll(OPEN_LAYERS)) if (el.getClientRects().length) return true; } catch { /* */ }
+ return false;
+ }
+ let _layerWatch = false;
+ function watchLayers() {
+ if (_layerWatch || typeof document === 'undefined') return;
+ _layerWatch = true;
+ document.addEventListener('openvibe-menu', (e) => { _menuOpen = !!(e && e.detail && e.detail.open); syncLayer(); });
+ try {
+ // aria-expanded and open change only when something opens or closes, so watching them page-wide is cheap.
+ if (typeof root.MutationObserver === 'function' && document.body) new root.MutationObserver(() => syncLayer()).observe(document.body, { attributes: true, subtree: true, attributeFilter: ['aria-expanded', 'open', 'aria-modal'] });
+ if (typeof root.MutationObserver === 'function' && document.body) new root.MutationObserver(() => syncLayer()).observe(document.body, { attributes: true, attributeFilter: ['class'] });
+ } catch { /* */ }
+ // Without an observer, any click may have opened or closed something: check right after it.
+ document.addEventListener('click', () => Promise.resolve().then(syncLayer), true);
+ }
+ function syncLayer() {
+ if (!_toastContainer) return;
+ const busy = layerBusy();
+ const was = _toastContainer.classList.contains('ovn-held');
+ if (busy === was) return;
+ _toastContainer.classList.toggle('ovn-held', busy);
+ for (const el of _toastContainer.querySelectorAll('.openvibe-toast')) busy ? pauseToast(el) : resumeToast(el);
+ if (!busy) flushQueue();
+ }
+
+ // ── Toasts ──────────────────────────────────────────────
+ function ensureToastContainer() {
+ if (_toastContainer) return;
+ _toastContainer = document.createElement('div');
+ _toastContainer.className = 'openvibe-toast-container';
+ _toastContainer.setAttribute('role', 'region');
+ _toastContainer.setAttribute('aria-label', 'Notifications');
+ _toastContainer.setAttribute('aria-live', 'polite');
+ document.body.appendChild(_toastContainer);
+ watchLayers();
+ }
+ function placeContainer() {
+ const phone = isPhone();
+ _toastContainer.classList.toggle('ovn-phone', phone);
+ if (phone) {
+ // Under whatever bar the site has; above it when the bar is hidden on scroll.
+ const bar = document.querySelector('nav.openvibe-navbar, nav.navbar, header.navbar, .navbar');
+ const r = bar && bar.getBoundingClientRect ? bar.getBoundingClientRect() : null;
+ const top = r && r.bottom > 0 ? Math.round(r.bottom) + 8 : 10;
+ _toastContainer.style.setProperty('--ovn-top', top + 'px');
+ }
+ }
+ function visibleToasts() { return _toastContainer ? [..._toastContainer.querySelectorAll('.openvibe-toast:not(.removing)')] : []; }
+ function maxVisible() { return isPhone() ? 1 : MAX_TOASTS; }
+
+ // Urgent first: a full stack makes room by letting a lower-priority card go, never a higher one.
+ const RANK = { critical: 3, high: 2, normal: 1, low: 0 };
+ const rank = (n) => RANK[(n && n.priority) || 'normal'] ?? 1;
+ const _shownIds = new Set(); // one notification id is one card, however many times a poll or a push brings it
+ function showToast(n) {
+ if (!_preferences.toasts) return;
+ if (held(n)) return;
+ if (_preferences.muted_categories.includes(n.category)) return;
+ const id = n && n.id != null ? String(n.id) : null;
+ if (id) {
+ if (_shownIds.has(id)) return;
+ _shownIds.add(id);
+ if (_shownIds.size > 500) _shownIds.delete(_shownIds.values().next().value);
+ }
+ ensureToastContainer();
+ const p = present(n);
+ // The same notice again within the window joins the card already showing (or waiting).
+ const now = Date.now();
+ const live = visibleToasts().find(el => el.__ovn && el.__ovn.key === p.key && now - el.__ovn.at < GROUP_WINDOW);
+ if (live) { mergeToast(live, n, p); return; }
+ const queued = _queue.find(q => q.p.key === p.key);
+ if (queued) { queued.count += 1; queued.n = rank(n) >= rank(queued.n) ? n : queued.n; queued.chips = [...new Set(queued.chips.concat(p.chips))]; return; }
+ _queue.push({ n, p, count: 1, chips: p.chips.slice() });
+ _queue.sort((a, b) => rank(b.n) - rank(a.n)); // stable: arrival order within a priority
+ flushQueue();
+ }
+ function flushQueue() {
+ if (!_toastContainer || layerBusy()) { if (_toastContainer) _toastContainer.classList.toggle('ovn-held', layerBusy()); updateMore(); return; }
+ placeContainer();
+ while (_queue.length) {
+ const vis = visibleToasts();
+ if (vis.length < maxVisible()) { renderToast(_queue.shift()); continue; }
+ const lowest = vis.reduce((a, el) => (rank(el.__ovn.n) < rank(a.__ovn.n) ? el : a), vis[0]);
+ if (rank(_queue[0].n) > rank(lowest.__ovn.n)) { dismissToast(lowest); continue; }
+ break;
+ }
+ updateMore();
+ }
+ /** "+N more" lives inside the card that is showing (a phone shows one at a time), never floating over the page. */
+ function updateMore() {
+ if (!_toastContainer) return;
+ const waiting = _queue.length;
+ const first = visibleToasts()[0];
+ _toastContainer.querySelectorAll('.openvibe-toast-more').forEach((m) => { if (!waiting || !isPhone() || !first || !first.contains(m)) m.remove(); });
+ if (!waiting || !isPhone() || !first) return;
+ let more = first.querySelector('.openvibe-toast-more');
+ if (!more) {
+ more = document.createElement('button');
+ more.type = 'button'; more.className = 'openvibe-toast-more';
+ more.addEventListener('click', (e) => { e.stopPropagation(); const f = visibleToasts()[0]; if (f) dismissToast(f); else flushQueue(); });
+ const sub = first.querySelector('.openvibe-toast-sub');
+ (sub || first.querySelector('.openvibe-toast-body') || first).appendChild(more);
+ }
+ more.textContent = `+${waiting} more`;
+ more.setAttribute('aria-label', `${waiting} more notification${waiting === 1 ? '' : 's'}: show the next`);
+ }
+ function renderToast(q) {
+ const { n, p } = q;
+ const toast = document.createElement('div');
+ toast.className = `openvibe-toast priority-${esc(n.priority || 'normal')}`;
+ toast.dataset.notifId = n.id || '';
+ toast.tabIndex = 0;
+ toast.style.setProperty('--ovn-tone', TONE[p.tone] || TONE.accent);
+ if (n.priority === 'critical') toast.setAttribute('role', 'alert');
+ toast.__ovn = { key: p.key, at: Date.now(), count: q.count, chips: q.chips.slice(), ids: n.id ? [n.id] : [], n };
+ const rich = richOf(n);
+ const custom = Array.isArray(rich.actions) ? rich.actions.filter(a => a && a.type !== 'input' && a.label).slice(0, 2) : [];
+ const actions = custom.length ? custom : (p.url ? [{ id: 'open', label: p.actionLabel || 'View', url: p.url, style: 'primary' }] : []);
+ toast.innerHTML = `
+ ${glyphHtml(n, p)}
+ <div class="openvibe-toast-body">
+ <div class="openvibe-toast-title"><span class="t">${esc(p.title)}</span><span class="n" data-role="n"${q.count > 1 ? '' : ' hidden'}>×${q.count}</span></div>
+ <div class="openvibe-toast-sub">${esc(p.label || '')}${p.label ? '<span aria-hidden="true">·</span>' : ''}<span>now</span></div>
+ ${p.message ? `<div class="openvibe-toast-msg" data-role="msg">${esc(p.message)}</div>` : ''}
+ <div data-role="chips">${chipsHtml({ ...p, chips: q.chips })}</div>
+ ${actions.length ? `<div class="openvibe-toast-actions">${actions.map(a => a.url
+ ? `<a href="${esc(a.url)}" data-action="${esc(a.id || 'open')}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</a>`
+ : `<button type="button" data-action="${esc(a.id)}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</button>`).join('')}</div>` : ''}
+ </div>
+ <button type="button" class="openvibe-toast-close" aria-label="Dismiss">×</button>`;
+ const duration = TOAST_DURATION[n.priority] ?? 5200;
+ toast.__ovnDuration = duration;
+ if (duration > 0) {
+ const bar = document.createElement('div');
+ bar.className = 'openvibe-toast-progress';
+ toast.appendChild(bar);
+ startTimer(toast, duration);
+ }
+ toast.querySelector('.openvibe-toast-close').addEventListener('click', (e) => { e.stopPropagation(); dismissToast(toast); });
+ toast.addEventListener('click', (e) => {
+ if (e.target.closest && e.target.closest('[data-action]')) return;
+ const ids = toast.__ovn.ids; if (ids.length) markRead(ids);
+ dismissToast(toast);
+ if (p.url) navigateTo(p.url);
+ });
+ toast.addEventListener('keydown', (e) => { if (e.key === 'Escape') dismissToast(toast); });
+ toast.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const ids = toast.__ovn.ids; if (ids.length) markRead(ids);
+ const a = actions.find(x => String(x.id || 'open') === btn.dataset.action);
+ if (a && a.url) { e.preventDefault(); dismissToast(toast); navigateTo(a.url); return; }
+ if (_config.onAction) _config.onAction(n.id, btn.dataset.action, n);
+ dismissToast(toast);
+ }));
+ // Hover or focus holds the timer; a swipe sideways (or up, on a phone) dismisses.
+ toast.addEventListener('mouseenter', () => pauseToast(toast));
+ toast.addEventListener('mouseleave', () => resumeToast(toast));
+ toast.addEventListener('focusin', () => pauseToast(toast));
+ toast.addEventListener('focusout', () => resumeToast(toast));
+ attachSwipe(toast);
+ _toastContainer.appendChild(toast);
+ }
+ function mergeToast(el, n, p) {
+ const st = el.__ovn;
+ if (n.id && st.ids.includes(n.id)) return;
+ st.count += 1; st.at = Date.now(); st.n = rank(n) >= rank(st.n) ? n : st.n;
+ if (n.id) st.ids.push(n.id);
+ st.chips = [...new Set(st.chips.concat(p.chips))].slice(0, 8);
+ const c = el.querySelector('[data-role="n"]'); if (c) { c.textContent = `×${st.count}`; c.hidden = false; }
+ const m = el.querySelector('[data-role="msg"]'); if (m && p.message) m.textContent = p.message;
+ const ch = el.querySelector('[data-role="chips"]'); if (ch) ch.innerHTML = chipsHtml({ ...p, chips: st.chips });
+ if (el.__ovnDuration > 0) startTimer(el, el.__ovnDuration);
+ }
+ function startTimer(el, ms) {
+ clearTimeout(el.__ovnTimer);
+ el.__ovnRemaining = ms; el.__ovnStarted = Date.now();
+ const bar = el.querySelector('.openvibe-toast-progress');
+ if (bar) { bar.style.transition = 'none'; bar.style.transform = 'scaleX(1)'; const go = () => { bar.style.transition = `transform ${ms}ms linear`; bar.style.transform = 'scaleX(0)'; }; (root.requestAnimationFrame || ((f) => f()))(go); }
+ if (_toastContainer && _toastContainer.classList.contains('ovn-held')) { pauseToast(el); return; }
+ el.__ovnTimer = setTimeout(() => dismissToast(el), ms);
+ }
+ function pauseToast(el) {
+ if (!el || !el.__ovnTimer) return;
+ clearTimeout(el.__ovnTimer); el.__ovnTimer = null;
+ el.__ovnRemaining = Math.max(800, (el.__ovnRemaining || 0) - (Date.now() - (el.__ovnStarted || Date.now())));
+ const bar = el.querySelector('.openvibe-toast-progress');
+ if (bar && root.getComputedStyle) { try { const t = root.getComputedStyle(bar).transform; bar.style.transition = 'none'; bar.style.transform = t && t !== 'none' ? t : 'scaleX(1)'; } catch { /* */ } }
+ }
+ function resumeToast(el) {
+ if (!el || el.__ovnTimer || !(el.__ovnDuration > 0) || el.classList.contains('removing')) return;
+ if (_toastContainer && _toastContainer.classList.contains('ovn-held')) return;
+ startTimer(el, el.__ovnRemaining || el.__ovnDuration);
+ }
+ function attachSwipe(el) {
+ if (typeof root.PointerEvent !== 'function') return;
+ let x0 = null, y0 = null, dx = 0, dy = 0;
+ el.addEventListener('pointerdown', (e) => { x0 = e.clientX; y0 = e.clientY; dx = dy = 0; });
+ el.addEventListener('pointermove', (e) => {
+ if (x0 == null) return;
+ dx = e.clientX - x0; dy = e.clientY - y0;
+ if (Math.abs(dx) > 8 || dy < -8) { el.style.transition = 'none'; el.style.transform = Math.abs(dx) >= Math.abs(dy) ? `translateX(${dx}px)` : `translateY(${Math.min(0, dy)}px)`; el.style.opacity = String(Math.max(0.2, 1 - Math.max(Math.abs(dx), -dy) / 220)); }
+ });
+ const end = () => {
+ if (x0 == null) return;
+ const gone = Math.abs(dx) > 90 || (isPhone() && dy < -40);
+ x0 = y0 = null;
+ if (gone) { el.__ovnSwiped = true; dismissToast(el); return; }
+ el.style.transition = 'transform .2s ease, opacity .2s ease'; el.style.transform = ''; el.style.opacity = '';
+ };
+ el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end);
+ // A swipe is not a click on the card.
+ el.addEventListener('click', (e) => { if (el.__ovnSwiped) { e.stopImmediatePropagation(); e.preventDefault(); } }, true);
+ }
+ function dismissToast(el) {
+ if (!el || !el.parentNode || el.classList.contains('removing')) return;
+ clearTimeout(el.__ovnTimer);
+ el.classList.add('removing');
+ setTimeout(() => { el.remove(); flushQueue(); }, 240);
+ }
+
+ // ── The ✦: the Action Center, loaded from beside this file on the first click ──
+ let _acLoading = null;
+ function actionCenterSrc() {
+ if (_config.actionCenterSrc) return _config.actionCenterSrc;
+ const m = /^(https?:\/\/[^?#]*\/)notification-ui\.js(?:[?#]|$)/.exec(SELF_SRC);
+ return `${m ? m[1] : 'https://openvibe.network/shared/'}action-center.js`;
+ }
+ function loadActionCenter() {
+ if (root.OpenVibeActionCenter) return Promise.resolve(root.OpenVibeActionCenter);
+ return _acLoading || (_acLoading = new Promise((ok) => {
+ const sc = document.createElement('script');
+ sc.src = actionCenterSrc(); sc.async = true;
+ sc.onload = () => ok(root.OpenVibeActionCenter || null);
+ sc.onerror = () => { _acLoading = null; ok(null); };
+ document.head.appendChild(sc);
+ }));
+ }
+ function openActions(anchor) {
+ loadActionCenter().then((AC) => { if (AC) AC.toggle({ anchor, token: () => _config.token, apiBase: _config.apiBase }); });
+ }
+ function createActionsEl() {
+ const b = document.createElement('div');
+ b.className = 'openvibe-actions-btn';
+ b.setAttribute('role', 'button'); b.setAttribute('aria-label', 'Agents'); b.setAttribute('aria-haspopup', 'dialog'); b.setAttribute('aria-expanded', 'false');
+ b.title = 'Agents: everything your agents are doing'; b.tabIndex = 0;
+ b.innerHTML = svg(GLYPH.spark);
+ b.addEventListener('click', () => openActions(b));
+ b.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openActions(b); } });
+ return b;
+ }
+
+ // ── Bell / badge ────────────────────────────────────────
+ 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(GLYPH.bell)}<span class="badge hidden">0</span>`;
+ bell.addEventListener('click', () => togglePanel());
+ bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } });
+ _bellEl = bell;
+ return bell;
+ }
+ 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');
+ }
+ 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 });
+ if (_unreadCount !== prev && listShowing()) loadTabCounts();
+ }
+
+ // ── List views (panel + inbox share this) ───────────────
+ const ICON = {
+ search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
+ filter: '<path d="M4 6.5h16M7 12h10M10 17.5h4"/>',
+ checkAll: '<path d="M2.5 12.5l4.5 4.5L17 7"/><path d="M11.5 16.5l.5.5L22 7"/>',
+ bellOn: GLYPH.bell,
+ bellOff: '<path d="M6.2 16.2V11a5.8 5.8 0 0 1 9.6-4.4M17.8 10.4v5.8l1.6 2H6"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0M3.5 3.5l17 17"/>',
+ gear: GLYPH.gear,
+ open: '<path d="M14 4h6v6M20 4l-9 9M19 14v5a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 4 19V7a1.5 1.5 0 0 1 1.5-1.5H10"/>',
+ read: '<path d="M5 12.6l4.6 4.6L19 7.6"/>',
+ close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
+ empty: '<path d="M18 37V26a14 14 0 0 1 28 0v11l4 5H14z"/><path d="M27 49a5 5 0 0 0 10 0"/><path d="M50 14l4-4M53 22h5M44 8l1-5"/>',
+ };
+
+ function buildView(root, { mode }) {
+ const v = { root, mode, tab: 'all', unread: false, q: '', items: [], offset: 0, hasMore: false, loading: false, seq: 0, ids: new Set() };
+ root.innerHTML = `
+ <div class="openvibe-nv-head">
+ <h3>Notifications <span class="count zero">0</span></h3>
+ <div class="openvibe-nv-actions">
+ <button type="button" data-act="read-all" title="Mark all as read">${svg(ICON.checkAll)}<span>Mark all read</span></button>
+ <button type="button" data-act="push" title="Push alerts on this device">${svg(ICON.bellOff)}<span>Push alerts</span></button>
+ <a href="${esc(inboxUrl().replace(/\/notifications$/, '/notifications#prefs'))}" data-act="prefs" title="Notification settings">${svg(ICON.gear)}<span>Settings</span></a>
+ ${mode === 'panel' ? `<a href="${esc(inboxUrl())}" data-act="inbox" title="Open the full inbox">${svg(ICON.open)}<span>Open inbox</span></a><button type="button" data-act="close" title="Close">${svg(ICON.close)}<span>Close</span></button>` : ''}
+ </div>
+ </div>
+ <div class="openvibe-nv-note" data-role="note" style="display:none"></div>
+ <div class="openvibe-nv-tools">
+ <div class="openvibe-nv-find">
+ <div class="openvibe-nv-search">${svg(ICON.search)}<input type="search" placeholder="Search notifications" aria-label="Search notifications" autocomplete="off" spellcheck="false"><button type="button" class="clear" aria-label="Clear search">×</button></div>
+ <button type="button" class="openvibe-nv-unread" aria-pressed="false" title="Show only unread">${svg(ICON.filter)}Unread</button>
+ </div>
+ <div class="openvibe-nv-tabs" role="tablist" aria-label="Notifications">
+ ${TABS.map(([id, label]) => `<button type="button" role="tab" data-tab="${id}" aria-selected="${id === 'all'}" tabindex="${id === 'all' ? '0' : '-1'}">${esc(label)}<span class="n" data-n="${id}"></span></button>`).join('')}
+ </div>
+ </div>
+ <div class="openvibe-nv-list" data-role="list" role="tabpanel" aria-label="All"></div>
+ <div class="openvibe-nv-foot"><span data-role="status"></span>${mode === 'dock' ? `<button type="button" class="ovn-foot-read" data-act="read-all-foot">${svg(ICON.checkAll)}Mark all read</button>` : ''}${mode === 'panel' || mode === 'dock' ? `<a href="${esc(inboxUrl())}">Full inbox →</a>` : ''}</div>`;
+ const list = root.querySelector('[data-role="list"]');
+ const input = root.querySelector('input[type="search"]');
+ let qTimer = null;
+ input.addEventListener('input', () => {
+ root.querySelector('.openvibe-nv-search').classList.toggle('has-q', !!input.value);
+ clearTimeout(qTimer); qTimer = setTimeout(() => { v.q = input.value.trim(); reload(v); }, 260);
+ });
+ root.querySelector('.openvibe-nv-search .clear').addEventListener('click', () => { input.value = ''; v.q = ''; root.querySelector('.openvibe-nv-search').classList.remove('has-q'); reload(v); });
+ // Tabs: a click or the arrow keys (Home, End) pick one; the list reloads with Network's ?tab=.
+ const tabs = [...root.querySelectorAll('.openvibe-nv-tabs [role="tab"]')];
+ const selectTab = (btn, focus) => {
+ tabs.forEach((b) => { const on = b === btn; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
+ if (focus) btn.focus();
+ try { btn.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } catch { /* */ }
+ v.tab = btn.dataset.tab;
+ list.setAttribute('aria-label', (TABS.find(([id]) => id === v.tab) || ['', 'All'])[1]);
+ reload(v);
+ };
+ tabs.forEach((btn) => btn.addEventListener('click', () => selectTab(btn)));
+ const tabRow = root.querySelector('.openvibe-nv-tabs');
+ const fade = () => { tabRow.classList.toggle('is-more', tabRow.scrollLeft + tabRow.clientWidth < tabRow.scrollWidth - 4); };
+ tabRow.addEventListener('scroll', fade, { passive: true });
+ v.fade = fade;
+ root.querySelector('.openvibe-nv-tabs').addEventListener('keydown', (e) => {
+ const i = tabs.indexOf(document.activeElement);
+ if (i < 0) return;
+ const j = e.key === 'ArrowRight' ? (i + 1) % tabs.length : e.key === 'ArrowLeft' ? (i - 1 + tabs.length) % tabs.length : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : -1;
+ if (j >= 0) { e.preventDefault(); selectTab(tabs[j], true); }
+ });
+ const unreadBtn = root.querySelector('.openvibe-nv-unread');
+ unreadBtn.addEventListener('click', () => { v.unread = !v.unread; unreadBtn.setAttribute('aria-pressed', String(v.unread)); reload(v); });
+ root.querySelector('[data-act="read-all"]').addEventListener('click', () => markAllRead());
+ const footRead = root.querySelector('[data-act="read-all-foot"]'); if (footRead) footRead.addEventListener('click', () => markAllRead());
+ root.querySelector('[data-act="push"]').addEventListener('click', () => togglePush(v));
+ const closeBtn = root.querySelector('[data-act="close"]');
+ if (closeBtn) closeBtn.addEventListener('click', () => closePanel());
+ // Infinite scroll: load the next page when the user nears the bottom.
+ list.addEventListener('scroll', () => {
+ if (v.loading || !v.hasMore) return;
+ if (list.scrollTop + list.clientHeight >= list.scrollHeight - 120) loadMore(v);
+ });
+ list.addEventListener('click', (e) => {
+ if (e.target.closest('[data-act="ac"]')) { const anchor = _bellEl; closePanel(); openActions(anchor); return; }
+ const item = e.target.closest('.openvibe-notif-item');
+ if (!item || (item.__ovnSwipedAt && Date.now() - item.__ovnSwipedAt < 450)) return;
+ const id = item.dataset.id;
+ const ids = (item.dataset.ids || id).split(',').filter(Boolean);
+ const act = e.target.closest('[data-row]');
+ if (act) {
+ e.stopPropagation();
+ if (act.dataset.row === 'dismiss') ids.forEach(x => dismiss(v, x));
+ else if (act.dataset.row === 'read') markRead(ids);
+ else if (act.dataset.row === 'copy') copyText(act.dataset.cmd || '', act);
+ return;
+ }
+ const n = v.items.find(x => String(x.id) === id);
+ const unreadIds = ids.filter(x => { const it = v.items.find(y => String(y.id) === x); return it && !isRead(it); });
+ if (unreadIds.length) markRead(unreadIds);
+ const u = n && present(n).url;
+ if (u) navigateTo(u);
+ });
+ list.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.classList && e.target.classList.contains('ovn-row')) { e.preventDefault(); e.target.click(); } });
+ // A thumbnail or avatar that does not load leaves its row (no inline handlers: a site's CSP may forbid them).
+ list.addEventListener('error', (e) => {
+ const img = e.target;
+ if (!img || img.tagName !== 'IMG') return;
+ const row = img.closest('.ovn-row');
+ if (img.classList.contains('thumb') && row) row.classList.remove('has-thumb');
+ img.remove();
+ }, true);
+ attachRowSwipe(v, list);
+ refreshPushButton(v);
+ _views.set(root, v);
+ paintTabCounts();
+ return v;
+ }
+ /**
+ * Touch: a row follows the finger sideways. Past a third of its width (at most 96 px), right marks it read and left
+ * clears it (Network's dismiss); short of that it springs back. A vertical move is the list scrolling, not a swipe.
+ */
+ function attachRowSwipe(v, list) {
+ if (typeof root.PointerEvent !== 'function') return;
+ let s = null;
+ list.addEventListener('pointerdown', (e) => {
+ if (e.pointerType === 'mouse') return;
+ const row = e.target.closest && e.target.closest('.ovn-row');
+ if (!row || e.target.closest('button, a')) return;
+ s = { row, item: row.parentNode, x0: e.clientX, y0: e.clientY, dx: 0, on: false, id: e.pointerId };
+ });
+ list.addEventListener('pointermove', (e) => {
+ if (!s || e.pointerId !== s.id) return;
+ const dx = e.clientX - s.x0, dy = e.clientY - s.y0;
+ if (!s.on) {
+ if (Math.abs(dy) > 10 && Math.abs(dy) > Math.abs(dx)) { s = null; return; }
+ if (Math.abs(dx) < 12) return;
+ s.on = true; s.item.classList.add('swiping');
+ try { s.row.setPointerCapture(e.pointerId); } catch { /* */ }
+ }
+ // Already read: a short give to the right, nothing to do.
+ s.dx = dx > 0 && !s.item.classList.contains('unread') ? Math.min(dx, 24) : dx;
+ s.item.dataset.swipe = s.dx > 0 ? 'read' : 'clear';
+ s.row.style.transform = `translateX(${s.dx}px)`;
+ });
+ const end = () => {
+ if (!s) return;
+ const { row, item, dx, on } = s; s = null;
+ item.classList.remove('swiping');
+ if (!on) return;
+ item.__ovnSwipedAt = Date.now();
+ const ids = (item.dataset.ids || item.dataset.id || '').split(',').filter(Boolean);
+ const w = row.offsetWidth || 320;
+ const far = Math.min(96, w / 3);
+ const settle = () => setTimeout(() => { if (!item.classList.contains('swiping')) delete item.dataset.swipe; }, 220);
+ if (dx < -far) { row.style.transform = `translateX(${-w}px)`; setTimeout(() => ids.forEach((x) => dismiss(v, x)), 180); return; }
+ row.style.transform = '';
+ if (dx > far && item.classList.contains('unread')) markRead(ids);
+ settle();
+ };
+ list.addEventListener('pointerup', end);
+ list.addEventListener('pointercancel', end);
+ }
+ function copyText(text, btn) {
+ const done = () => { if (btn) { const was = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = was; }, 1400); } };
+ try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, () => {}); return; } } catch { /* */ }
+ done();
+ }
+
+ function renderItems(v, { append = false } = {}) {
+ const list = v.root.querySelector('[data-role="list"]');
+ // The same notice repeated back to back ("X is live!" six times; three browser checks failing) reads as one
+ // row with a count and the sites it covers.
+ const groups = [];
+ for (const n of v.items) {
+ const p = present(n);
+ const g = groups[groups.length - 1];
+ if (g && g.p.key === p.key) {
+ g.ids.push(String(n.id)); if (!isRead(n)) g.unread = true;
+ g.chips = [...new Set(g.chips.concat(p.chips))].slice(0, 8);
+ if (!g.cmd && p.command) g.cmd = p.command;
+ } else groups.push({ n, p, ids: [String(n.id)], unread: !isRead(n), chips: p.chips.slice(), cmd: p.command });
+ }
+ let lastDay = append ? v.lastDay : null;
+ const html = groups.map(({ n, p, ids, unread, chips, cmd }) => {
+ try {
+ const day = dayLabel(n.created_at);
+ const head = day !== lastDay ? `<div class="openvibe-nv-day">${esc(day)}</div>` : '';
+ lastDay = day;
+ const when = esc(timeAgo(n.created_at));
+ const label = p.label ? `<span class="dot"></span><span>${esc(p.label)}</span>` : '';
+ const thumb = p.kind === 'ops' ? null : thumbOf(n);
+ return `${head}<div class="openvibe-notif-item ${unread ? 'unread' : ''}" data-id="${esc(n.id)}" data-ids="${esc(ids.join(','))}" style="--ovn-tone:${TONE[p.tone] || TONE.accent}">
+ <div class="ovn-under" aria-hidden="true"><span class="read">${svg(ICON.read)}Read</span><span class="clear">Clear${svg(ICON.close)}</span></div>
+ <div class="ovn-row${thumb ? ' has-thumb' : ''}" tabindex="0">
+ ${unread ? '<span class="unread-dot" role="img" aria-label="Unread"></span>' : ''}
+ ${glyphHtml(n, p)}
+ <div class="content">
+ <div class="title"><span class="t">${esc(p.title)}</span>${ids.length > 1 ? `<span class="count" title="${ids.length} times">×${ids.length}</span>` : ''}</div>
+ ${p.message ? `<div class="msg">${esc(p.message)}</div>` : ''}
+ ${chipsHtml({ ...p, chips })}
+ ${cmd ? `<button type="button" class="copy" data-row="copy" data-cmd="${esc(cmd)}">Copy fix command</button>` : ''}
+ <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${when}</span>${label}<div class="row-actions">${unread ? `<button type="button" data-row="read" title="Mark as read" aria-label="Mark as read">${svg(ICON.read)}</button>` : ''}<button type="button" data-row="dismiss" title="Clear" aria-label="Clear">${svg(ICON.close)}</button></div></div>
+ </div>
+ ${thumb ? `<img class="thumb" src="${esc(thumb)}" alt="" loading="lazy">` : ''}
+ </div>
+ </div>`;
+ } catch { return ''; }
+ }).join('');
+ v.lastDay = lastDay;
+ if (!append) list.innerHTML = v.tab === 'agents' ? `<button type="button" class="ovn-ac-link" data-act="ac">${svg(GLYPH.spark)}<span>Everything your agents are doing: running, waiting for you, done</span></button>` : '';
+ if (!v.items.length) {
+ const [t0, s0] = TAB_EMPTY[v.tab] || TAB_EMPTY.all;
+ const acLink = v.tab === 'agents' ? `<button type="button" class="ovn-ac-link" data-act="ac">${svg(GLYPH.spark)}<span>Everything your agents are doing: running, waiting for you, done</span></button>` : '';
+ const title = v.q ? 'Nothing matches that search' : (v.unread ? 'You\'re all caught up' : t0);
+ const sub = v.q ? 'Try other words.' : (v.unread ? 'Nothing unread here.' : s0);
+ list.innerHTML = `${acLink}<div class="openvibe-nv-empty"><svg viewBox="0 0 64 64" aria-hidden="true">${ICON.empty}</svg><b>${esc(title)}</b>${esc(sub)}</div>`;
+ } else {
+ const old = list.querySelector('.openvibe-nv-more'); if (old) old.remove();
+ list.insertAdjacentHTML('beforeend', html);
+ if (v.hasMore) list.insertAdjacentHTML('beforeend', `<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>`);
+ const more = list.querySelector('[data-act="more"]'); if (more) more.addEventListener('click', () => loadMore(v));
+ }
+ const st = v.root.querySelector('[data-role="status"]');
+ if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.unread ? ' unread' : ''}` : '';
+ }
+
+ async function fetchPage(v, offset) {
+ const params = new URLSearchParams({ limit: String(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);
+ return apiJson(`/api/notifications?${params}`);
+ }
+ async function reload(v) {
+ const seq = ++v.seq;
+ loadTabCounts();
+ if (v.fade) setTimeout(v.fade, 0);
+ v.loading = true; v.offset = 0; v.items = []; v.ids = new Set(); v.hasMore = false; v.total = null; v.lastDay = null;
+ const list = v.root.querySelector('[data-role="list"]');
+ list.innerHTML = '<div class="openvibe-nv-more"><span class="spinner"></span></div>';
+ try {
+ const data = await fetchPage(v, 0);
+ 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;
+ renderItems(v);
+ } catch (e) {
+ if (seq !== v.seq) return;
+ list.innerHTML = `<div class="openvibe-nv-empty"><svg viewBox="0 0 64 64" aria-hidden="true">${ICON.empty}</svg><b>${e.status === 401 ? 'Sign in to see your notifications' : 'Could not load notifications'}</b>${e.status === 401 ? '' : 'Check your connection and try again.'}</div>`;
+ } finally { if (seq === v.seq) v.loading = false; }
+ }
+ async function loadMore(v) {
+ if (v.loading || !v.hasMore) return;
+ const seq = v.seq; v.loading = true;
+ const more = v.root.querySelector('.openvibe-nv-more'); if (more) more.innerHTML = '<span class="spinner"></span>';
+ try {
+ const data = await fetchPage(v, v.offset);
+ if (seq !== v.seq) return;
+ const fresh = (data.notifications || []).filter(n => !v.ids.has(String(n.id)));
+ fresh.forEach(n => v.ids.add(String(n.id)));
+ v.items = v.items.concat(fresh); v.hasMore = !!data.has_more; v.offset += (data.notifications || []).length; v.total = data.total ?? v.total;
+ // Re-render only the tail to keep the scroll position.
+ const old = v.root.querySelector('.openvibe-nv-more'); if (old) old.remove();
+ if (fresh.length) { const tail = { ...v, items: fresh }; renderItems(tail, { append: true }); v.lastDay = tail.lastDay; }
+ else if (v.hasMore) { const list = v.root.querySelector('[data-role="list"]'); list.insertAdjacentHTML('beforeend', '<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>'); list.querySelector('[data-act="more"]').addEventListener('click', () => loadMore(v)); }
+ const st = v.root.querySelector('[data-role="status"]');
+ if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.unread ? ' unread' : ''}` : '';
+ } catch { /* keep what we have */ }
+ finally { v.loading = false; }
+ }
+ function applyReadLocally(v, ids, all) {
+ const set = new Set((ids || []).map(String));
+ for (const n of v.items) if (all || set.has(String(n.id))) n.is_read = 1;
+ v.root.querySelectorAll('.openvibe-notif-item.unread').forEach(el => {
+ const elIds = (el.dataset.ids || el.dataset.id || '').split(',');
+ if (all || elIds.some(x => set.has(x))) { el.classList.remove('unread'); el.querySelector('.unread-dot')?.remove(); el.querySelector('[data-row="read"]')?.remove(); }
+ });
+ }
+
+ // The counts on the tabs (Network GET /api/notifications/unread-by-tab, 4.30.0): read when a list loads, after a read
+ // or a clear, and when the badge changes with a list showing; a Network without the route shows none.
+ let _tabCounts = null, _tabAt = 0;
+ function loadTabCounts(force) {
+ if (!hasSession() || !_views.size) return;
+ if (!force && Date.now() - _tabAt < 4000) return;
+ _tabAt = Date.now();
+ apiJson('/api/notifications/unread-by-tab').then((d) => { _tabCounts = (d && d.tabs) || null; paintTabCounts(); }).catch(() => { _tabCounts = null; paintTabCounts(); });
+ }
+ function paintTabCounts() {
+ for (const v of _views.values()) {
+ for (const [id, label] of TABS) {
+ const el = v.root.querySelector(`[data-n="${id}"]`);
+ if (!el) continue;
+ const n = id === 'all' || !_tabCounts ? 0 : Math.max(0, Number(_tabCounts[id]) || 0);
+ el.textContent = n ? (n > 99 ? '99+' : String(n)) : '';
+ el.parentNode.setAttribute('aria-label', n ? `${label}, ${n} unread` : label);
+ }
+ }
+ }
+ function listShowing() { return [..._views.values()].some((v) => v.mode !== 'panel' || (_panelEl && _panelEl.classList.contains('open'))); }
+
+ // ── Mutations ───────────────────────────────────────────
+ async function markRead(ids) {
+ ids = (ids || []).map(String).filter(Boolean);
+ if (!ids.length) return;
+ for (const v of _views.values()) applyReadLocally(v, ids, false);
+ broadcast({ type: 'read', ids });
+ try {
+ const data = ids.length === 1
+ ? await apiJson(`/api/notifications/${encodeURIComponent(ids[0])}/read`, { method: 'POST' })
+ : await apiJson('/api/notifications/read-batch', { method: 'POST', body: { ids } });
+ if (data && typeof data.unread === 'number') setBadge(data.unread, { quiet: true }), broadcast({ type: 'unread', count: data.unread });
+ else pollUnread();
+ } catch { pollUnread(); }
+ loadTabCounts(true);
+ }
+ async function markAllRead() {
+ for (const v of _views.values()) applyReadLocally(v, [], true);
+ setBadge(0);
+ if (_tabCounts) { _tabCounts = Object.fromEntries(Object.keys(_tabCounts).map((k) => [k, 0])); paintTabCounts(); }
+ broadcast({ type: 'read', all: true });
+ try { await apiJson('/api/notifications/read-all', { method: 'POST', body: {} }); } catch { pollUnread(); }
+ loadTabCounts(true);
+ }
+ async function dismiss(v, id) {
+ const n = v.items.find(x => String(x.id) === String(id));
+ v.items = v.items.filter(x => String(x.id) !== String(id));
+ const row = v.root.querySelector(`.openvibe-notif-item[data-id="${CSS.escape(String(id))}"]`);
+ if (row) {
+ const rest = (row.dataset.ids || '').split(',').filter(x => x && x !== String(id));
+ if (rest.length) { row.dataset.ids = rest.join(','); row.dataset.id = rest[0]; const c = row.querySelector('.title .count'); if (c) { if (rest.length > 1) c.textContent = `×${rest.length}`; else c.remove(); } }
+ else row.remove();
+ }
+ if (v.total != null) v.total = Math.max(0, v.total - 1);
+ if (!v.items.length) renderItems(v);
+ try {
+ await apiJson(`/api/notifications/${encodeURIComponent(id)}/dismiss`, { method: 'POST' });
+ if (n && !isRead(n)) { setBadge(Math.max(0, _unreadCount - 1), { quiet: true }); loadTabCounts(true); }
+ } catch { /* */ }
+ }
+
+ // ── Polling / toasts ────────────────────────────────────
+ let _polling = false;
+ async function pollUnread() {
+ if (_polling || !hasSession()) return;
+ _polling = true;
+ _lastPoll = Date.now();
+ try {
+ const data = await apiJson('/api/notifications/unread-count');
+ const prev = _unreadCount;
+ const count = Number(data.count) || 0;
+ setBadge(count);
+ if (count > prev && _lastSeenAt) {
+ // Something new arrived: fetch what's newer than the last thing we saw.
+ const params = new URLSearchParams({ unread_only: '1', limit: '5', since: _lastSeenAt });
+ const nd = await apiJson(`/api/notifications?${params}`);
+ // Only what is unread and newer than the last thing seen, whatever the server answered.
+ const since = _lastSeenAt;
+ const fresh = (nd.notifications || []).filter(n => n && !n.is_read && (!since || String(n.created_at || '') > since)).slice().reverse();
+ for (const n of fresh) { showToast(n); playSound(n.priority); }
+ // Any open list should reflect the new items too.
+ for (const v of _views.values()) if (v.mode === 'inbox' || (_panelEl && _panelEl.classList.contains('open'))) reload(v);
+ }
+ const newest = await newestCreatedAt();
+ if (newest) _lastSeenAt = newest;
+ } catch { /* offline / signed out — try again next tick */ }
+ finally { _polling = false; }
+ }
+ async function newestCreatedAt() {
+ try { const d = await apiJson('/api/notifications/newest'); const c = d.notification && d.notification.created_at; return c ? String(c) : null; } catch { return null; }
+ }
+ function startPolling() {
+ stopPolling();
+ if (!hasSession()) return;
+ newestCreatedAt().then(c => { _lastSeenAt = c || new Date().toISOString(); pollUnread(); });
+ _pollTimer = setInterval(() => { if (!liveOpen() || Date.now() - _lastPoll >= REALTIME_POLL) pollUnread(); }, POLL_INTERVAL);
+ }
+ function stopPolling() { if (_pollTimer) clearInterval(_pollTimer); _pollTimer = null; }
+
+ // ── 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. */
+ function soon(all) {
+ _soonAll = _soonAll || all;
+ if (_soon) return;
+ _soon = setTimeout(() => {
+ _soon = null;
+ if (_polling) { soon(false); return; } // a poll in flight may predate the event: read again after it
+ const lists = _soonAll; _soonAll = false;
+ pollUnread();
+ if (lists) for (const v of _views.values()) reload(v);
+ }, 300);
+ }
+ 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`;
+ }
+ let _liveLoading = null;
+ function loadLive() {
+ if (root.OVNotificationLive) return Promise.resolve(root.OVNotificationLive);
+ return _liveLoading || (_liveLoading = new Promise((ok) => {
+ const sc = document.createElement('script');
+ sc.src = liveSrc(); sc.async = true;
+ sc.onload = () => ok(root.OVNotificationLive || null);
+ sc.onerror = () => { _liveLoading = null; ok(null); };
+ document.head.appendChild(sc);
+ }));
+ }
+ function startRealtime() {
+ if (!_config.realtime || !hasSession() || typeof root.EventSource !== 'function') return;
+ if (_live) { _live.restart(); return; }
+ loadLive().then((L) => {
+ if (!L || _live || !_config.realtime || !hasSession()) return;
+ _live = L.create({
+ ticketUrl: `${_config.apiBase}/api/v1/realtime/ticket`,
+ token: () => _config.token,
+ credentials: isCrossOrigin() ? 'omit' : 'include',
+ onNotification: () => soon(false),
+ onGap: () => soon(true),
+ });
+ _live.start();
+ });
+ }
+ function stopRealtime() { if (_live) _live.stop(); }
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) pollUnread(); });
+
+ // ── Panel ───────────────────────────────────────────────
+ function createPanel() {
+ const panel = document.createElement('div');
+ panel.className = 'openvibe-notif-panel';
+ panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Notifications');
+ document.body.appendChild(panel);
+ buildView(panel, { mode: 'panel' });
+ attachSheetDrag(panel);
+ document.addEventListener('click', (e) => {
+ if (panel.classList.contains('open') && !panel.contains(e.target) && !(_bellEl && _bellEl.contains(e.target))) closePanel();
+ });
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && panel.classList.contains('open')) closePanel(); });
+ _panelEl = panel;
+ return panel;
+ }
+ function closePanel() {
+ if (_panelEl && _panelEl.contains(document.activeElement) && _bellEl) { try { _bellEl.focus({ preventScroll: true }); } catch { /* */ } }
+ _panelEl?.classList.remove('open'); _bellEl?.classList.remove('open'); _bellEl?.setAttribute('aria-expanded', 'false');
+ if (_panelEl) _panelEl.style.transform = '';
+ document.documentElement.classList.remove('ovn-sheet-open');
+ syncLayer();
+ }
+ /** Phones: the sheet's head drags down; past 110 px it closes, short of that it springs back. */
+ function attachSheetDrag(panel) {
+ const head = panel.querySelector('.openvibe-nv-head');
+ if (!head || typeof root.PointerEvent !== 'function') return;
+ let y0 = null, dy = 0;
+ head.addEventListener('pointerdown', (e) => {
+ if (!isPhone() || e.pointerType === 'mouse' || e.target.closest('button, a')) return;
+ y0 = e.clientY; dy = 0; panel.style.transition = 'none';
+ try { head.setPointerCapture(e.pointerId); } catch { /* */ }
+ });
+ head.addEventListener('pointermove', (e) => { if (y0 == null) return; dy = Math.max(0, e.clientY - y0); panel.style.transform = `translateY(${dy}px)`; });
+ const end = () => {
+ if (y0 == null) return;
+ y0 = null;
+ panel.style.transition = 'transform .2s ease';
+ if (dy > 110) closePanel(); else panel.style.transform = '';
+ setTimeout(() => { panel.style.transition = ''; }, 220);
+ };
+ head.addEventListener('pointerup', end);
+ head.addEventListener('pointercancel', end);
+ }
+ function togglePanel() {
+ if (!_panelEl) createPanel();
+ const opening = !_panelEl.classList.contains('open');
+ if (opening && window.OpenVibePanels && !_panelEl.__registered) { _panelEl.__registered = true; window.OpenVibePanels.register({ el: _panelEl, openClass: 'open', id: 'notifications', close: () => { if (_panelEl.classList.contains('open')) togglePanel(); } }); window.OpenVibePanels.closeAll(_panelEl); }
+ if (opening) {
+ // Sit directly under the site's navbar, whatever its height (sticky bars, banners, safe areas).
+ const bar = _bellEl && _bellEl.closest('nav, header, .navbar');
+ const bottom = bar ? bar.getBoundingClientRect().bottom : (_bellEl ? _bellEl.getBoundingClientRect().bottom : 56);
+ _panelEl.style.setProperty('--ovn-top', Math.max(8, Math.round(bottom) + 6) + 'px');
+ if (!_panelEl.__esc) { _panelEl.__esc = (e) => { if (e.key === 'Escape' && _panelEl.classList.contains('open')) togglePanel(); }; document.addEventListener('keydown', _panelEl.__esc); }
+ }
+ if (!opening) { closePanel(); return; }
+ _panelEl.classList.add('open');
+ _bellEl?.classList.add('open');
+ _bellEl?.setAttribute('aria-expanded', 'true');
+ document.documentElement.classList.toggle('ovn-sheet-open', isPhone());
+ syncLayer();
+ reload(_views.get(_panelEl)); refreshPushButton(_views.get(_panelEl)); pollUnread();
+ // Focus the chosen tab, so the keyboard starts in the panel (Escape and the bell bring it back).
+ setTimeout(() => { try { _panelEl.querySelector('[role="tab"][aria-selected="true"]')?.focus({ preventScroll: true }); } catch { /* */ } }, 0);
+ }
+
+ // ── Web Push ────────────────────────────────────────────
+ function pushSupported() { return typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window && window.isSecureContext; }
+ function urlBase64ToUint8Array(b64) {
+ const padding = '='.repeat((4 - (b64.length % 4)) % 4);
+ const base64 = (b64 + padding).replace(/-/g, '+').replace(/_/g, '/');
+ const raw = window.atob(base64); const out = new Uint8Array(raw.length);
+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
+ return out;
+ }
+ async function currentSubscription() {
+ if (!pushSupported()) return null;
+ try { const reg = await navigator.serviceWorker.getRegistration(_config.swPath); return reg ? await reg.pushManager.getSubscription() : null; } catch { return null; }
+ }
+ async function pushStatus() {
+ const supported = pushSupported();
+ const sub = supported ? await currentSubscription() : null;
+ return { supported, permission: supported ? Notification.permission : 'unsupported', subscribed: !!sub };
+ }
+ async function enablePush() {
+ if (!pushSupported()) throw new Error('This browser does not support push notifications');
+ if (!hasSession()) throw new Error('Sign in first');
+ const perm = await Notification.requestPermission();
+ if (perm !== 'granted') throw new Error(perm === 'denied' ? 'Notifications are blocked for this site in your browser settings' : 'Permission not granted');
+ const reg = await navigator.serviceWorker.register(_config.swPath, { scope: '/' });
+ await navigator.serviceWorker.ready;
+ const { publicKey } = await apiJson('/api/push/vapid-key');
+ if (!publicKey) throw new Error('Push is not configured on the server');
+ let sub = await reg.pushManager.getSubscription();
+ if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) });
+ await apiJson('/api/push/subscribe', { method: 'POST', body: { subscription: { ...sub.toJSON(), userAgent: navigator.userAgent.slice(0, 200) } } });
+ try { localStorage.setItem('ov_push_enabled', '1'); } catch { /* */ }
+ return true;
+ }
+ async function disablePush() {
+ const sub = await currentSubscription();
+ if (sub) {
+ try { await apiJson('/api/push/unsubscribe', { method: 'POST', body: { endpoint: sub.endpoint } }); } catch { /* */ }
+ try { await sub.unsubscribe(); } catch { /* */ }
+ }
+ try { localStorage.removeItem('ov_push_enabled'); } catch { /* */ }
+ return true;
+ }
+ async function refreshPushButton(v) {
+ const btn = v && v.root.querySelector('[data-act="push"]');
+ if (!btn) return;
+ const st = await pushStatus();
+ if (!st.supported) { btn.style.display = 'none'; return; }
+ btn.style.display = '';
+ btn.classList.toggle('on', st.subscribed);
+ btn.innerHTML = st.subscribed ? `${svg(ICON.bellOn)}<span>Push on</span>` : `${svg(ICON.bellOff)}<span>Enable push</span>`;
+ btn.title = st.subscribed ? 'Push alerts are on for this device: select to turn off' : (st.permission === 'denied' ? 'Notifications are blocked in your browser settings' : 'Get alerts on this device even when the site is closed');
+ btn.disabled = st.permission === 'denied' && !st.subscribed;
+ }
+ async function togglePush(v) {
+ const note = v.root.querySelector('[data-role="note"]');
+ const say = (msg, ms = 4000) => { if (!note) return; note.textContent = msg; note.style.display = ''; clearTimeout(note._t); note._t = setTimeout(() => { note.style.display = 'none'; }, ms); };
+ try {
+ const st = await pushStatus();
+ if (st.subscribed) { await disablePush(); say('Push alerts are off for this device.'); }
+ else { await enablePush(); say('Push alerts are on: you\'ll get them here even when this site is closed.'); }
+ } catch (e) { say(e.message || 'Could not change push setting', 6000); }
+ for (const view of _views.values()) refreshPushButton(view);
+ }
+
+ // ── Public API ──────────────────────────────────────────
+ const OpenVibeNotifications = {
+ init(opts = {}) {
+ Object.assign(_config, opts);
+ _quietAt = 0; loadQuiet(); // quiet hours before the first notice can arrive
+ injectStyles();
+ if (opts.preferences) Object.assign(_preferences, opts.preferences);
+ channel();
+ startPolling();
+ startRealtime();
+ // If the user enabled push on this device before, make sure the worker is registered.
+ try { if (pushSupported() && localStorage.getItem('ov_push_enabled') === '1') navigator.serviceWorker.register(_config.swPath, { scope: '/' }).catch(() => {}); } catch { /* */ }
+ },
+ destroy() {
+ stopPolling();
+ stopRealtime();
+ _toastContainer?.remove(); _panelEl?.remove();
+ _toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null; _queue.length = 0;
+ },
+ /** Create and inject the bell icon. Returns the DOM element. */
+ createBell(container) {
+ injectStyles();
+ const bell = createBellEl();
+ if (container && _config.actions !== false && !container.querySelector('.openvibe-actions-btn')) container.appendChild(createActionsEl());
+ if (container) container.appendChild(bell);
+ pollUnread();
+ return bell;
+ },
+ /** Open the Action Center (the ✦) from a site's own control. */
+ openActions(anchor) { injectStyles(); openActions(anchor || null); },
+ /** Full-page inbox (same list, filters, search, infinite scroll) rendered into a container. */
+ renderInbox(container, opts = {}) {
+ injectStyles();
+ if (!container) return null;
+ container.classList.add('openvibe-notif-inbox');
+ const v = buildView(container, { mode: 'inbox' });
+ if (opts.tab) { v.tab = opts.tab; container.querySelectorAll('[data-tab]').forEach(b => { b.classList.toggle('active', b.dataset.tab === opts.tab); b.setAttribute('aria-selected', String(b.dataset.tab === opts.tab)); }); }
+ reload(v);
+ return v;
+ },
+ /** The Dock's Activity tab: the bell's list in the Dock's container (no frame, no title, no close). */
+ renderDock(container) {
+ injectStyles();
+ if (!container) return null;
+ container.classList.add('openvibe-notif-dock');
+ const v = buildView(container, { mode: 'dock' });
+ reload(v);
+ return v;
+ },
+ /** Programmatically show a toast (real-time events). Marks nothing on the server. */
+ push(notification) {
+ showToast(notification);
+ playSound(notification.priority);
+ if (!notification.id) return;
+ setBadge(_unreadCount + 1);
+ },
+ /** Re-fetch the badge and any open lists (call after an in-page action). */
+ refresh() { pollUnread(); for (const v of _views.values()) reload(v); },
+ markRead(ids) { return markRead(Array.isArray(ids) ? ids : [ids]); },
+ markAllRead,
+ setPreferences(prefs) { Object.assign(_preferences, prefs); },
+ setToken(token) {
+ _config.token = token;
+ if (token || !isCrossOrigin()) { startPolling(); startRealtime(); } else { stopPolling(); stopRealtime(); setBadge(0, { quiet: true }); }
+ },
+ /** The realtime feed's state ({ state: 'off' | 'ticket' | 'connecting' | 'open' | 'backoff' | 'hidden' | 'blocked' | 'failed' | 'signed-out' | 'unavailable', … }), or null when off. */
+ realtimeState() { return _live ? _live.state() : null; },
+ /** How a notification reads (title, message, chips, tone, glyph, group key): the same for toasts and the list. */
+ present,
+ togglePanel, closePanel,
+ enablePush, disablePush, pushStatus,
+ get unreadCount() { return _unreadCount; },
+ };
+
+ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNotifications;
+ else root.OpenVibeNotifications = OpenVibeNotifications;
+
+})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
new file mode 100644
new file mode 100644
@@ -0,0 +1,259 @@
@@ -0,0 +1,259 @@
+/**
+ * ov-mark.js — the OpenVibe "OV" brand mark as a drop-in.
+ * <span class="ov-mark"></span> → 32px animated mark
+ * <span class="ov-mark" data-size="56"> → any size; add data-static="1" for no motion
+ * <span class="ov-mark" data-variant="live"> → the site's own twist on the motion (see VARIANTS)
+ * Self-contained: injects its own CSS once, works on any page (Live, Network, static pages).
+ * The mark is one stroke, the "open loop" (4.25.0; the owner, 2026-10-28: "the open vibe logo isn't even that great…
+ * come up with new ideas… more modern… better effects"): an O opened at the top by the V, so it reads OV and "open" at
+ * once. The stroke runs from the site's accent towards violet; it draws itself in on a visit's first view, a short light
+ * runs along it, and the site's dot sits in the opening (Live's is red and on air). Transform/opacity/stroke motion only.
+ *
+ * Motion budget: SVG animations restyle and re-lay-out on the main thread every frame, so an
+ * endlessly moving mark kept idle pages 12-29% busy (browser check, OpenVibe.Host). A mark now
+ * moves only for its intro (the first INTRO_MS of a 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 activity (data-state busy/ok,
+ * island.js). Otherwise its CSS animations are paused where they are and its SMIL clock stopped,
+ * so it keeps its look. 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).
+ *
+ * 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, a pumpkin's stem, leaf and ribs in October, a cap 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.
+ */
+(function () {
+ 'use strict';
+ if (window.__ovMark) return; window.__ovMark = true;
+ let n = 0;
+ const CSS = `
+.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;color:var(--accent,#8b5cf6);flex:none;vertical-align:middle;line-height:0;--ovm-to:color-mix(in oklab,currentColor 50%,#e879f9)}
+.ov-mark svg{width:100%;height:100%;overflow:visible;transform-origin:50% 50%}
+.ov-mark .g{fill:currentColor;opacity:.12;transform-box:fill-box;transform-origin:center;animation:ovmGlow 3.4s ease-in-out infinite}
+.ov-mark .lp{fill:none;stroke-width:5.2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:100;animation:ovmDraw 1.2s cubic-bezier(.3,.8,.2,1) both}
+.ov-mark .sw{fill:none;stroke:#fff;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:7 93;opacity:.9;animation:ovmSweep 3s linear infinite}
+.ov-mark .d{fill:var(--ovm-dot,var(--text-primary,#fff));transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}
+/* Per-site variants: one family, each site with its own rhythm so the network reads as one brand with many rooms.
+ Colours stay with the theme (currentColor); only the dot and the motion change. */
+.ov-mark[data-variant=live]{--ovm-dot:#ef4444}
+.ov-mark[data-variant=live] .d{animation:ovmOnAir 1.4s ease-in-out infinite}
+.ov-mark[data-variant=tools] .sw{animation-duration:2s}
+.ov-mark[data-variant=media] .sw,.ov-mark[data-variant=video] .sw{stroke-dasharray:3 6;animation-duration:6s}
+.ov-mark[data-variant=games] svg{animation:ovmBounce 1.6s cubic-bezier(.3,1.4,.4,1) infinite}
+.ov-mark[data-variant=games] .d{animation-duration:1.6s}
+.ov-mark[data-variant=community] .sw{animation-direction:reverse}
+.ov-mark[data-variant=network] .g{animation-duration:2.4s}
+.ov-mark[data-variant=chat] .d{animation:ovmDot 1s ease-in-out infinite}
+.ov-mark[data-variant=stream] .sw{stroke-dasharray:20 80;animation-duration:2.4s}
+.ov-mark[data-variant=codes] .lp{animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) infinite alternate}
+.ov-mark[data-variant=blog] .sw,.ov-mark[data-variant=wiki] .sw,.ov-mark[data-variant=news] .sw{animation-duration:6s}
+.ov-mark[data-variant=vip]{--ovm-dot:#facc15}
+.ov-mark[data-variant=tips]{--ovm-dot:#22c55e}
+.ov-mark[data-variant=deals],.ov-mark[data-variant=coupons]{--ovm-dot:#f59e0b}
+.ov-mark[data-variant=trade] .sw{animation-timing-function:cubic-bezier(.4,0,.2,1)}
+.ov-mark[data-variant=host] svg{animation:none}
+@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.75}50%{transform:scale(1.6);opacity:1}}
+@keyframes ovmBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.5px)}}
+.ov-mark:hover svg,a:hover>.ov-mark svg,button:hover>.ov-mark svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
+.ov-mark:hover .sw,a:hover>.ov-mark .sw{animation-duration:1s}
+.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .lp,.ov-mark[data-static] .sw,.ov-mark[data-static] .d{animation:none!important}
+.ov-mark[data-static] .sw{display:none}
+.ov-mark[data-ov]:not([data-ovm-run]) svg,.ov-mark[data-ov]:not([data-ovm-run]) .g,.ov-mark[data-ov]:not([data-ovm-run]) .sw,.ov-mark[data-ov]:not([data-ovm-run]) .d{animation-play-state:paused!important}
+.ov-mark[data-ov]:not([data-ovm-run]) .sw{opacity:0;transition:opacity .4s}
+.ov-mark[data-ov][data-variant=codes]:not([data-ovm-run]) .lp{animation:none!important;stroke-dashoffset:0}
+.ov-mark .p{fill:none;stroke:currentColor;stroke-width:3.4;stroke-linecap:round;transform:rotate(-90deg);transform-box:fill-box;transform-origin:center;stroke-dasharray:138.2;stroke-dashoffset:calc(138.2px * (1 - var(--ovm-p,0)));opacity:0;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s}
+.ov-mark[data-progress] .p{opacity:1}
+.ov-mark[data-progress] .sw{opacity:0}
+.ov-mark[data-state=busy] .sw{animation-duration:1s}
+.ov-mark[data-state=ok]{color:var(--success,#22c55e)!important}
+.ov-mark[data-state=error]{color:var(--danger,#ef4444)!important}
+.ov-mark[data-state=ok] svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
+@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}
+@keyframes ovmDraw{from{stroke-dashoffset:100}to{stroke-dashoffset:0}}
+@keyframes ovmSweep{from{stroke-dashoffset:100}to{stroke-dashoffset:0}}
+@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.45);opacity:1}}
+@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.15)}to{transform:rotate(360deg) scale(1)}}
+.ov-mark .sh,.ov-mark .sw2{display:none}
+html[data-ov-season-mark=halloween] .ov-mark .sh,html[data-ov-season-mark=winter] .ov-mark .sw2{display:inline}
+html[data-ov-season-mark=halloween] .ov-mark .d{display:none}
+.ov-mark .ss{fill:none;stroke:#5fae3c;stroke-width:3.2;stroke-linecap:round}
+.ov-mark .sl{fill:#7fd04e;transform-box:fill-box;transform-origin:0 70%}
+.ov-mark .sc{fill:none;stroke:#f4fbff;stroke-width:4.6;stroke-linecap:round}
+.ov-mark .sd{fill:#f4fbff}
+html[data-ov-season-mark=halloween] .ov-mark{--ovm-to:#f97316}
+html[data-ov-season-mark] .ov-mark[data-ovm-run] .sl{animation:ovmLeaf .9s cubic-bezier(.3,1.6,.5,1) both}
+.ov-mark[data-ovm-flourish] .sl{animation:ovmWiggle 1.2s ease-in-out 1}
+.ov-mark[data-ovm-flourish] .sd{animation:ovmTwinkle 1.4s ease-in-out 1}
+.ov-mark[data-ovm-flourish] .sw{animation:ovmSweep .9s linear 1;opacity:.9}
+.ovm-fall{position:fixed;width:0;height:0;pointer-events:none;z-index:10010}
+.ovm-fall i{position:absolute;left:-4px;top:-4px;width:9px;height:9px;border-radius:0 70% 0 70%;opacity:0;animation:ovmFall 3s cubic-bezier(.3,.1,.5,1) forwards}
+html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:50%}
+@keyframes ovmLeaf{from{transform:scale(0) rotate(-50deg)}to{transform:none}}
+@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 svg,.ov-mark .g,.ov-mark .lp,.ov-mark .sw,.ov-mark .d,.ov-mark .sl,.ov-mark .sd{animation:none!important}.ov-mark .sw,.ovm-fall{display:none}}
+html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
+ // The open loop: from the opening's left end round the bottom to its right end, then down the V and back up.
+ // pathLength=100 lets the draw-in and the running light be written in hundredths of the stroke.
+ const LOOP = 'M15.2 8.8A17.5 17.5 0 1 0 32.8 8.8L24 30.5Z';
+ function svg(id) {
+ return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs>
+<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" style="stop-color:var(--ovm-to)"/></linearGradient></defs>
+<circle class="g" cx="24" cy="24" r="22"/>
+<circle class="p" cx="24" cy="24" r="22"/>
+<path class="lp" d="${LOOP}" pathLength="100" stroke="url(#${id}g)"/>
+<path class="sw" d="${LOOP}" pathLength="100"/>
+<circle class="d" cx="24" cy="7.4" r="2.7"/>
+<g class="sh"><path class="ss" d="M24 9.6c.2-2.8 1-4.8 2.7-6.1"/><path class="sl" d="M26.4 4.6c2.3-2.5 5.8-2.7 8.3-1.2-1.9 2.7-5.4 3.5-8.3 1.2z"/></g>
+<g class="sw2"><path class="sc" d="M6.6 15.4A19.4 19.4 0 0 1 14.3 7.2"/><path class="sc" d="M33.7 7.2A19.4 19.4 0 0 1 41.4 15.4"/><circle class="sd" cx="10.4" cy="9" r="1.4"/><circle class="sd" cx="38.2" cy="9.6" r="1.2"/></g></svg>`;
+ }
+ const INTRO_MS = 8000, LINGER_MS = 1200;
+ const state = new WeakMap(); // mark → { introEnd, lingerEnd, hover, focus, onScreen, timer }
+ let io = null, rm = null;
+ // One intro per visit (tab): the deadline is kept in sessionStorage so the next page of a
+ // multi-page site finishes the same intro instead of starting another.
+ const INTRO_KEY = 'ov-mark-intro-end';
+ function introDeadline(now) {
+ let end = 0;
+ try { end = Number(sessionStorage.getItem(INTRO_KEY)) || 0; } catch { /* no storage */ }
+ 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';
+ }
+ 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 s = el.getAttribute('data-state');
+ const run = may && (now < st.introEnd || st.hover || st.focus || now < st.lingerEnd || s === 'busy' || s === 'ok');
+ if (run !== el.hasAttribute('data-ovm-run')) { if (run) el.setAttribute('data-ovm-run', ''); else el.removeAttribute('data-ovm-run'); }
+ try {
+ if (run) svgEl.unpauseAnimations();
+ else {
+ // Never run yet: rest on the frame where the V's light sweep is centred.
+ if (svgEl.getCurrentTime() < 0.5) svgEl.setCurrentTime(1.6);
+ svgEl.pauseAnimations();
+ }
+ } catch { /* no SMIL */ }
+ clearTimeout(st.timer); st.timer = 0;
+ const next = Math.min(...[st.introEnd, st.lingerEnd].filter((t) => t > now));
+ if (run && next !== Infinity) st.timer = setTimeout(() => update(el), next - now + 20);
+ }
+ // A mark taken out of the page stops and is let go (a navbar re-render replaces its mark);
+ // mount() picks it up again if it comes back.
+ function forget(el) {
+ const st = state.get(el); if (!st) return;
+ clearTimeout(st.timer); state.delete(el);
+ if (io) try { io.unobserve(el); } catch { /* */ }
+ el.removeAttribute('data-ovm-run');
+ try { el.querySelector('svg').pauseAnimations(); } catch { /* */ }
+ }
+ function updateAll() { document.querySelectorAll('.ov-mark[data-ov]').forEach(update); }
+ // ── The season ──
+ const season = () => document.documentElement.getAttribute('data-ov-season-mark');
+ let flourishTimer = 0;
+ function scheduleFlourish() {
+ clearTimeout(flourishTimer); flourishTimer = 0;
+ if (!season()) return;
+ flourishTimer = setTimeout(() => {
+ if (season() && !reduced() && !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);
+ });
+ }
+ scheduleFlourish();
+ }, (4 + Math.random() * 4) * 60000);
+ }
+ const FALL_KEY = 'ov-season-fall';
+ function fallOnce(el) {
+ const id = season(); if (!id || reduced() || document.hidden || !el.isConnected) return;
+ 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;
+ const box = document.createElement('div'); box.className = 'ovm-fall'; box.setAttribute('aria-hidden', 'true');
+ const x0 = r.left + r.width / 2, y0 = r.top + r.height * 0.6;
+ box.style.left = x0 + 'px'; box.style.top = y0 + 'px';
+ // The leaves land on the bottom edge of the bar the mark sits in and fade there, so they never cross a page's
+ // heading (they fell 120-280 px over it on phones, 10-30). A mark outside a bar gets a short fall.
+ const bar = el.closest('.openvibe-navbar,header,nav');
+ 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++) {
+ 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 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]}`;
+ box.appendChild(s);
+ }
+ document.body.appendChild(box);
+ setTimeout(() => box.remove(), 5200);
+ }
+ const bound = new WeakSet();
+ function track(el) {
+ state.set(el, { introEnd: 0, lingerEnd: 0, hover: false, focus: false, onScreen: io ? null : true, timer: 0 });
+ if (!bound.has(el)) {
+ bound.add(el);
+ const host = el.closest('a,button') || el;
+ const set = (k, v) => () => {
+ const st = state.get(el); if (!st) return;
+ st[k] = v; if (!v) st.lingerEnd = Date.now() + LINGER_MS;
+ update(el);
+ };
+ host.addEventListener('pointerenter', set('hover', true)); host.addEventListener('pointerleave', set('hover', false));
+ host.addEventListener('focusin', set('focus', true)); host.addEventListener('focusout', set('focus', false));
+ }
+ if (io) io.observe(el);
+ update(el);
+ }
+ function mount(root) {
+ (root || document).querySelectorAll('.ov-mark').forEach(el => {
+ if (!el.hasAttribute('data-ov')) {
+ el.setAttribute('data-ov', '1');
+ const size = parseInt(el.getAttribute('data-size'), 10);
+ if (size) { el.style.width = size + 'px'; el.style.height = size + 'px'; }
+ el.innerHTML = svg('ovm' + (++n) + '_');
+ }
+ if (!state.has(el) && el.isConnected) track(el);
+ });
+ }
+ function init() {
+ if (!document.getElementById('ov-mark-css')) { const st = document.createElement('style'); st.id = 'ov-mark-css'; st.textContent = CSS; document.head.appendChild(st); }
+ try {
+ io = new IntersectionObserver((entries) => entries.forEach((e) => {
+ const st = state.get(e.target); if (!st) return;
+ st.onScreen = e.isIntersecting; update(e.target);
+ }));
+ } catch { io = null; }
+ try { rm = window.matchMedia('(prefers-reduced-motion: reduce)'); rm.addEventListener('change', updateAll); } catch { rm = null; }
+ document.addEventListener('visibilitychange', updateAll);
+ mount();
+ scheduleFlourish();
+ try {
+ 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.addedNodes.length) added = true;
+ r.removedNodes.forEach((node) => {
+ if (node.nodeType !== 1) return;
+ if (state.has(node) && !node.isConnected) forget(node);
+ if (node.querySelectorAll) node.querySelectorAll('.ov-mark[data-ov]').forEach((m) => { if (!m.isConnected) forget(m); });
+ });
+ }
+ if (added) mount();
+ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-ov-motion', 'data-ov-season-mark'] });
+ } catch { /* */ }
+ }
+ window.ovMarkMount = mount;
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
+})();
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"test": "node test/run.js",
"test": "node test/run.js",
"pack": "node scripts/pack.js",
"pack": "node scripts/pack.js",
"pack:stores": "node scripts/pack.js --stores",
"pack:stores": "node scripts/pack.js --stores",
- "lint:firefox": "npx --yes [email protected] lint --source-dir browser --warnings-as-errors",
+ "lint:firefox": "npx --yes [email protected] lint --source-dir browser --warnings-as-errors --ignore-files \"shared/**\"",
"pack:dev": "node scripts/pack.js --api http://localhost:4850",
"pack:dev": "node scripts/pack.js --api http://localhost:4850",
"icons": "node scripts/icons.js"
"icons": "node scripts/icons.js"
},
},
@@ -3,16 +3,21 @@
@@ -3,16 +3,21 @@
* The permission audit for browser/manifest.json. test/manifest.test.js runs it, and so does
* The permission audit for browser/manifest.json. test/manifest.test.js runs it, and so does
* scripts/pack.js before it writes a zip. It fails when anything widens:
* scripts/pack.js before it writes a zip. It fails when anything widens:
*
*
- * permissions exactly activeTab + scripting + storage (scripting runs lib/autoapply.js or lib/rating.js in the
- * active tab only, when the person presses "Try these codes on this page" or "Save its rating to
- * OpenVibe Reviews": activeTab is the only host access)
- * host_permissions exactly <API origin>/*
+ * permissions exactly activeTab + scripting + storage, plus sidePanel in the Chrome build
+ * (scripting runs lib/autoapply.js or lib/rating.js in the active tab only, when the
+ * person presses "Try these codes on this page" or "Save its rating to OpenVibe
+ * Reviews": activeTab is the only host access; Firefox's sidebar_action, the
+ * side panel there, needs no permission of its own)
+ * host_permissions exactly the API origin, the Network origin and the Chat origin, all three
+ * https://openvibe.<tld> sites (the coupon API, the Dock's state, the Dock's
+ * Chats tab)
* forbidden keys content_scripts, background, web_accessible_resources,
* forbidden keys content_scripts, background, web_accessible_resources,
* externally_connectable, optional_permissions, optional_host_permissions,
* externally_connectable, optional_permissions, optional_host_permissions,
* declarative_net_request, chrome_url_overrides, devtools_page, sandbox,
* declarative_net_request, chrome_url_overrides, devtools_page, sandbox,
- * side_panel, omnibox, … (anything not on the allow-list of keys)
- * CSP script-src 'self' only, connect-src only the API origin, no unsafe-*
- * pages popup/options HTML load only local scripts, no inline script
+ * omnibox, … (anything not on the allow-list of keys; the side panel's own keys,
+ * side_panel and sidebar_action, are on it)
+ * CSP script-src 'self' only, connect-src only the OpenVibe origins, no unsafe-*
+ * pages popup/options/panel HTML load only local scripts, no inline script
* config lib/config.js API_ORIGIN equals the host permission's origin
* config lib/config.js API_ORIGIN equals the host permission's origin
* hand-offs lib/config.js HANDOFF_ORIGINS are https://openvibe.<tld> sites only (the
* hand-offs lib/config.js HANDOFF_ORIGINS are https://openvibe.<tld> sites only (the
* tabs the Companion may open when you press a button; never requested)
* tabs the Companion may open when you press a button; never requested)
@@ -21,17 +26,35 @@ const fs = require('fs');
@@ -21,17 +26,35 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const ALLOWED_KEYS = new Set(['manifest_version', 'name', 'short_name', 'version', 'description', 'homepage_url', 'author', 'icons',
const ALLOWED_KEYS = new Set(['manifest_version', 'name', 'short_name', 'version', 'description', 'homepage_url', 'author', 'icons',
- 'action', 'options_ui', 'permissions', 'host_permissions', 'content_security_policy', 'minimum_chrome_version', 'browser_specific_settings']);
-const PERMISSIONS = ['activeTab', 'scripting', 'storage'];
+ 'action', 'options_ui', 'permissions', 'host_permissions', 'content_security_policy', 'minimum_chrome_version', 'browser_specific_settings',
+ 'side_panel', 'sidebar_action']);
+const BASE_PERMISSIONS = ['activeTab', 'scripting', 'storage'];
+const SIDE_PERMISSIONS = ['sidePanel', 'sidebarAction'];
+const PERMISSIONS = BASE_PERMISSIONS.concat(['sidePanel']);
-function auditManifest(m, { apiOrigin }) {
+function auditManifest(m, { apiOrigin, origins } = {}) {
const problems = [];
const problems = [];
+ const os = (origins && origins.length ? origins : [apiOrigin]).filter(Boolean);
if (m.manifest_version !== 3) problems.push('manifest_version must be 3');
if (m.manifest_version !== 3) problems.push('manifest_version must be 3');
for (const k of Object.keys(m)) if (!ALLOWED_KEYS.has(k)) problems.push(`manifest key "${k}" is not allowed`);
for (const k of Object.keys(m)) if (!ALLOWED_KEYS.has(k)) problems.push(`manifest key "${k}" is not allowed`);
const perms = Array.isArray(m.permissions) ? m.permissions : [];
const perms = Array.isArray(m.permissions) ? m.permissions : [];
- if (perms.length !== PERMISSIONS.length || !PERMISSIONS.every((p) => perms.includes(p))) problems.push(`permissions must be exactly ${PERMISSIONS.join(', ')} (got ${perms.join(', ') || 'none'})`);
+ const extra = perms.filter((p) => !BASE_PERMISSIONS.includes(p));
+ if (perms.length < BASE_PERMISSIONS.length || !BASE_PERMISSIONS.every((p) => perms.includes(p))) {
+ problems.push(`permissions must be exactly ${BASE_PERMISSIONS.join(', ')} (got ${perms.join(', ') || 'none'})`);
+ }
+ if (extra.length > 1 || extra.some((p) => !SIDE_PERMISSIONS.includes(p))) {
+ problems.push(`the only extra permission may be sidePanel (Chrome) (got ${extra.join(', ') || 'none'})`);
+ }
const hosts = Array.isArray(m.host_permissions) ? m.host_permissions : [];
const hosts = Array.isArray(m.host_permissions) ? m.host_permissions : [];
- if (hosts.length !== 1 || hosts[0] !== `${apiOrigin}/*`) problems.push(`host_permissions must be exactly ${apiOrigin}/* (got ${hosts.join(', ') || 'none'})`);
+ const want = os.map((o) => o + '/*').sort();
+ if (hosts.length !== want.length || hosts.slice().sort().join() !== want.join()) {
+ problems.push(`host_permissions must be exactly ${want.join(', ')} (got ${hosts.join(', ') || 'none'})`);
+ }
+ const panel = m.side_panel || m.sidebar_action;
+ if (!panel) problems.push('the side panel needs side_panel (Chrome) or sidebar_action (Firefox)');
+ else if ((m.side_panel && m.side_panel.default_path !== 'panel.html') || (m.sidebar_action && m.sidebar_action.default_panel !== 'panel.html')) {
+ problems.push('the side panel opens panel.html');
+ }
if (!m.action || m.action.default_popup !== 'popup.html') problems.push('action.default_popup must be popup.html');
if (!m.action || m.action.default_popup !== 'popup.html') problems.push('action.default_popup must be popup.html');
if (!m.options_ui || m.options_ui.page !== 'options.html') problems.push('options_ui.page must be options.html');
if (!m.options_ui || m.options_ui.page !== 'options.html') problems.push('options_ui.page must be options.html');
const csp = m.content_security_policy && m.content_security_policy.extension_pages;
const csp = m.content_security_policy && m.content_security_policy.extension_pages;
@@ -39,7 +62,7 @@ function auditManifest(m, { apiOrigin }) {
@@ -39,7 +62,7 @@ function auditManifest(m, { apiOrigin }) {
else {
else {
const dirs = Object.fromEntries(csp.split(';').map((d) => d.trim()).filter(Boolean).map((d) => { const [k, ...v] = d.split(/\s+/); return [k, v]; }));
const dirs = Object.fromEntries(csp.split(';').map((d) => d.trim()).filter(Boolean).map((d) => { const [k, ...v] = d.split(/\s+/); return [k, v]; }));
if (JSON.stringify(dirs['script-src']) !== JSON.stringify(["'self'"])) problems.push("CSP script-src must be exactly 'self'");
if (JSON.stringify(dirs['script-src']) !== JSON.stringify(["'self'"])) problems.push("CSP script-src must be exactly 'self'");
- if (JSON.stringify(dirs['connect-src']) !== JSON.stringify([apiOrigin])) problems.push(`CSP connect-src must be exactly ${apiOrigin}`);
+ if (JSON.stringify(dirs['connect-src'].slice().sort()) !== JSON.stringify(os.slice().sort())) problems.push(`CSP connect-src must be exactly ${os.join(' ')}`);
if (JSON.stringify(dirs['default-src']) !== JSON.stringify(["'none'"])) problems.push("CSP default-src must be 'none'");
if (JSON.stringify(dirs['default-src']) !== JSON.stringify(["'none'"])) problems.push("CSP default-src must be 'none'");
if (JSON.stringify(dirs['object-src']) !== JSON.stringify(["'none'"])) problems.push("CSP object-src must be 'none'");
if (JSON.stringify(dirs['object-src']) !== JSON.stringify(["'none'"])) problems.push("CSP object-src must be 'none'");
if (/unsafe-|\*|data:|blob:|http:(?!\/\/(localhost|127\.0\.0\.1))/.test(csp)) problems.push('CSP must not allow unsafe-*, wildcards, data:, blob: or remote http');
if (/unsafe-|\*|data:|blob:|http:(?!\/\/(localhost|127\.0\.0\.1))/.test(csp)) problems.push('CSP must not allow unsafe-*, wildcards, data:, blob: or remote http');
@@ -67,14 +90,18 @@ function auditDir(dir) {
@@ -67,14 +90,18 @@ function auditDir(dir) {
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
const configSrc = fs.readFileSync(path.join(dir, 'lib', 'config.js'), 'utf8');
const configSrc = fs.readFileSync(path.join(dir, 'lib', 'config.js'), 'utf8');
const origin = (configSrc.match(/API_ORIGIN:\s*'([^']+)'/) || [])[1];
const origin = (configSrc.match(/API_ORIGIN:\s*'([^']+)'/) || [])[1];
+ const network = (configSrc.match(/NETWORK_ORIGIN:\s*'([^']+)'/) || [])[1];
+ const chat = (configSrc.match(/CHAT_ORIGIN:\s*'([^']+)'/) || [])[1];
const handoffs = ((configSrc.match(/HANDOFF_ORIGINS:\s*Object\.freeze\(\[([^\]]*)\]\)/) || [])[1] || '').split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
const handoffs = ((configSrc.match(/HANDOFF_ORIGINS:\s*Object\.freeze\(\[([^\]]*)\]\)/) || [])[1] || '').split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
const problems = [];
const problems = [];
if (!origin) problems.push('lib/config.js has no API_ORIGIN');
if (!origin) problems.push('lib/config.js has no API_ORIGIN');
if (!handoffs.length) problems.push('lib/config.js has no HANDOFF_ORIGINS');
if (!handoffs.length) problems.push('lib/config.js has no HANDOFF_ORIGINS');
for (const h of handoffs) if (!/^https:\/\/openvibe\.[a-z]+$/.test(h)) problems.push(`hand-off origin ${h} is not an https://openvibe.<tld> site`);
for (const h of handoffs) if (!/^https:\/\/openvibe\.[a-z]+$/.test(h)) problems.push(`hand-off origin ${h} is not an https://openvibe.<tld> site`);
- problems.push(...auditManifest(manifest, { apiOrigin: origin }));
- for (const page of ['popup.html', 'options.html']) problems.push(...auditPage(fs.readFileSync(path.join(dir, page), 'utf8'), page));
- return { manifest, origin, problems };
+ const origins = [origin, network, chat].filter(Boolean);
+ for (const o of [network, chat].filter(Boolean)) if (!/^https:\/\/openvibe\.[a-z]+$/.test(o)) problems.push(`${o} is not an https://openvibe.<tld> site`);
+ problems.push(...auditManifest(manifest, { apiOrigin: origin, origins }));
+ for (const page of ['popup.html', 'options.html', 'panel.html']) problems.push(...auditPage(fs.readFileSync(path.join(dir, page), 'utf8'), page));
+ return { manifest, origin, origins, problems };
}
}
-module.exports = { auditManifest, auditPage, auditDir, PERMISSIONS, ALLOWED_KEYS };
+module.exports = { auditManifest, auditPage, auditDir, PERMISSIONS, BASE_PERMISSIONS, ALLOWED_KEYS };
@@ -75,9 +75,17 @@ function listFiles(dir, base = dir) {
@@ -75,9 +75,17 @@ function listFiles(dir, base = dir) {
/** The store manifest for one browser: only drops the other browser's key (the audit runs on the full one). */
/** The store manifest for one browser: only drops the other browser's key (the audit runs on the full one). */
function forTarget(files, target) {
function forTarget(files, target) {
const m = JSON.parse(files.get('manifest.json'));
const m = JSON.parse(files.get('manifest.json'));
- if (target === 'chrome') delete m.browser_specific_settings;
- else if (target === 'firefox') delete m.minimum_chrome_version;
- else throw new Error('target must be chrome or firefox');
+ if (target === 'chrome') {
+ delete m.browser_specific_settings;
+ delete m.sidebar_action;
+ m.permissions = (m.permissions || []).filter((p) => p !== 'sidebarAction');
+ m.permissions.push('sidePanel');
+ m.side_panel = { default_path: 'panel.html' };
+ } else if (target === 'firefox') {
+ delete m.minimum_chrome_version;
+ delete m.side_panel;
+ m.permissions = (m.permissions || []).filter((p) => p !== 'sidePanel');
+ } else throw new Error('target must be chrome or firefox');
files.set('manifest.json', Buffer.from(JSON.stringify(m, null, 2) + '\n'));
files.set('manifest.json', Buffer.from(JSON.stringify(m, null, 2) + '\n'));
}
}
@@ -131,4 +139,4 @@ if (require.main === module) {
@@ -131,4 +139,4 @@ if (require.main === module) {
}
}
}
}
-module.exports = { build, zip, crc32 };
+module.exports = { build, zip, crc32, forTarget };
@@ -11,20 +11,39 @@ const { check, done } = require('./helpers/harness');
@@ -11,20 +11,39 @@ const { check, done } = require('./helpers/harness');
const DIR = path.join(__dirname, '..', 'browser');
const DIR = path.join(__dirname, '..', 'browser');
const ORIGIN = 'https://openvibe.coupons';
const ORIGIN = 'https://openvibe.coupons';
+const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat'];
(async () => {
(async () => {
const { manifest, origin, problems } = auditDir(DIR);
const { manifest, origin, problems } = auditDir(DIR);
- await check('the shipped manifest passes the audit: activeTab + scripting + storage, one host permission (the API), MV3', async () => {
+ await check('the shipped manifest passes the audit: activeTab + scripting + storage + sidePanel, three OpenVibe host permissions, MV3', async () => {
assert.deepStrictEqual(problems, []);
assert.deepStrictEqual(problems, []);
assert.strictEqual(origin, ORIGIN);
assert.strictEqual(origin, ORIGIN);
assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'scripting', 'storage']);
assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'scripting', 'storage']);
- assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*']);
+ assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*', 'https://openvibe.network/*', 'https://openvibe.chat/*']);
+ assert.strictEqual(manifest.sidebar_action.default_panel, 'panel.html', 'the Dock lives in the side panel');
for (const k of ['content_scripts', 'background', 'web_accessible_resources', 'externally_connectable', 'optional_permissions', 'optional_host_permissions']) {
for (const k of ['content_scripts', 'background', 'web_accessible_resources', 'externally_connectable', 'optional_permissions', 'optional_host_permissions']) {
assert.ok(!(k in manifest), `${k} must not be present`);
assert.ok(!(k in manifest), `${k} must not be present`);
}
}
});
});
+ await check('the side panel is a key in both browsers\' manifests', async () => {
+ const { forTarget } = require('../scripts/pack');
+ const ff = new Map([['manifest.json', Buffer.from(JSON.stringify(manifest))]]);
+ forTarget(ff, 'firefox');
+ const firefox = JSON.parse(ff.get('manifest.json').toString('utf8'));
+ assert.ok(!('side_panel' in firefox), 'Firefox has no Chrome key');
+ assert.strictEqual(firefox.sidebar_action.default_panel, 'panel.html');
+ assert.ok(!firefox.permissions.includes('sidePanel'), 'Firefox needs no side-panel permission');
+ const ch = new Map([['manifest.json', Buffer.from(JSON.stringify(manifest))]]);
+ forTarget(ch, 'chrome');
+ const chrome = JSON.parse(ch.get('manifest.json').toString('utf8'));
+ assert.ok(chrome.side_panel, 'Chrome gets side_panel');
+ assert.ok(!('sidebar_action' in chrome), 'and no Firefox key');
+ assert.ok(chrome.permissions.includes('sidePanel'), 'Chrome asks for its side-panel permission');
+ assert.ok(!chrome.permissions.includes('sidebarAction'));
+ });
+
await check('the audit fails if anything widens', async () => {
await check('the audit fails if anything widens', async () => {
const widen = [
const widen = [
(m) => m.permissions.push('tabs'),
(m) => m.permissions.push('tabs'),
@@ -52,18 +71,18 @@ const ORIGIN = 'https://openvibe.coupons';
@@ -52,18 +71,18 @@ const ORIGIN = 'https://openvibe.coupons';
for (const [i, mutate] of widen.entries()) {
for (const [i, mutate] of widen.entries()) {
const m = JSON.parse(JSON.stringify(manifest));
const m = JSON.parse(JSON.stringify(manifest));
mutate(m);
mutate(m);
- assert.ok(auditManifest(m, { apiOrigin: ORIGIN }).length > 0, `widening #${i} was not caught: ${mutate}`);
+ assert.ok(auditManifest(m, { apiOrigin: ORIGIN, origins: ORIGINS }).length > 0, `widening #${i} was not caught: ${mutate}`);
}
}
});
});
await check('the API origin agrees across lib/config.js, host_permissions and the CSP connect-src', async () => {
await check('the API origin agrees across lib/config.js, host_permissions and the CSP connect-src', async () => {
const csp = manifest.content_security_policy.extension_pages;
const csp = manifest.content_security_policy.extension_pages;
- assert.match(csp, /connect-src https:\/\/openvibe\.coupons;/);
- assert.strictEqual(auditManifest(manifest, { apiOrigin: 'https://evil.example.net' }).length > 0, true);
+ assert.match(csp, /connect-src https:\/\/openvibe\.coupons https:\/\/openvibe\.network https:\/\/openvibe\.chat;/);
+ assert.strictEqual(auditManifest(manifest, { apiOrigin: 'https://evil.example.net' }).length > 0, true, 'another origin is caught');
});
});
await check('pages load only local scripts: no inline script, no inline handlers, no remote code', async () => {
await check('pages load only local scripts: no inline script, no inline handlers, no remote code', async () => {
- for (const p of ['popup.html', 'options.html']) assert.deepStrictEqual(auditPage(fs.readFileSync(path.join(DIR, p), 'utf8'), p), []);
+ for (const p of ['popup.html', 'options.html', 'panel.html']) assert.deepStrictEqual(auditPage(fs.readFileSync(path.join(DIR, p), 'utf8'), p), []);
assert.ok(auditPage('<script>alert(1)</script>', 'x').length);
assert.ok(auditPage('<script>alert(1)</script>', 'x').length);
assert.ok(auditPage('<script src="https://cdn.example.net/x.js"></script>', 'x').length);
assert.ok(auditPage('<script src="https://cdn.example.net/x.js"></script>', 'x').length);
assert.ok(auditPage('<button onclick="x()">', 'x').length);
assert.ok(auditPage('<button onclick="x()">', 'x').length);
@@ -71,7 +90,9 @@ const ORIGIN = 'https://openvibe.coupons';
@@ -71,7 +90,9 @@ const ORIGIN = 'https://openvibe.coupons';
await check('the code never uses cookies, dynamic code or HTML injection; only api.js fetches; only platform.js runs a script in a page', async () => {
await check('the code never uses cookies, dynamic code or HTML injection; only api.js fetches; only platform.js runs a script in a page', async () => {
const files = [];
const files = [];
- (function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (p.endsWith('.js')) files.push(p); } })(DIR);
+ // browser/shared/ is the released OpenVibe.Shared Dock, bundled as it is: it is audited in its own
+ // repository (OpenVibe.Shared), not here.
+ (function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) { if (e.name !== 'shared') walk(p); } else if (p.endsWith('.js')) files.push(p); } })(DIR);
const forbidden = [/\binnerHTML\b/, /\bouterHTML\b/, /insertAdjacentHTML/, /document\.write/, /\beval\s*\(/, /new Function\s*\(/, /\bcookies\b/, /document\.cookie/,
const forbidden = [/\binnerHTML\b/, /\bouterHTML\b/, /insertAdjacentHTML/, /document\.write/, /\beval\s*\(/, /new Function\s*\(/, /\bcookies\b/, /document\.cookie/,
/\bhistory\.(search|getVisits)/, /webRequest/, /storage\.sync/, /sendMessage|onMessageExternal|runtime\.connect/, /XMLHttpRequest/, /importScripts/];
/\bhistory\.(search|getVisits)/, /webRequest/, /storage\.sync/, /sendMessage|onMessageExternal|runtime\.connect/, /XMLHttpRequest/, /importScripts/];
for (const f of files) {
for (const f of files) {
@@ -79,7 +100,7 @@ const ORIGIN = 'https://openvibe.coupons';
@@ -79,7 +100,7 @@ const ORIGIN = 'https://openvibe.coupons';
const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"])\/\/.*$/gm, '$1');
const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"])\/\/.*$/gm, '$1');
for (const re of forbidden) assert.doesNotMatch(src, re, `${path.relative(DIR, f)} uses ${re}`);
for (const re of forbidden) assert.doesNotMatch(src, re, `${path.relative(DIR, f)} uses ${re}`);
const rel = path.relative(DIR, f);
const rel = path.relative(DIR, f);
- if (rel !== path.join('lib', 'api.js')) assert.doesNotMatch(src.replace(/fetch: self\.fetch\.bind\(self\)/g, ''), /\bfetch\s*\(/, `${rel} makes a request itself`);
+ if (rel !== path.join('lib', 'api.js') && rel !== path.join('lib', 'network-api.js')) assert.doesNotMatch(src.replace(/fetch: self\.fetch\.bind\(self\)/g, ''), /\bfetch\s*\(/, `${rel} makes a request itself`);
}
}
const tabUse = files.filter((f) => /\btabs\.(query|create|update)\b/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));
const tabUse = files.filter((f) => /\btabs\.(query|create|update)\b/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));
assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs (it opens only hand-off pages)');
assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs (it opens only hand-off pages)');
@@ -45,7 +45,7 @@ function unzip(buf) {
@@ -45,7 +45,7 @@ function unzip(buf) {
const src = path.join(__dirname, '..', 'browser');
const src = path.join(__dirname, '..', 'browser');
for (const [name, data] of files) assert.ok(data.equals(fs.readFileSync(path.join(src, name))), name);
for (const [name, data] of files) assert.ok(data.equals(fs.readFileSync(path.join(src, name))), name);
assert.ok(files.has('manifest.json') && files.has('popup.html') && files.has('lib/api.js') && files.has('icons/icon-128.png'));
assert.ok(files.has('manifest.json') && files.has('popup.html') && files.has('lib/api.js') && files.has('icons/icon-128.png'));
- assert.deepStrictEqual(auditManifest(JSON.parse(files.get('manifest.json')), { apiOrigin: 'https://openvibe.coupons' }), []);
+ assert.deepStrictEqual(auditManifest(JSON.parse(files.get('manifest.json')), { apiOrigin: 'https://openvibe.coupons', origins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat'] }), []);
});
});
await check('the build is reproducible', async () => {
await check('the build is reproducible', async () => {
@@ -59,8 +59,8 @@ function unzip(buf) {
@@ -59,8 +59,8 @@ function unzip(buf) {
assert.match(r.out, /-dev\.zip$/);
assert.match(r.out, /-dev\.zip$/);
const files = unzip(fs.readFileSync(r.out));
const files = unzip(fs.readFileSync(r.out));
const m = JSON.parse(files.get('manifest.json'));
const m = JSON.parse(files.get('manifest.json'));
- assert.deepStrictEqual(m.host_permissions, ['http://localhost:4850/*']);
- assert.match(m.content_security_policy.extension_pages, /connect-src http:\/\/localhost:4850;/);
+ assert.deepStrictEqual(m.host_permissions, ['http://localhost:4850/*', 'https://openvibe.network/*', 'https://openvibe.chat/*']);
+ assert.match(m.content_security_policy.extension_pages, /connect-src http:\/\/localhost:4850 https:\/\/openvibe\.network https:\/\/openvibe\.chat;/);
assert.match(m.name, /development/);
assert.match(m.name, /development/);
assert.match(files.get('lib/config.js').toString(), /API_ORIGIN: 'http:\/\/localhost:4850'/);
assert.match(files.get('lib/config.js').toString(), /API_ORIGIN: 'http:\/\/localhost:4850'/);
assert.doesNotMatch(files.get('manifest.json').toString() + files.get('lib/config.js').toString(), /openvibe\.coupons\//);
assert.doesNotMatch(files.get('manifest.json').toString() + files.get('lib/config.js').toString(), /openvibe\.coupons\//);
@@ -76,6 +76,14 @@ function unzip(buf) {
@@ -76,6 +76,14 @@ function unzip(buf) {
for (const [name, data] of files) if (name !== 'manifest.json') assert.ok(data.equals(fs.readFileSync(path.join(src, name))), name);
for (const [name, data] of files) if (name !== 'manifest.json') assert.ok(data.equals(fs.readFileSync(path.join(src, name))), name);
const want = { ...full };
const want = { ...full };
delete want[dropped];
delete want[dropped];
+ if (target === 'chrome') {
+ delete want.sidebar_action;
+ want.permissions = want.permissions.filter((p) => p !== 'sidebarAction').concat(['sidePanel']);
+ want.side_panel = { default_path: 'panel.html' };
+ } else {
+ delete want.side_panel;
+ want.permissions = want.permissions.filter((p) => p !== 'sidePanel');
+ }
assert.deepStrictEqual(JSON.parse(files.get('manifest.json')), want);
assert.deepStrictEqual(JSON.parse(files.get('manifest.json')), want);
assert.ok(fs.readFileSync(build({ outDir: tmp, target }).out).equals(fs.readFileSync(r.out)));
assert.ok(fs.readFileSync(build({ outDir: tmp, target }).out).equals(fs.readFileSync(r.out)));
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+/**
+ * The Dock in the side panel: the panel page loads with a stubbed Network, mounts the Dock against
+ * it, and says what is missing when there is no sign-in.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+const { check, done } = require('./helpers/harness');
+const { createPanel } = require('../browser/panel.js');
+const { createNetworkClient } = require('../browser/lib/network-api.js');
+
+const DIR = path.join(__dirname, '..', 'browser');
+const TOKEN = 'ov_token_' + 'x'.repeat(40);
+
+/** The bits of a document the panel touches. */
+function fakeDoc() {
+ const node = (id) => ({ id, textContent: '', hidden: true, addEventListener(type, fn) { if (type === 'click') this.onclick = fn; } });
+ const nodes = { gate: node('gate'), 'gate-title': node('gate-title'), 'gate-text': node('gate-text'), 'gate-button': node('gate-button') };
+ return { nodes, getElementById: (id) => nodes[id] || null };
+}
+
+/** A stand-in for the Network's GET /api/v1/me/dock. */
+async function startNetwork({ accept = TOKEN } = {}) {
+ const requests = [];
+ const server = http.createServer((req, res) => {
+ requests.push({ url: req.url, auth: req.headers.authorization || null });
+ const ok = req.headers.authorization === 'Bearer ' + accept;
+ res.writeHead(ok ? 200 : 401, { 'content-type': 'application/json' });
+ res.end(JSON.stringify(ok
+ ? { state: { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: new Date().toISOString() }, counts: { running: 1, waiting: 0, unread: 0 } }
+ : { error: 'unauthorized' }));
+ });
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const origin = 'http://127.0.0.1:' + server.address().port;
+ return { origin, requests, close: () => server.close() };
+}
+
+function fakeDock() {
+ const dock = { mounted: null, opened: 0, mount(opts) { dock.mounted = opts; }, open() { dock.opened++; } };
+ return dock;
+}
+
+(async () => {
+ const net1 = await startNetwork();
+ try {
+ await check('the panel loads the Dock with the Network\'s state', async () => {
+ const doc = fakeDoc();
+ const dock = fakeDock();
+ const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => TOKEN });
+ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => TOKEN } }, net, dock, sibling: (n) => 'chrome-extension://abc/shared/' + n });
+ const out = await panel.start();
+ assert.strictEqual(out.mounted, true, 'the Dock is mounted');
+ assert.strictEqual(dock.opened, 1, 'and opened');
+ assert.strictEqual(dock.mounted.token, TOKEN, "with the person's sign-in");
+ assert.strictEqual(dock.mounted.apiBase, 'https://openvibe.network', "the Network, where the Dock's state is");
+ assert.strictEqual(dock.mounted.chatBase, 'https://openvibe.chat', 'and Chat, where the Chats tab reads');
+ assert.strictEqual(dock.mounted.sibling('dock.js'), 'chrome-extension://abc/shared/dock.js', 'the bundled Dock, from this extension');
+ assert.strictEqual(doc.nodes.gate.hidden, true, 'the gate steps aside');
+ assert.ok(net1.requests.some((r) => r.url === '/api/v1/me/dock' && r.auth === 'Bearer ' + TOKEN), 'the Network is asked with that sign-in');
+ });
+
+ await check('without a sign-in the panel says what is missing', async () => {
+ const doc = fakeDoc();
+ const dock = fakeDock();
+ const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => null });
+ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => null } }, net, dock });
+ const out = await panel.start();
+ assert.strictEqual(out.mounted, false, 'no Dock without a sign-in');
+ assert.strictEqual(doc.nodes.gate.hidden, false, 'the gate is shown');
+ assert.match(doc.nodes['gate-title'].textContent, /OpenVibe\.Network/);
+ assert.strictEqual(doc.nodes['gate-button'].hidden, false, 'with the way to fix it');
+ });
+
+ await check('a sign-in the Network refuses is not used', async () => {
+ const doc = fakeDoc();
+ const dock = fakeDock();
+ const bad = 'wrong_token_' + 'y'.repeat(40);
+ const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => bad });
+ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => bad } }, net, dock });
+ const out = await panel.start();
+ assert.strictEqual(out.mounted, false, 'no Dock with a sign-in the Network refused');
+ assert.strictEqual(doc.nodes.gate.hidden, false, 'the gate says so');
+ assert.match(doc.nodes['gate-title'].textContent, /said no/);
+ });
+ } finally {
+ net1.close();
+ }
+
+ await check('the panel bundles the released Dock and loads nothing remote', async () => {
+ for (const f of ['shared/dock.js', 'shared/action-center.js', 'shared/notification-ui.js', 'shared/ov-mark.js', 'shared/kit.css']) {
+ assert.ok(fs.existsSync(path.join(DIR, f)), 'the panel ships ' + f);
+ }
+ const html = fs.readFileSync(path.join(DIR, 'panel.html'), 'utf8');
+ assert.ok(!/src="https?:/i.test(html), 'no remote script');
+ assert.ok(!/href="https?:/i.test(html), 'no remote stylesheet');
+ assert.match(html, /shared\/dock\.js/);
+ });
+
+ done();
+})();