openvibers/openvibe.extensions Commits
Companion 0.9.0: bundle OpenVibe.Shared 4.124.0, the Dock's agent view in the side panel (#14)
Changes
nnew file mode 100644@@@ -0,0 +1,44 @@++# Changelog++++The OpenVibe Companion's releases. Each one is a `browser/manifest.json` version and a [GitHub++release](https://github.com/OpenVibers/OpenVibe.Extensions/releases); older entries are in the git++history.++++## 0.9.0 — 2026-11-07++++- **The side panel's Dock is the current released one.** `browser/shared/` moves from OpenVibe.Shared++ 4.57.0 to 4.124.0: the Vibe tab, the timer, the realtime notification feed and the Agents tab's++ **agent view** — the person's desk stream, the task line and worked time, and the owner's++ **Pause/Resume**, **Curtain now**, **Mute/Unmute** and **End** controls. The new files are++ `dock-vibe.js`, `dock-agent.js`, `dock-timer.js` and `notification-live.js`; `dock.js`,++ `action-center.js`, `notification-ui.js`, `ov-mark.js` and `kit.css` are updated in place.++- **Manifest:** one host permission, `https://openvibe.codes/*`, for the agent view's API and its++ still. The CSP now allows that origin in `connect-src`, `frame-src https://openvibe.live` for the++ embed, `img-src 'self' blob:` for the still, and `style-src 'self' 'unsafe-inline'` because the++ Dock draws its own styles in a `<style>` element (scripts stay `'self'` only).++- `panel.js` passes OpenVibe.Codes' origin (`agentBase`) to the Dock; `lib/config.js` names it.++- `scripts/check-panel.js` and `test/panel.test.js` load the unpacked extension in headless Chrome++ and check the Agents tab draws the agent view with its four controls, its styles and no CSP++ refusal.++- Known, pending outside this repository: OpenVibe.Codes' session check refuses the Dock's++ `<network>/dock`-issued OAuth token, and OpenVibe.Live's `frame-ancestors` does not list an++ extension origin, so the live view stays hidden in production until one of them accepts it.++++## 0.8.1 — 2026-11-06++++- The side panel opens on the page's Dock state and follows it: `dock.open({ doc })` gets the tab,++ chat, agent and unread counts the page had, and the panel re-reads them every 30 s and when it++ comes back.++++## 0.8.0 — 2026-11-06++++- One-click Dock sign-in: the Network's OAuth public client `companion` (PKCE S256, no secret),++ read-only scopes `dock`, `activity` and `chat.read`; the access token in `storage.session`, the++ rotating refresh token in `storage.local`, and Sign out revokes the family. The 0.7.0 pasted++ token remains a fallback.++++## 0.7.0 — 2026-10-30++++- The OpenVibe Dock in the side panel (Chrome `side_panel`, Firefox `sidebar_action`) on every site:++ the released OpenVibe.Shared Dock (4.54.0, then 4.57.0), bundled because the CSP allows no remote++ script, reading and writing the same Network state as the Dock on a site.@@@ -2,9 +2,9 @@> OpenVibe's first-party client surfaces: today, the **OpenVibe Companion** browser extension.--**Status:** alpha (roadmap Wave 18, client part). The extension builds, its tests pass, and Chromium--loads the unpacked 0.3.0 build with no console or CSP errors (2026-10-15, Playwright's Chromium: branded Chrome no--longer loads unpacked extensions from the command line). A development build of++**Status:** alpha (roadmap Wave 18, client part). The extension builds, its tests pass, and headless++Chrome loads the unpacked 0.9.0 build with no console or CSP errors (2026-11-07, Chrome 156 via++CDP `Extensions.loadUnpacked`; the side panel's Agents tab draws the Dock's agent view against stubbed first parties, see the credential note below). A development build ofthe coupon part looked up codes and sent a report against a local OpenVibe.Coupons. Mozilla's`web-ext lint` (what addons.mozilla.org runs on upload) passes with no errors, warnings or noticesand runs in CI; it hasn't been loaded in a running Firefox yet. It is **not published** to any@@@ -75,13 +75,13 @@ the Companion never rewrites links or adds referral tags.| | ||---|---|--| **Permissions** | `activeTab`, `identity`, `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). `identity` starts the Dock's one-click OAuth sign-in (`chrome.identity.launchWebAuthFlow` / Firefox's `browser.identity`). `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`, `identity`, `scripting`, `storage`, and `sidePanel` in the Chrome build (Firefox's `sidebar_action` needs no permission of its own). The host permissions are four OpenVibe origins: `https://openvibe.coupons/*` (the coupon API), `https://openvibe.network/*` (the Dock's state), `https://openvibe.codes/*` (the Dock's agent view and its four controls) and `https://openvibe.chat/*` (the Dock's Chats tab). `identity` starts the Dock's one-click OAuth sign-in (`chrome.identity.launchWebAuthFlow` / Firefox's `browser.identity`). `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'`. |++| **Sent by the extension** | The Coupons part sends 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'`. The Dock sends the person's own bearer to `https://openvibe.network` (its state, `/api/v1/me/dock`), `https://openvibe.chat` (Chats), and, when the Agents tab needs it, `https://openvibe.codes` (the agent view and its controls). || **Hand-offs (only when you press the button)** | "Keep on my Rent board", "Check the rent", "Watch this page", "Compare delivery apps" and "Save its rating to OpenVibe Reviews" open **a new tab you see** on `https://openvibe.rent/board`, `https://openvibe.rent/fair`, `https://openvibe.watch/watches/new`, `https://openvibe.food/delivery` or `https://openvibe.reviews/save`, with the page's address and title (and, for Reviews, the rating read from it; for Rent, the price, city and bedrooms read from it) in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. || **Stored** | The optional `cpx_` install token in `storage.local` (never synced); for the Dock, the one-click OAuth access token in `storage.session` (memory, cleared when the browser closes) and the rotating refresh token in `storage.local` (this device, not synced). A pasted 0.7.0 `networkToken` is still accepted as a fallback until a later release removes it. |--| **CSP** | `default-src 'none'; script-src 'self'; connect-src https://openvibe.coupons; object-src 'none'`. There is no inline script, no remote code, no `eval`, and no `innerHTML`: the popup builds DOM nodes and sets text. |++| **CSP** | `default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; connect-src https://openvibe.coupons https://openvibe.network https://openvibe.chat https://openvibe.codes; frame-src https://openvibe.live; object-src 'none'; base-uri 'none'`. The Dock draws its own styles in a `<style>` element (styles only; script stays `'self'`), the agent view shows its still from a local `blob:`, and its Live embed is the only frame. There is no inline script, no remote code, no `eval`, and no `innerHTML` in the extension's own code: the popup builds DOM nodes and sets text. |### The credential: an install token, never a session@@@ -106,13 +106,21 @@ covered in Coupons' own tests: lookups are identical for every caller, and the A### 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++The **OpenVibe Dock** — the pill, the card with its Vibe, Agents, Chats and Activity tabs — sits in++the browser's side panel (Chrome's `side_panel`, Firefox's `sidebar_action`), on every site. It is the++released OpenVibe.Shared Dock 4.124.0, bundled under `browser/shared/` because the extension's CSP allows noremote script, and it reads and writes the same state on the Network as the Dock on a site, so itopens where the person left off. It hands that state to the Dock as it opens (the page's tab, chat, agent andunread counts) and re-reads it every 30 s, so a change on the page reaches the open side panel.++The Agents tab draws the person's own desk stream the same way a site's Dock does: the Live embed, the++task line, and the owner's **Pause/Resume**, **Curtain now**, **Mute/Unmute** and **End** controls. The++view is read with the person's token from OpenVibe.Codes (`GET /api/v1/me/agent-view`); the extension++allows Codes and Live's embed for exactly that. Codes' normal session check does not yet accept the++Dock's read-only OAuth token (issuer `<network>/dock`), so a production side panel keeps the view++hidden until Codes or the Network accepts that credential; `scripts/check-panel.js` proves the bundled++Dock itself draws it. Live's embed also allows first-party site origins, not an extension origin yet.++It needs the person's own OpenVibe sign-in. Press **Sign in** in the panel or the settings page: theNetwork's account chooser opens, the extension is a public OAuth client (`companion`, PKCE S256, nosecret), and the Dock gets a read-only token (`dock`, `activity`, `chat.read`) that refreshes itself.@@@ -128,10 +136,12 @@ browser/manifest.json MV3; Chromium (minimum_chrome_version 114) and Firefox (gecko id, strict_min_version 128)panel.html / panel.js the Dock in the side panel: the Network's state, the same tabs as the Dock on a sitepanel.css the Dock fills the panel (no floating card, no pill)-- shared/ the released OpenVibe.Shared Dock (v4.57.0), bundled: the CSP allows no remote script++ shared/ the released OpenVibe.Shared Dock 4.124.0, bundled: the CSP allows no remote script++ (dock.js, dock-vibe.js, dock-agent.js, dock-timer.js, action-center.js,++ notification-ui.js, notification-live.js, ov-mark.js, kit.css)popup.html / popup.js DOM wiring onlyoptions.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 (Coupons), NETWORK_ORIGIN and CHAT_ORIGIN (the Dock), CODES_ORIGIN (its agent view); HANDOFF_ORIGINS (Rent, Watch, Food, Reviews)lib/host.js URL → hostname (everything else dropped)lib/handoff.js the Rent board, Watch, Food delivery and Reviews addresses, built from a cleaned URL; which sites are rentals and review siteslib/autoapply.js "Try these codes on this page": injected into the active tab only when you press it@@@ -148,6 +158,7 @@ scripts/audit.js the permission audit (manifest, CSP, pages)pack.js zip for loading, or --stores: the Chrome and Firefox store zips (runs the audit first)check-autoapply.js lib/autoapply.js on test/fixtures/checkout-*.html in headless Chrome (CDP, no dependencies)++ check-panel.js the unpacked extension in headless Chrome: the panel's Agents tab draws the agent view (CDP, no dependencies)icons.js draws the icons (no dependencies)```@@@ -159,13 +170,14 @@ 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`, `lib/network-api.js` and `lib/auth.js` fetch. | `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, any style source beyond `'unsafe-inline'`, any image beyond `'self' blob:`, any frame beyond Live, MV2. The first-party origins agree across config, host permissions 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`, `lib/network-api.js`, `lib/auth.js` and the bundled Dock fetch. | `test/manifest.test.js` |++| The side panel in a real headless Chrome: the unpacked extension opens `panel.html` on the Agents tab with a stubbed Network/Chat/Codes, and the agent view draws its stream frame, line, meta and Pause/Resume, Curtain now, Mute/Unmute and End, with its styles applied and no CSP refusal. Skipped when no Chrome is installed. | `test/panel.test.js` (`scripts/check-panel.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 Coupons part 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, and only `cpx_` tokens are stored there. | `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` || `scripts/pack.js`: the zip is exactly `browser/`, audited and reproducible. A development build rewrites the origin in all three places. Remote `http` origins are refused. | `test/pack.test.js` |--Beyond the automated tests, this was checked by hand on 2026-09-22:++Beyond the automated tests, the coupon flow was checked by hand on 2026-09-22:- Headless Chrome 150, loaded through CDP `Extensions.loadUnpacked`, accepted the manifest andrendered both pages with no console errors or CSP violations.@@@ -173,7 +185,12 @@ Beyond the automated tests, this was checked by hand on 2026-09-22:configured. It resolved a shop, listed its code, and posted a report with a `cpx_` token. Thereport was stored with channel `extension`.--That check is not in CI. Firefox hasn't been tried.++On 2026-11-07 the same CDP load ran the side panel in headless Chrome 156 (now `test/panel.test.js`++and `scripts/check-panel.js`): with a stubbed Network, Chat and Codes, `panel.html` opened on the++Agents tab, drew the agent view's Live frame, task line and `Paused by @ada` meta, and showed++Pause/Resume, Curtain now, Mute/Unmute and End, with the Dock's styles applied and no CSP refusal.++The 2026-09-22 coupon flow is not in CI; the panel check is, and it skips when no Chrome is installed.++Firefox hasn't been tried.## Packing (never published from here)@@@ -198,7 +215,9 @@ shows "Allow access to openvibe.coupons".## Before a store release (for the lead)1. **OpenVibe.Coupons is already public** (see its README; live at `https://openvibe.coupons` since-- 2026-10-09). The release build only talks to `https://openvibe.coupons`.++ 2026-10-09). The release build talks to `https://openvibe.coupons` for codes and to++ `https://openvibe.network`, `https://openvibe.chat` and `https://openvibe.codes` for the Dock,++ each named in the manifest and the CSP and nothing else.2. **Store listings:** `npm run pack:stores` builds `dist/openvibe-companion-<version>-chrome.zip`(no `browser_specific_settings`) and `-firefox.zip` (no `minimum_chrome_version`), byte for bytereproducible. Upload them with the fields in [store/listing.md](store/listing.md), the single@@@ -223,6 +242,14 @@ shows "Allow access to openvibe.coupons".- `GET /api/v1/merchants/:id/coupons`- `POST /api/v1/coupons/:id/report`- `cpx_` install tokens from `/connect-extension`++- [OpenVibe.Codes](https://github.com/OpenVibers/OpenVibe.Codes) `/api/v1/me/agent-view` (the++ Dock's agent view: the state, the still, and the pause, curtain, mute and end controls). Its++ session check does not accept the Dock's `<network>/dock`-issued OAuth token yet, so the view is++ bundled and verified against a stub but stays hidden in production until Codes or Network accepts++ that credential.++- [OpenVibe.Live](https://github.com/OpenVibers/OpenVibe.Live) `/embed/agent/<ags_id>?vt=…`, the++ agent view's frame. Its `frame-ancestors` list is first-party site origins; an extension origin is++ not on it yet.- No npm dependencies. Tests and scripts use Node's standard library.---@@@ -5,17 +5,17 @@"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.8.1",++ "version": "0.9.0","surfaces": {"browser": {"name": "OpenVibe Companion",-- "version": "0.8.1",++ "version": "0.9.0","manifest": 3,"targets": ["chromium >= 114","firefox >= 128"],-- "api": "https://openvibe.coupons (OpenVibe.Coupons public API), https://openvibe.network (the Dock's state), https://openvibe.chat (the Dock's Chats tab)",++ "api": "https://openvibe.coupons (OpenVibe.Coupons public API), https://openvibe.network (the Dock's state), https://openvibe.chat (the Dock's Chats tab), https://openvibe.codes (the Dock's agent view and its four controls); the agent embed is framed from https://openvibe.live","permissions": ["activeTab","identity",@@@ -26,11 +26,12 @@"hostPermissions": ["https://openvibe.coupons/*","https://openvibe.network/*",++ "https://openvibe.codes/*","https://openvibe.chat/*"],"credential": "optional cpx_ install token (coupons.lookup, coupons.report); the Dock one-click OAuth public client (scopes dock, activity, chat.read; access in storage.session, rotating refresh in storage.local); the 0.7.0 pasted OpenVibe token is still accepted as a fallback until a later release","publishedToStores": false,-- "loadedInChrome": "yes: headless Chromium loads the build (popup and options render, no console errors); lib/autoapply.js passes three fixture checkout pages in headless Chrome; the one-click Dock sign-in is covered by test/auth.test.js",++ "loadedInChrome": "yes: headless Chrome 156 loads the 0.9.0 build via CDP Extensions.loadUnpacked; the side panel's Agents tab draws the Dock's agent view against a stubbed Network/Chat/Codes (the Live stream frame, the task line and meta, and Pause/Resume, Curtain now, Mute/Unmute and End) with its styles applied and no CSP refusal (scripts/check-panel.js); lib/autoapply.js passes three fixture checkout pages in headless Chrome; the one-click Dock sign-in is covered by test/auth.test.js","loadedInFirefox": "not yet loaded in a browser; Mozilla's web-ext lint (8.10.0, what AMO runs on upload) passes with 0 errors, 0 warnings and 0 notices, and runs in CI with warnings as errors","handoffs": ["https://openvibe.rent/board (keep a listing)",@@@ -43,19 +44,21 @@"storeListing": "store/ (listing, privacy and permission answers, screenshot plan); support URL https://openvibe.coupons/companion","features": ["OpenVibe Dock one-click sign-in: public OAuth client companion (PKCE S256, no secret), read-only scopes dock/activity/chat.read, access token in storage.session, rotating refresh token in storage.local, Sign out revokes the family, the account page lists it under Signed-in apps",-- "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock (v4.57.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), and re-reading it every 30 s so the page, the pop-out and the panel agree; approvals/sends are hidden with the read-only scopes"++ "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock 4.124.0 bundled under browser/shared/ (dock.js, dock-vibe.js, dock-agent.js, dock-timer.js, action-center.js, notification-ui.js, notification-live.js, ov-mark.js, kit.css), reading and writing the same Network state as the Dock on a site, with the same tabs (Vibe, Agents, Chats, Activity), and re-reading it every 30 s so the page, the pop-out and the panel agree; the Agents tab draws the person's agent view with Pause/Resume, Curtain now, Mute/Unmute and End, and approvals/sends are hidden with the read-only scopes"],"pending": ["owner: create the Chrome Web Store and AMO developer listings and upload the store zips with store/'s answers and screenshots; the Chrome id is fixed by browser/manifest.json key","Firefox: the extension loaded and run in a real Firefox (web-ext lint passes; nothing has run it yet)",-- "use: on 2026-10-22 OpenVibe.Coupons had issued no install token and received no code report; it held 169 codes for 16 of its 37 merchants, all from the DealNews feed through OpenVibe.Sources"++ "use: on 2026-10-22 OpenVibe.Coupons had issued no install token and received no code report; it held 169 codes for 16 of its 37 merchants, all from the DealNews feed through OpenVibe.Sources",++ "Codes/Live credential: the Dock's agent view now asks OpenVibe.Codes with the one-click OAuth token, but Codes' session check refuses the <network>/dock issuer, and Live's embed frame-ancestors allows first-party sites but not an extension origin; the view is bundled and verified against a stub, and needs one of those two to accept the extension before it shows in production"]}},"pending": ["owner: create the Chrome Web Store and AMO developer listings and upload the store zips with store/'s answers and screenshots; the Chrome id is fixed by browser/manifest.json key","Firefox: the extension loaded and run in a real Firefox (web-ext lint passes; nothing has run it yet)",-- "use: on 2026-10-22 OpenVibe.Coupons had issued no install token and received no code report; it held 169 codes for 16 of its 37 merchants, all from the DealNews feed through OpenVibe.Sources"++ "use: on 2026-10-22 OpenVibe.Coupons had issued no install token and received no code report; it held 169 codes for 16 of its 37 merchants, all from the DealNews feed through OpenVibe.Sources",++ "Codes/Live credential: the Dock's agent view now asks OpenVibe.Codes with the one-click OAuth token, but Codes' session check refuses the <network>/dock issuer, and Live's embed frame-ancestors allows first-party sites but not an extension origin; the view is bundled and verified against a stub, and needs one of those two to accept the extension before it shows in production"],"deployed": false,"features": [@@@ -64,10 +67,10 @@"watches a page on OpenVibe.Watch, and compares delivery apps on OpenVibe.Food","saves a review site's stated rating to OpenVibe.Reviews (read only when pressed, only on those sites)","an optional cpx_ install token, never an OpenVibe session; and the Dock one-click OAuth sign-in (scopes dock, activity, chat.read; access in storage.session, rotating refresh in storage.local; the 0.7.0 pasted token is still a fallback until a later release)",-- "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock (v4.57.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), and re-reading it every 30 s so the page, the pop-out and the panel agree; approvals/sends are hidden with the read-only scopes",++ "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock 4.124.0 bundled under browser/shared/ (dock.js, dock-vibe.js, dock-agent.js, dock-timer.js, action-center.js, notification-ui.js, notification-live.js, ov-mark.js, kit.css), reading and writing the same Network state as the Dock on a site, with the same tabs (Vibe, Agents, Chats, Activity), and re-reading it every 30 s so the page, the pop-out and the panel agree; the Agents tab draws the person's agent view with Pause/Resume, Curtain now, Mute/Unmute and End, and approvals/sends are hidden with the read-only scopes","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-11-06"++ "updated": "2026-11-07"}@@@ -1,9 +1,10 @@/** OpenVibe Companion — configuration.*-- * API_ORIGIN is the ONLY origin this extension ever sends a request to. It must match the manifest's-- * host_permissions and the connect-src of its CSP; scripts/pack.js rewrites all three together for-- * a development build (--api http://localhost:4850) and test/manifest.test.js checks they agree.++ * API_ORIGIN is where the Coupons API lives. It must match the manifest's host_permissions and the++ * connect-src of its CSP; scripts/pack.js rewrites all three together for a development build++ * (--api http://localhost:4850) and test/manifest.test.js checks they agree. NETWORK_ORIGIN,++ * CHAT_ORIGIN and CODES_ORIGIN are the Dock's: its state, its Chats tab and its agent view.** HANDOFF_ORIGINS are the sites the Companion may open a tab on when you press one of its buttons* (lib/handoff.js): the Rent board, a new Watch, Food's delivery comparison, and Reviews' page for saving the rating@@@ -31,6 +32,9 @@// origins the side panel may reach besides the API origin.NETWORK_ORIGIN: 'https://openvibe.network',CHAT_ORIGIN: 'https://openvibe.chat',++ // CODES_ORIGIN is OpenVibe.Codes' API: the Dock's agent view (GET /api/v1/me/agent-view and the++ // four controls), and the still Codes serves when nothing is live.++ CODES_ORIGIN: 'https://openvibe.codes',// One-click Dock sign-in (OAuth public client, no secret). The redirect URI is the browser's own// extension URL; the exact values are also in the Network client row. A build whose runtime// identity.getRedirectURL() is not one of these refuses to sign in rather than send an@@@ -2,7 +2,7 @@"manifest_version": 3,"name": "OpenVibe Companion","short_name": "OpenVibe",-- "version": "0.8.1",++ "version": "0.9.0","key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqVJohdlaysL2HgSsj2Fsd8PxPvXJkZHjEvNyGSYNSEpZBoIeZd7Cm7xznGVikDBbe7WseaNLt+HheX4WhN94jU/FrS2016AqQG9NTHoymJR8XSrPPDyhc85EUpICOFwqfFsSOykzaRyHZVcrvoepYwntwFjWdt1ul4VozwkqKovZoKUgCn398aNvZ6AH/J0xb0xWMiOiL9CvUWN/jK4wv0ShnJevpmyBBHL273CkQvfMS3VUBvBjq3seMU8BAHkE5RI3c2y/MRdjwYGsCoMU6MPYwt46PQZ0S8SPnFFozJndkpoqc/ue9oXhd77b12GKlJ1BPhHgn8BSx4IAvjeL1wIDAQAB","description": "Coupon codes for the shop you are on, tried in its coupon box when you ask; the same restaurant on every delivery app; a review site's rating saved to OpenVibe.Reviews; 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",@@@ -38,10 +38,11 @@"host_permissions": ["https://openvibe.coupons/*","https://openvibe.network/*",++ "https://openvibe.codes/*","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 https://openvibe.network https://openvibe.chat; object-src 'none'; base-uri 'none'"++ "extension_pages": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; connect-src https://openvibe.coupons https://openvibe.network https://openvibe.chat https://openvibe.codes; frame-src https://openvibe.live; object-src 'none'; base-uri 'none'"},"minimum_chrome_version": "114","browser_specific_settings": {@@@ -5,7 +5,9 @@* 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.++ * same tabs (Vibe, Agents, Chats, Activity) and the same place. The Agents tab's agent view reads++ * the person's desk stream from OpenVibe.Codes (the extension's `agentBase`) and frames its embed++ * from OpenVibe.Live.** The sign-in is the Network's OAuth public client (lib/auth.js): one press, PKCE, a scoped access* token in storage.session and a rotating refresh token in storage.local. The 0.7.0 pasted@@@ -70,6 +72,7 @@me: account && account.username ? account.username : '',apiBase: opts.config.NETWORK_ORIGIN,chatBase: opts.config.CHAT_ORIGIN,++ agentBase: opts.config.CODES_ORIGIN,sibling: opts.sibling,});// Hand the state we just read to the Dock as it opens, so its own first read cannot race it back to the@@@ -96,8 +96,8 @@.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 button.spin svg { animation: ovac-pulse 1s ease-in-out infinite; }++ @keyframes ovac-pulse { 0%, 100% { opacity: .35; } 50% { opacity: 1; } }.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; }@@@ -137,7 +137,10 @@.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-wait span { position: relative; display: inline-block; width: 6px; height: 6px; margin: 0 10px; border: 0; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovac-hop 1s ease-in-out .15s infinite; }++ .ovac-wait span::before, .ovac-wait span::after { content: ''; position: absolute; top: 0; width: 6px; height: 6px; border-radius: 50%; background: inherit; animation: ovac-hop 1s ease-in-out infinite; }++ .ovac-wait span::before { left: -10px; } .ovac-wait span::after { left: 10px; animation-delay: .3s; }++ @keyframes ovac-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-4px); opacity: 1; } }.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) {nnew file mode 100644@@@ -0,0 +1,268 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — the Dock's agent view (Dock plan 2026-10-30, K-D3; Agent Streams S1), Shared 4.120.0++//++// The person's own agent desk stream at the top of the Dock's Agents tab. dock.js loads this file the first time that++// tab opens:++// OpenVibeDockAgent.mount(container, { token, base, active }) → { refresh(), destroy() }++//++// `base` is OpenVibe.Codes' API (GET /api/v1/me/agent-view, the same POST for pause, Curtain now, mute and End, and++// the frame). Agent Streams S5 adds the four controls: a moderator cannot resume the owner's pause on Live, and the++// Dock is the owner's own view, so it may always resume.++// Codes mints the view ticket server-side with the person's own token — an iframe on another site has no Live cookie —++// so the frame it answers is already Live's /embed/agent/<ags_id>?vt=wvt_…: the picture, the "AI agent" label, the++// curtain state and the viewer count. The embed renews its own ticket while it plays.++//++// Nothing live: the last frame through the same API (an <img> cannot carry a token), with the task and the worked time.++// There is no step line yet: steps are Agent Streams S2, and this view shows none until Live serves them.++// Everything is set as text, never markup; the frame's address is the one Codes answered, as an https Live address.++// ═══════════════════════════════════════════════════════════════++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OpenVibeDockAgent) return;++++ const POLL_MS = 20000;++ const EMBED = /^https:\/\/openvibe\.live\/embed\/agent\/ags_[0-9A-HJKMNP-TV-Z]{26}\?vt=wvt_[A-Za-z0-9_-]+$/;++ const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;++ const G = {++ eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="3"/><path d="M4 4l16 16"/>',++ pause: '<rect x="7" y="5" width="3.5" height="14" rx="1"/><rect x="13.5" y="5" width="3.5" height="14" rx="1"/>',++ play: '<path d="M8 5.5v13l10-6.5z"/>',++ mute: '<path d="M4 9.5h4L13 5v14l-5-4.5H4z"/><path d="M17 9l4 4m0-4l-4 4"/>',++ sound: '<path d="M4 9.5h4L13 5v14l-5-4.5H4z"/><path d="M17 9.5a4 4 0 0 1 0 5"/>',++ stop: '<rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/>',++ open: '<path d="M14 4h6v6M20 4l-9 9M19 14v5a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 4 19V7a1.5 1.5 0 0 1 1.5-1.5H10"/>',++ };++++ // The style goes into the document the view lives in: the page, or the Dock's own window when it is popped out.++ function styles(doc) {++ const d = doc || (typeof document !== 'undefined' ? document : null);++ if (!d || d.getElementById('ovda-styles')) return;++ const st = d.createElement('style');++ st.id = 'ovda-styles';++ st.textContent = `++ .ovda { display: none; flex: none; padding: 8px 8px 2px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }++ .ovda.on { display: block; }++ .ovda-stage { position: relative; overflow: hidden; aspect-ratio: 16 / 9; border-radius: 14px; background: #0a0c11; border: 1px solid var(--border, rgba(255,255,255,.1)); }++ .ovda-frame, .ovda-still { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; object-fit: contain; background: #0a0c11; }++ .ovda-still { object-fit: cover; opacity: .92; }++ .ovda-wait { position: absolute; inset: 0; display: grid; place-items: center; color: var(--text-muted, #8b8b96); font-size: 12.5px; text-align: center; padding: 12px; }++ .ovda-line { display: flex; align-items: center; gap: 6px; min-height: 34px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }++ .ovda-line b { color: var(--text-primary, #ececf1); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++ .ovda-line b a { color: inherit; text-decoration: none; }++ .ovda-line b a:hover { text-decoration: underline; }++ .ovda-line .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); flex: none; }++ .ovda-line .dot.live { background: var(--live-red, #e5484d); animation: ovda-pulse 1.6s ease-in-out infinite; }++ .ovda-line .dot.off { background: var(--text-muted, #8b8b96); }++ .ovda-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 11.5px; color: var(--text-muted, #8b8b96); }++ .ovda-acts { display: flex; gap: 6px; margin-left: auto; }++ .ovda-acts button, .ovda-acts a { display: inline-flex; align-items: center; gap: 5px; min-height: 30px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12));++ background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; text-decoration: none; }++ .ovda-acts button:hover, .ovda-acts a:hover { color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); }++ .ovda-acts button:disabled { opacity: .5; cursor: default; }++ .ovda-acts svg { width: 14px; height: 14px; }++ .ovda-acts button[data-act="end"] { color: color-mix(in srgb, var(--live-red, #e5484d) 65%, var(--text-primary, #ececf1)); }++ .ovda svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }++ @keyframes ovda-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }++ @media (prefers-reduced-motion: reduce) { .ovda-line .dot.live { animation: none; } }++ `;++ (d.head || d.documentElement).appendChild(st);++ }++++ const mins = (s) => {++ const m = Math.max(0, Math.round((Number(s) || 0) / 60));++ return m < 1 ? 'under a minute' : m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;++ };++++ function mount(container, opts = {}) {++ const d = container.ownerDocument || document;++ styles(d);++ const o = { ...opts };++ const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };++ let gone = false, timer = 0, busy = false, agent = null, frameUrl = null, lastFrame = '';++ container.innerHTML = '';++ container.classList.add('ovda');++ const stage = h('div', 'ovda-stage');++ const line = h('div', 'ovda-line');++ const meta = h('div', 'ovda-meta');++ const acts = h('div', 'ovda-acts');++ const head = h('div');++ head.append(line, meta, acts);++ const body = h('div');++ body.append(stage, head);++ container.append(body);++++ async function api(path, init = {}) {++ const base = String(o.base || 'https://openvibe.codes').replace(/\/+$/, '');++ const t = typeof o.token === 'function' ? await o.token() : o.token;++ const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };++ const r = await fetch(base + path, { ...init, headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });++ const b = await r.json().catch(() => ({}));++ if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status });++ return b;++ }++++ /** The still is behind the person's token, so it is read here and shown as a local object URL. */++ function still(url) {++ if (!url || frameUrl === url) return;++ frameUrl = url;++ Promise.resolve(typeof o.token === 'function' ? o.token() : o.token).then((t) => fetch(url, { headers: t ? { authorization: `Bearer ${t}` } : {}, credentials: 'omit' }))++ .then((r) => (r.ok ? r.blob() : null))++ .then((blob) => {++ if (!blob || gone || frameUrl !== url) return;++ const next = URL.createObjectURL(blob);++ const img = h('img', 'ovda-still');++ img.alt = 'The last frame of your agent';++ img.src = next;++ img.decoding = 'async';++ const old = stage.querySelector('img.ovda-still');++ if (old) { stage.removeChild(old); }++ stage.append(img);++ if (lastFrame) URL.revokeObjectURL(lastFrame);++ lastFrame = next;++ })++ .catch(() => { /* no still: the line says enough */ });++ }++++ function paint() {++ if (gone) return;++ const has = !!agent;++ container.classList.toggle('on', has);++ if (!has) { stage.innerHTML = ''; line.innerHTML = ''; meta.textContent = ''; acts.innerHTML = ''; return; }++++ const live = agent.state === 'live';++ const ended = agent.state === 'ended';++ // The picture: the live embed when there is one, else the still.++ const embed = typeof agent.embed_url === 'string' && EMBED.test(agent.embed_url) ? agent.embed_url : null;++ const shown = stage.querySelector('iframe');++ if (embed && (!shown || shown.getAttribute('src') !== embed)) {++ stage.innerHTML = '';++ const f = h('iframe', 'ovda-frame');++ f.src = embed;++ f.title = 'Your agent\'s screen';++ f.setAttribute('allow', 'autoplay; fullscreen; picture-in-picture');++ f.setAttribute('referrerpolicy', 'no-referrer');++ stage.append(f);++ } else if (!embed) {++ if (shown) stage.innerHTML = '';++ if (agent.last_frame_url) still(agent.last_frame_url);++ else if (!stage.querySelector('img')) stage.append(h('div', 'ovda-wait', ended ? 'Nothing to show' : 'Waiting for the desk'));++ }++++ // The line: what it is doing, one row.++ line.innerHTML = '';++ line.append(h('span', `dot ${live ? 'live' : 'off'}`));++ const title = h('b');++ const url = agent.task && /^https:\/\//.test(String(agent.task.url || '')) ? agent.task.url : (/^https:\/\//.test(String(agent.page_url || '')) ? agent.page_url : null);++ const text = (agent.task && agent.task.title) || agent.title || 'Your agent';++ if (url) { const a = h('a', null, text); a.href = url; a.rel = 'noopener'; a.target = '_blank'; title.append(a); }++ else title.textContent = text;++ line.append(title);++++ // The meta: the state, the viewers, the curtain, the worked time.++ meta.textContent = '';++ const bits = [live ? 'Live' : ended ? 'Ended' : 'Waiting for the desk'];++ if (live && agent.viewers > 0) bits.push(`${agent.viewers} watching`);++ if (agent.paused) bits.push(agent.paused_by ? `Paused by @${agent.paused_by}` : 'Paused');++ if (live && agent.curtain && agent.curtain.up) bits.push('Private screen');++ bits.push(`worked ${mins(agent.worked_seconds)}`);++ meta.textContent = bits.join(' · ');++++ // S5: the four controls, for the owner (the Dock is their own view, so resume is always theirs).++ acts.innerHTML = '';++ if (!ended) {++ const pause = h('button', null);++ pause.type = 'button';++ pause.dataset.act = 'pause';++ pause.innerHTML = `${svg(agent.paused ? G.play : G.pause)}${agent.paused ? 'Resume' : 'Pause'}`;++ pause.title = agent.paused && agent.paused_by ? `Paused by @${agent.paused_by}` : 'Pause the agent at the next safe point';++ acts.append(pause);++ }++ if (live && !(agent.curtain && agent.curtain.up)) {++ const b = h('button', null);++ b.type = 'button';++ b.dataset.act = 'curtain';++ b.innerHTML = `${svg(G.eye)}Curtain now`;++ b.title = 'Hide the screen now; your desk lifts it when it is clear';++ acts.append(b);++ }++ if (!ended) {++ const mute = h('button', null);++ mute.type = 'button';++ mute.dataset.act = 'mute';++ mute.innerHTML = `${svg(agent.voice_muted ? G.sound : G.mute)}${agent.voice_muted ? 'Unmute' : 'Mute'}`;++ mute.title = agent.voice_muted ? 'Let the agent speak again' : 'Mute the agent\'s voice';++ acts.append(mute);++ }++ if (!ended) {++ const b = h('button', null);++ b.type = 'button';++ b.dataset.act = 'end';++ b.innerHTML = `${svg(G.stop)}End`;++ acts.append(b);++ }++ if (agent.page_url && /^https:\/\/openvibe\.live\//.test(agent.page_url)) {++ const a = h('a');++ a.href = agent.page_url;++ a.target = '_blank';++ a.rel = 'noopener';++ a.setAttribute('aria-label', 'Open the stream on OpenVibe.Live');++ a.title = 'Open the stream on OpenVibe.Live';++ a.innerHTML = svg(G.open);++ acts.append(a);++ }++ }++++ async function refresh() {++ if (gone || busy) return;++ if (typeof o.active === 'function' && !o.active()) { schedule(); return; }++ busy = true;++ clearTimeout(timer);++ try {++ const doc = await api('/api/v1/me/agent-view');++ if (gone) return;++ agent = doc && doc.agent && typeof doc.agent === 'object' ? doc.agent : null;++ paint();++ } catch (e) {++ // Codes away, or Live away: no stream, and the rest of the tab is untouched.++ if (!gone) { agent = null; paint(); }++ } finally {++ busy = false;++ schedule();++ }++ }++ function schedule() {++ clearTimeout(timer);++ if (!gone && agent && agent.state !== 'ended') timer = setTimeout(refresh, POLL_MS);++ }++++ async function control(what, btn, body = {}) {++ if (!btn || btn.disabled) return;++ btn.disabled = true;++ try {++ const doc = await api(`/api/v1/me/agent-view/${what}`, { method: 'POST', json: true, body });++ agent = doc && doc.agent ? doc.agent : agent;++ if (what === 'end') frameUrl = '';++ paint();++ } catch (e) {++ btn.disabled = false;++ btn.title = e.message || 'That did not work. Try again.';++ return;++ }++ if (agent && agent.state !== 'ended') schedule();++ }++++ acts.addEventListener('click', (e) => {++ const b = e.target.closest('button[data-act]');++ if (!b) return;++ if (b.dataset.act === 'pause') control('pause', b, { paused: !(agent && agent.paused) });++ else if (b.dataset.act === 'curtain') control('curtain', b);++ else if (b.dataset.act === 'mute') control('mute', b, { muted: !(agent && agent.voice_muted) });++ else if (b.dataset.act === 'end') control('end', b);++ });++++ refresh();++ return { refresh, destroy() { gone = true; clearTimeout(timer); if (lastFrame) URL.revokeObjectURL(lastFrame); container.innerHTML = ''; container.classList.remove('ovda'); } };++ }++++ root.OpenVibeDockAgent = { mount };++})(typeof window !== 'undefined' ? window : globalThis);nnew file mode 100644@@@ -0,0 +1,186 @@++/**++ * openvibe-shared/dock-timer.js: the Dock's timer (plan L48 R2; the owner, 11-01: "a reminder system… timer or however++ * often they want"). dock.js loads it when the person's Dock state carries a timer, or when they open the timer menu.++ *++ * OpenVibeDockTimer.attach({ state, save, host, icon }) → { sync(), pillText(), menu(anchor), stop() }++ *++ * The timer lives in the Network's Dock state (network.dock-state@1 `timer`, contracts 0.271.0), so it counts down on++ * every OpenVibe site the person has open and follows them to the next. Each page counts from the same end time; the++ * page that sees the end rings (a soft chime, only on a page that is showing, and a notification when the person++ * allowed them) and moves a focus timer on to its break or its next round. Every page computes that next phase the same++ * way from the same end time, so two pages writing it at once write the same thing. Nothing spins: the pill shows the++ * minutes, and a line fills as the time goes.++ */++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OpenVibeDockTimer) return;++++ const PRESETS = [[5, 'timer'], [10, 'timer'], [25, 'timer']];++ const CSS = `++.ovdk-timer { flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 10px; padding: 10px 12px 12px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }++.ovdk-timer-t { min-width: 0; display: grid; gap: 1px; }++.ovdk-timer-t b { font-size: 22px; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: .01em; line-height: 1.1; }++.ovdk-timer-t span { color: var(--text-muted, #8b8b96); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovdk-timer-acts { display: flex; gap: 6px; }++.ovdk-timer-acts button, .ovdk-tmenu button { min-height: 32px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; }++.ovdk-timer-acts button:hover, .ovdk-tmenu button:hover { border-color: var(--accent, #3b82f6); }++.ovdk-timer-bar { grid-column: 1 / -1; height: 4px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #ececf1) 9%, transparent); overflow: hidden; }++.ovdk-timer-bar i { display: block; height: 100%; width: 0; border-radius: inherit; background: var(--accent, #3b82f6); transition: width 1s linear; }++.ovdk-timer[data-kind="break"] .ovdk-timer-bar i { background: #22c55e; }++.ovdk-timer.is-done b { color: var(--accent, #3b82f6); }++.ovdk-tmenu { position: absolute; z-index: 5; right: 8px; top: 52px; width: min(300px, calc(100% - 16px)); display: grid; gap: 10px; padding: 12px; border-radius: 14px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-card, var(--bg-secondary, #16161d)); box-shadow: 0 16px 40px rgba(0,0,0,.4); }++.ovdk-tmenu p { margin: 0; color: var(--text-muted, #8b8b96); font-size: 11px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }++.ovdk-tmenu .row { display: flex; flex-wrap: wrap; gap: 6px; }++.ovdk-tmenu .own { display: grid; grid-template-columns: 70px minmax(0, 1fr) auto; gap: 6px; }++.ovdk-tmenu input { min-height: 32px; min-width: 0; box-sizing: border-box; padding: 0 10px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: inherit; font: inherit; font-size: 13px; }++.ovdk-tmenu .go { background: var(--accent, #3b82f6); border-color: transparent; color: var(--on-accent, #fff); }++.ovdk-pill[data-tone="timer"] .ovdk-t { font-variant-numeric: tabular-nums; }++.ovdk-pill[data-tone="timer-done"] { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); }++@media (prefers-reduced-motion: reduce) { .ovdk-timer-bar i { transition: none; } }++`;++ const pad = (n) => String(n).padStart(2, '0');++ const left = (ms) => {++ const s = Math.max(0, Math.ceil(ms / 1000));++ const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60;++ return h ? `${h}:${pad(m)}:${pad(r)}` : `${m}:${pad(r)}`;++ };++ const iso = (ms) => new Date(ms).toISOString();++++ /** What comes after a timer that has ended: the break after a focus round, the next round, or nothing. */++ function nextOf(t) {++ if (!t || !t.rounds || t.kind === 'timer') return null;++ const end = Date.parse(t.ends_at);++ const round = t.round || 1;++ if (t.kind === 'focus' && round < t.rounds) return { ...t, kind: 'break', started_at: iso(end), ends_at: iso(end + (t.break_min || 5) * 60000) };++ if (t.kind === 'break') return { ...t, kind: 'focus', round: round + 1, started_at: iso(end), ends_at: iso(end + (t.focus_min || 25) * 60000) };++ return null;++ }++ const titleOf = (t) => (t.kind === 'break' ? 'Break' : t.kind === 'focus' ? `Focus${t.rounds > 1 ? ` ${t.round || 1} of ${t.rounds}` : ''}` : 'Timer');++++ function chime() {++ try {++ const A = root.AudioContext || root.webkitAudioContext;++ if (!A) return;++ const ctx = new A();++ [[660, 0], [880, 0.22], [990, 0.44]].forEach(([f, at]) => {++ const o = ctx.createOscillator(), g = ctx.createGain();++ o.type = 'sine'; o.frequency.value = f;++ g.gain.setValueAtTime(0.0001, ctx.currentTime + at);++ g.gain.exponentialRampToValueAtTime(0.18, ctx.currentTime + at + 0.02);++ g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + at + 0.5);++ o.connect(g).connect(ctx.destination);++ o.start(ctx.currentTime + at); o.stop(ctx.currentTime + at + 0.55);++ });++ setTimeout(() => { try { ctx.close(); } catch { /* */ } }, 1500);++ } catch { /* no sound here */ }++ }++++ function attach(c) {++ const ctx = c || {};++ let tick = 0, rang = '', doneUntil = 0, doneLabel = '';++ const now = () => Date.now();++ const timer = () => { const t = (ctx.state() || {}).timer; return t && Date.parse(t.ends_at) ? t : null; };++ const style = () => {++ const st = ctx.host && ctx.host() && ctx.host().ownerDocument.getElementById('ovdk-styles') || document.getElementById('ovdk-styles');++ if (st && !st.textContent.includes('.ovdk-timer {')) st.textContent += CSS;++ };++++ function ended(t) {++ if (rang === t.ends_at) return;++ rang = t.ends_at;++ const next = nextOf(t);++ const said = next ? (next.kind === 'break' ? `Focus done: a ${next.break_min || 5}-minute break` : `Break over: focus ${next.round} of ${next.rounds}`) : `${t.label || titleOf(t)}: time's up`;++ if (!document.hidden) chime();++ try { if (root.Notification && Notification.permission === 'granted' && !document.hidden) new Notification('OpenVibe', { body: said, tag: `ov-timer-${t.ends_at}` }); } catch { /* */ }++ doneUntil = now() + 60000; doneLabel = said;++ ctx.save({ timer: next });++ }++++ function paintStrip() {++ const host = ctx.host && ctx.host();++ if (!host) return;++ const body = host.querySelector('.ovdk-body');++ let strip = host.querySelector('.ovdk-timer');++ const t = timer();++ const done = !t && now() < doneUntil;++ if (!t && !done) { if (strip) strip.remove(); return; }++ if (!strip) {++ strip = document.createElement('div'); strip.className = 'ovdk-timer'; strip.setAttribute('role', 'timer'); strip.setAttribute('aria-live', 'off');++ strip.innerHTML = '<div class="ovdk-timer-t"><b></b><span></span></div><div class="ovdk-timer-acts"><button type="button" data-t="more">+5 min</button><button type="button" data-t="stop">Stop</button></div><div class="ovdk-timer-bar"><i></i></div>';++ strip.addEventListener('click', (e) => {++ const b = e.target.closest('[data-t]'); if (!b) return;++ const cur = timer();++ if (b.dataset.t === 'stop') { doneUntil = 0; ctx.save({ timer: null }); }++ if (b.dataset.t === 'more' && cur) ctx.save({ timer: { ...cur, ends_at: iso(Math.max(Date.parse(cur.ends_at), now()) + 5 * 60000) } });++ if (b.dataset.t === 'ok') { doneUntil = 0; }++ paintStrip();++ });++ if (body) body.parentNode.insertBefore(strip, body); else host.appendChild(strip);++ }++ strip.classList.toggle('is-done', done);++ strip.dataset.kind = t ? t.kind : 'done';++ strip.querySelector('b').textContent = t ? left(Date.parse(t.ends_at) - now()) : 'Done';++ strip.querySelector('span').textContent = t ? `${titleOf(t)}${t.label ? ` · ${t.label}` : ''}` : doneLabel;++ const total = t ? Date.parse(t.ends_at) - Date.parse(t.started_at) : 1;++ strip.querySelector('.ovdk-timer-bar i').style.width = t ? `${Math.min(100, Math.max(0, 100 * (now() - Date.parse(t.started_at)) / Math.max(1, total)))}%` : '100%';++ const acts = strip.querySelector('.ovdk-timer-acts');++ acts.innerHTML = t ? '<button type="button" data-t="more">+5 min</button><button type="button" data-t="stop">Stop</button>' : '<button type="button" data-t="ok">OK</button>';++ }++++ function step() {++ const t = timer();++ if (t && now() >= Date.parse(t.ends_at)) ended(t);++ paintStrip();++ if (ctx.repaint) ctx.repaint();++ if (!timer() && now() >= doneUntil) { clearInterval(tick); tick = 0; }++ }++ function sync() {++ style();++ if ((timer() || now() < doneUntil) && !tick) tick = setInterval(step, 1000);++ step();++ }++ /** The pill's words while a timer runs (null when there is none): minutes left, or what just ended. */++ function pillText() {++ const t = timer();++ if (t) return { text: left(Date.parse(t.ends_at) - now()), tone: 'timer', label: `${titleOf(t)}${t.label ? `, ${t.label}` : ''}: ${left(Date.parse(t.ends_at) - now())} left` };++ if (now() < doneUntil) return { text: "Time's up", tone: 'timer-done', label: doneLabel };++ return null;++ }++ function start(minutes, kind, label, rounds) {++ const m = Math.max(1, Math.min(180, Math.round(Number(minutes) || 0)));++ const at = now();++ const t = { kind, started_at: iso(at), ends_at: iso(at + m * 60000), ...(label ? { label: String(label).slice(0, 60) } : {}) };++ if (kind === 'focus') Object.assign(t, { round: 1, rounds: rounds || 4, focus_min: m, break_min: 5 });++ rang = ''; doneUntil = 0;++ // Asked once, at the moment the person starts one (a click): may the end show as a notification?++ try { if (root.Notification && Notification.permission === 'default') Notification.requestPermission().catch(() => {}); } catch { /* */ }++ ctx.save({ timer: t });++ sync();++ }++ function menu(anchor) {++ style();++ const host = ctx.host && ctx.host();++ if (!host) return;++ let m = host.querySelector('.ovdk-tmenu');++ if (m) { m.remove(); return; }++ m = document.createElement('div'); m.className = 'ovdk-tmenu'; m.setAttribute('role', 'dialog'); m.setAttribute('aria-label', 'Start a timer');++ m.innerHTML = `<p>Timer</p><div class="row">${PRESETS.map(([n]) => `<button type="button" data-min="${n}">${n} min</button>`).join('')}</div>++<p>Focus</p><div class="row"><button type="button" data-focus="25">25 min, 5 min break, ×4</button><button type="button" data-focus="50">50 / 5, ×2</button></div>++<p>Your own</p><div class="own"><input type="number" min="1" max="180" value="15" aria-label="Minutes" data-own-min><input type="text" maxlength="60" placeholder="What for (optional)" aria-label="What it is for" data-own-label><button type="button" class="go" data-own>Start</button></div>`;++ m.addEventListener('click', (e) => {++ const b = e.target.closest('button'); if (!b) return;++ if (b.dataset.min) start(b.dataset.min, 'timer');++ else if (b.dataset.focus) start(b.dataset.focus, 'focus', '', b.dataset.focus === '50' ? 2 : 4);++ else if (b.hasAttribute('data-own')) start(m.querySelector('[data-own-min]').value, 'timer', m.querySelector('[data-own-label]').value.trim());++ else return;++ m.remove();++ });++ m.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); m.remove(); if (anchor) anchor.focus(); } });++ host.appendChild(m);++ const first = m.querySelector('button'); if (first) first.focus();++ }++ return { sync, pillText, menu, start, stop: () => { clearInterval(tick); tick = 0; } };++ }++++ root.OpenVibeDockTimer = { attach, nextOf };++})(typeof window !== 'undefined' ? window : globalThis);nnew file mode 100644@@@ -0,0 +1,288 @@++/**++ * openvibe-shared/dock-vibe.js: the Dock's Vibe tab (plan L48 V2), loaded by dock.js the first time the tab opens.++ * OpenVibeDockVibe.mount(panel, { chatCall, active, styles, icons }) → { refresh(), pause(), talk() }++ * chatCall is the Dock's call to OpenVibe.Chat with the person's Network token; active() says whether the Vibe tab is++ * the one showing; styles() is the Dock's own <style> (this file adds its rules there, so a popped-out Dock has them).++ */++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OpenVibeDockVibe) return;++ const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;++ const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };++ const MIC = '<rect x="9" y="3.5" width="6" height="10.5" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5v3"/>';++ const CSS = `++ .ovdk-vibe-h .ovdk-i { color: var(--accent, #3b82f6); margin-left: 6px; }++ .ovdk-away { margin-left: auto; font-size: 12.5px; font-weight: 650; color: var(--accent, #3b82f6); text-decoration: none; white-space: nowrap; }++ .ovdk-away:hover { text-decoration: underline; }++ .ovdk-msg.is-vibe .ovdk-text a { color: var(--accent, #3b82f6); text-decoration: underline; word-break: break-all; }++ .ovdk-typing .ovdk-text { display: inline-flex; gap: 4px; align-items: center; padding: 10px 12px; }++ .ovdk-typing i { width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted, #8b8b96); animation: ovdk-hop 1s ease-in-out infinite; }++ .ovdk-typing i:nth-child(2) { animation-delay: .15s; } .ovdk-typing i:nth-child(3) { animation-delay: .3s; }++ .ovdk-vibe-start { list-style: none; display: grid; gap: 6px; padding: 18px 6px 8px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }++ .ovdk-vibe-start b { color: var(--text-primary, #ececf1); font-size: 15.5px; }++ .ovdk-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin-top: 8px; }++ .ovdk-chip { padding: 7px 12px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; cursor: pointer; }++ .ovdk-chip:hover { border-color: var(--accent, #3b82f6); }++ .ovdk-mic { width: 40px; height: 40px; flex: none; margin-left: -8px; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center; background: transparent; color: var(--text-muted, #8b8b96); touch-action: none; }++ .ovdk-mic:hover { color: var(--text-primary, #ececf1); }++ .ovdk-mic.is-on { color: var(--on-accent, #fff); background: var(--live-red, #e5484d); box-shadow: 0 0 0 4px color-mix(in srgb, var(--live-red, #e5484d) 28%, transparent); }++ .ovdk-res { display: grid; gap: 6px; margin-top: 6px; max-width: 100%; }++ .ovdk-res-c { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 4px 10px; padding: 8px 10px; border-radius: 12px;++ border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 35%, var(--border, rgba(255,255,255,.12))); background: color-mix(in srgb, var(--accent, #3b82f6) 8%, transparent); }++ .ovdk-res-c .ovdk-i { color: var(--accent, #3b82f6); }++ .ovdk-res-t { min-width: 0; font-size: 13px; line-height: 1.35; color: var(--text-primary, #ececf1); }++ .ovdk-res-t b { font-weight: 750; }++ .ovdk-res-t small { display: block; color: var(--text-muted, #8b8b96); font-size: 12px; }++ .ovdk-res-a { display: flex; gap: 4px; }++ .ovdk-res-a button, .ovdk-res-a a { min-height: 30px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent;++ color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; }++ .ovdk-res-a button:hover, .ovdk-res-a a:hover { border-color: var(--accent, #3b82f6); }++ .ovdk-res-a button:disabled { opacity: .55; cursor: default; }++ .ovdk-res-c.is-stopped { border-color: var(--border, rgba(255,255,255,.12)); background: transparent; }++ .ovdk-res-c.is-stopped .ovdk-i, .ovdk-res-c.is-stopped .ovdk-res-t { color: var(--text-muted, #8b8b96); }++ @media (prefers-reduced-motion: reduce) { .ovdk-typing i { animation: none; } }++`;++ let ctx = null, G = {};++ const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };++++ // ── Vibe (plan L48 V2; the owner, 11-01: "access your agent or the AI… from across all websites… use a voice++ // feature to tell it to set a reminder… have a conversation with your Vibe AI") ──++ // The same conversation as openvibe.chat/vibe, through Chat's /api/vibe with the person's Network token: what is++ // asked on Live carries on on Chat and back. Vibe answers in the background; the tab waits for it (a quick poll++ // for a minute and a half after a message, then every 15 s while the tab is open). The mic is the browser's own++ // speech recognition, hold to talk or Alt+V, and only while the person asks: no wake word, never always on. A++ // browser without it (Firefox) records instead, up to 30 s, and Chat's /api/vibe/listen turns that into text++ // (OpenVibe.AI's speech-to-text on this network's own machines; the recording is never kept).++ // Everything is set as text; a link is a link only when it is https.++ const vibe = { panel: null, chat: null, msgs: [], last: 0, timer: 0, busy: false, waitUntil: 0, rec: null, talk: null };++ const SR = root.SpeechRecognition || root.webkitSpeechRecognition || null;++ const MR = !SR && root.MediaRecorder && root.navigator && root.navigator.mediaDevices && root.navigator.mediaDevices.getUserMedia ? root.MediaRecorder : null;++ const TALK = Boolean(SR || MR);++ const REC_TYPES = ['audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4', 'audio/webm', 'audio/ogg'];++ const REC_MAX_MS = 30000;++ const STARTERS = ['Remind me to stretch every 2 hours', 'What is on today?', 'Who is live right now?', 'Remind me tomorrow at 9 to call Sam'];++ function linkified(text) {++ const span = h('span', 'ovdk-text');++ String(text || '').split(/(https:\/\/[^\s<>"')]+)/).forEach((part, i) => {++ if (i % 2) { const a = h('a', '', part); a.href = part; a.target = '_blank'; a.rel = 'noopener noreferrer'; span.append(a); } else if (part) span.append(part);++ });++ return span;++ }++ const DAY = 24 * 3600 * 1000;++ /** One thing Vibe did (Chat's results on its answer): what, when, and Undo / Open. */++ function resultChip(m, r, i) {++ const set = r.action === 'set';++ const c = h('div', `ovdk-res-c${set ? '' : ' is-stopped'}`);++ c.append(icon(r.kind === 'timer' ? G.clock : G.bell));++ const t = h('div', 'ovdk-res-t');++ const what = r.kind === 'timer' ? (set ? 'Timer' : 'Timer stopped') : (set ? 'Reminder set' : 'Reminder stopped');++ t.append(h('b', '', `${what}: `), String(r.title || ''));++ if (r.when && set) t.append(h('small', '', String(r.when)));++ const a = h('div', 'ovdk-res-a');++ const young = !m.at || Date.now() - Date.parse(m.at) < DAY;++ if (set && young && Number(m.id) > 0) {++ const undo = h('button', '', 'Undo'); undo.type = 'button';++ undo.setAttribute('aria-label', `Undo: ${what} ${r.title || ''}`.trim());++ undo.addEventListener('click', async () => {++ undo.disabled = true;++ try {++ const out = await ctx.chatCall('/api/vibe/undo', { method: 'POST', body: JSON.stringify({ message_id: Number(m.id), index: i }) });++ m.results = (out && out.results) || m.results.map((x, k) => (k === i ? { ...x, action: 'stopped' } : x));++ c.replaceWith(resultChip(m, m.results[i], i));++ if (r.kind === 'timer' && ctx.refresh) ctx.refresh();++ } catch (e) {++ undo.disabled = false; undo.textContent = e && e.status === 410 ? 'Too old to undo' : 'Try again';++ if (e && (e.status === 409 || e.status === 410)) undo.disabled = true;++ }++ });++ a.append(undo);++ }++ if (r.url && /^https:\/\//.test(r.url)) { const o = h('a', '', 'Open'); o.href = r.url; o.target = '_blank'; o.rel = 'noopener'; a.append(o); }++ c.append(t, a);++ return c;++ }++ function results(m) {++ const box = h('div', 'ovdk-res');++ m.results.slice(0, 4).forEach((r, i) => { if (r && (r.kind === 'reminder' || r.kind === 'timer')) box.append(resultChip(m, r, i)); });++ return box;++ }++ function vbubble(m, prev) {++ const mine = m.from === 'you';++ const li = h('li', `ovdk-msg${mine ? ' is-mine' : ' is-vibe'}${prev && prev.from === m.from ? ' is-cont' : ''}`);++ if (!mine && !(prev && prev.from === m.from)) li.append(h('span', 'ovdk-who', 'Vibe'));++ li.append(linkified(m.text));++ if (!mine && Array.isArray(m.results) && m.results.length) li.append(results(m));++ if (m.at) li.title = new Date(m.at).toLocaleString();++ return li;++ }++ async function openVibe() {++ clearTimeout(vibe.timer);++ const panel = vibe.panel; if (!panel) return;++ vibe.msgs = []; vibe.last = 0;++ panel.textContent = '';++ const head = h('div', 'ovdk-thread-h ovdk-vibe-h');++ const title = h('b', '', 'Vibe');++ const away = h('a', 'ovdk-away', 'Open in Chat'); away.href = 'https://openvibe.chat/vibe'; away.target = '_blank'; away.rel = 'noopener';++ head.append(icon(G.vibe), title, away);++ const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');++ const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);++ const typing = h('li', 'ovdk-msg is-vibe ovdk-typing'); typing.innerHTML = '<span class="ovdk-text"><i></i><i></i><i></i></span>'; typing.setAttribute('aria-label', 'Vibe is writing');++ const form = h('form', 'ovdk-send');++ const box = h('textarea'); box.rows = 1; box.maxLength = 8000; box.placeholder = TALK ? 'Ask Vibe, or hold the mic' : 'Ask Vibe'; box.setAttribute('aria-label', 'Message Vibe');++ const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));++ const err = h('p', 'ovdk-err'); err.hidden = true;++ let mic = null;++ if (TALK) {++ mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));++ mic.setAttribute('aria-label', 'Talk to Vibe: hold, or press Alt+V'); mic.title = 'Hold to talk (Alt+V)'; mic.setAttribute('aria-pressed', 'false');++ form.append(mic);++ }++ 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 })); } });++++ async function send(text) {++ if (!text || vibe.busy) return;++ vibe.busy = true; go.disabled = true; err.hidden = true;++ try {++ let timezone = null; try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { /* */ }++ const out = await ctx.chatCall('/api/vibe/ask', { method: 'POST', body: JSON.stringify({ message: text, slug: vibe.chat ? vibe.chat.slug : undefined, timezone }) });++ if (out.chat) { vibe.chat = out.chat; away.href = out.chat.url; }++ box.value = ''; fit();++ const empty = list.querySelector('.ovdk-vibe-start'); if (empty) empty.remove();++ if (out.message && !vibe.msgs.some((x) => x.id === out.message.id)) { list.append(vbubble(out.message, vibe.msgs[vibe.msgs.length - 1])); vibe.msgs.push(out.message); vibe.last = Math.max(vibe.last, out.message.id || 0); }++ vibe.waitUntil = Date.now() + 90000;++ list.append(typing);++ scroll.scrollTop = scroll.scrollHeight;++ clearTimeout(vibe.timer); vibe.timer = setTimeout(() => pull(), 1200);++ } catch (x) {++ err.textContent = x.status === 429 ? 'That is a lot of questions at once: wait a minute, then ask again.' : x.status === 401 ? 'Sign in again to talk to Vibe.' : 'That did not reach Vibe. Try again.';++ err.hidden = false;++ } finally { vibe.busy = false; go.disabled = false; box.focus(); }++ }++ form.addEventListener('submit', (e) => { e.preventDefault(); send(box.value.trim()); });++++ async function pull(first) {++ let got;++ try {++ got = await ctx.chatCall(`/api/vibe/chat${vibe.chat ? `?slug=${encodeURIComponent(vibe.chat.slug)}${vibe.last ? `&after=${vibe.last}` : ''}` : ''}`);++ } catch (e) {++ if (first) { list.textContent = ''; list.append(h('li', 'ovdk-none', e.status === 401 ? 'Sign in to OpenVibe.Chat to talk to Vibe.' : 'Vibe did not answer just now. Try again in a moment.')); }++ return;++ }++ if (!ctx.active() || vibe.panel !== panel) return;++ if (got.chat) { vibe.chat = got.chat; away.href = got.chat.url; }++ if (first) list.textContent = '';++ const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;++ let answered = false;++ (got.messages || []).forEach((m) => {++ if (vibe.msgs.some((x) => x.id === m.id)) return;++ list.insertBefore(vbubble(m, vibe.msgs[vibe.msgs.length - 1]), typing.parentNode === list ? typing : null);++ vibe.msgs.push(m); vibe.last = Math.max(vibe.last, Number(m.id) || 0);++ if (m.from === 'vibe') answered = true;++ if (!first && Array.isArray(m.results) && m.results.some((r) => r && r.kind === 'timer') && ctx.refresh) ctx.refresh();++ });++ if (answered || Date.now() > vibe.waitUntil) { typing.remove(); vibe.waitUntil = 0; }++ if (first && !vibe.msgs.length) {++ const start = h('li', 'ovdk-vibe-start');++ start.append(h('b', '', 'Ask Vibe anything'), h('span', '', TALK ? 'Type, or hold the mic and say it. It sets reminders, finds what is live, and answers from the whole network.' : 'It sets reminders, finds what is live, and answers from the whole network.'));++ const chips = h('div', 'ovdk-chips');++ STARTERS.forEach((s) => { const c = h('button', 'ovdk-chip', s); c.type = 'button'; c.addEventListener('click', () => send(s)); chips.append(c); });++ start.append(chips);++ list.append(start);++ }++ if (first || atEnd || answered) scroll.scrollTop = scroll.scrollHeight;++ clearTimeout(vibe.timer);++ if (ctx.active()) vibe.timer = setTimeout(() => pull(), vibe.waitUntil ? 1500 : 15000);++ }++++ // The mic: hold to talk (or a click to start and another to stop), Alt+V from anywhere while the Dock is open.++ if (mic) {++ let said = '', heard = false;++ const stop = () => { if (vibe.rec) { try { vibe.rec.stop(); } catch { /* */ } } };++ const on = (yes) => { mic.classList.toggle('is-on', yes); mic.setAttribute('aria-pressed', yes ? 'true' : 'false'); };++ const fail = (text) => { err.textContent = text; err.hidden = false; };++ // No speech recognition in this browser: record, then Chat's /api/vibe/listen says what was said.++ const record = async () => {++ const type = REC_TYPES.find((t) => { try { return MR.isTypeSupported(t); } catch { return false; } }) || '';++ let stream;++ try { stream = await root.navigator.mediaDevices.getUserMedia({ audio: true }); } catch (e) {++ return fail(e && e.name === 'NotAllowedError' ? 'The microphone is off for this site: allow it in the browser to talk to Vibe.' : 'No microphone was found.');++ }++ const chunks = [];++ let r;++ try { r = new MR(stream, { ...(type ? { mimeType: type } : {}), audioBitsPerSecond: 32000 }); } catch { stream.getTracks().forEach((t) => t.stop()); return fail('This browser cannot record here.'); }++ const cap = setTimeout(stop, REC_MAX_MS);++ r.ondataavailable = (ev) => { if (ev.data && ev.data.size) chunks.push(ev.data); };++ r.onstop = async () => {++ clearTimeout(cap); vibe.rec = null; on(false);++ stream.getTracks().forEach((t) => t.stop());++ const kind = String(r.mimeType || type || 'audio/webm').split(';')[0];++ const blob = new Blob(chunks, { type: kind });++ if (blob.size < 800) { box.placeholder = 'Ask Vibe, or hold the mic'; return; }++ box.placeholder = 'Working out what you said…'; mic.disabled = true;++ try {++ const lang = String(document.documentElement.lang || root.navigator.language || 'en').slice(0, 2).toLowerCase();++ const out = await ctx.chatCall(`/api/vibe/listen?lang=${encodeURIComponent(/^[a-z]{2}$/.test(lang) ? lang : 'en')}`, { method: 'POST', body: blob, type: kind });++ const said = String((out && out.text) || '').trim();++ if (said) { box.value = said; fit(); send(said); } else fail('I could not hear that. Try again, a little closer.');++ } catch (e) {++ fail(e && e.status === 429 ? 'That is a lot of talking for one minute: type it, or try again shortly.' : e && e.status === 413 ? 'Keep it under 30 seconds.' : 'I could not hear that just now. Try again, or type it.');++ } finally { mic.disabled = false; box.placeholder = 'Ask Vibe, or hold the mic'; }++ };++ vibe.rec = r; err.hidden = true; on(true); box.placeholder = 'Listening… let go or tap to send';++ try { r.start(); } catch { vibe.rec = null; on(false); stream.getTracks().forEach((t) => t.stop()); }++ };++ const start = () => {++ if (vibe.rec) return stop();++ if (!SR) return record();++ const r = new SR();++ r.lang = document.documentElement.lang || navigator.language || 'en-US';++ r.interimResults = true; r.continuous = false;++ said = ''; heard = false;++ r.onresult = (ev) => {++ let text = '';++ for (let i = 0; i < ev.results.length; i++) text += ev.results[i][0].transcript;++ said = text.trim(); heard = true; box.value = said; fit();++ };++ r.onerror = (ev) => {++ if (ev.error === 'not-allowed' || ev.error === 'service-not-allowed') { err.textContent = 'The microphone is off for this site: allow it in the browser to talk to Vibe.'; err.hidden = false; }++ };++ r.onend = () => {++ vibe.rec = null; mic.classList.remove('is-on'); mic.setAttribute('aria-pressed', 'false');++ if (heard && said) send(said);++ };++ vibe.rec = r;++ mic.classList.add('is-on'); mic.setAttribute('aria-pressed', 'true');++ try { r.start(); } catch { vibe.rec = null; mic.classList.remove('is-on'); }++ };++ let held = 0;++ mic.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; held = Date.now(); start(); });++ mic.addEventListener('pointerup', () => { if (held && Date.now() - held > 400) stop(); held = 0; });++ mic.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); start(); } });++ vibe.talk = start;++ }++ list.append(h('li', 'ovdk-wait', null));++ list.firstChild.innerHTML = '<span></span>';++ await pull(true);++ setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);++ }++++ function mount(panel, c) {++ ctx = c || {};++ G = { mic: MIC, ...(ctx.icons || {}) };++ const st = typeof ctx.styles === 'function' ? ctx.styles() : null;++ if (st && !st.textContent.includes('.ovdk-vibe-start')) st.textContent += CSS;++ else if (!st && !document.getElementById('ovdk-vibe-styles')) { const s = document.createElement('style'); s.id = 'ovdk-vibe-styles'; s.textContent = CSS; document.head.appendChild(s); }++ vibe.panel = panel;++ openVibe();++ return {++ refresh: () => { vibe.panel = panel; return openVibe(); },++ pause: () => { clearTimeout(vibe.timer); if (vibe.rec) { try { vibe.rec.abort(); } catch { /* */ } } },++ talk: () => { if (vibe.talk) vibe.talk(); },++ };++ }++ root.OpenVibeDockVibe = { mount };++})(typeof window !== 'undefined' ? window : globalThis);@@@ -13,9 +13,11 @@// 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.++// Tabs: Agents (action-center.js mount(): what needs you, then what runs, approve or decline in place; K-D3: above it,++// the person's own desk stream — dock-agent.js loads the first time the tab opens — with Curtain now, End and the still++// when nothing is live), Chats (K-D2: the person's conversations and DMs from OpenVibe.Chat's own API,++// GET /api/dm/conversations, opened in place, read and answered there) and Activity (notification-ui.js renderDock():++// the bell's list).// Desktop: a floating card at the pill's corner. Drag its head to move it (it snaps to the nearest corner), drag the// free corner to resize (320-720 × 360-900 px). It is not modal: the page stays usable. Phones: a sheet from the// bottom over a scrim; drag the head down, ×, Escape or a tap outside closes it. Motion is transform and opacity only.@@@ -38,8 +40,14 @@chat: '<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5z"/>',back: '<path d="M14.5 6.5L9 12l5.5 5.5"/>',send: '<path d="M4.5 12h11M11 6.5l5.5 5.5-5.5 5.5"/>',++ vibe: '<path d="M12 4.5a7.5 7.5 0 0 1 6.4 11.4L19.5 19.5l-3.6-1.1A7.5 7.5 0 1 1 12 4.5z"/><path d="M9 11.5h.01M12 11.5h.01M15 11.5h.01"/>',++ clock: '<circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/>',};-- const TABS = [['agent', 'Agents', G.spark], ['chats', 'Chats', G.chat], ['activity', 'Activity', G.bell]];++ // Vibe first (L48 V2): the person's AI, on every site.++ const TABS = [['vibe', 'Vibe', G.vibe], ['agent', 'Agents', G.spark], ['chats', 'Chats', G.chat], ['activity', 'Activity', G.bell]];++ // The tabs the Network keeps in the person's Dock state (network.dock-state@1; vibe since contracts 0.270.0); a++ // newer one stays on this site's copy until the Network learns it.++ const SAVED_TABS = ['vibe', 'agent', 'chats', 'activity'];const 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;@@@ -55,6 +63,8 @@const kind = () => (phone() ? 'phone' : 'desktop');const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);++ // K-D3: the agent stream comes from OpenVibe.Codes' API (it mints the view ticket with the person's own token).++ const agentBase = () => String(o.agentBase || 'https://openvibe.codes').replace(/\/+$/, '');async function call(path, init = {}) {const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');@@@ -139,7 +149,9 @@@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 { display: flex; gap: 2px; padding: 3px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #ececf1) 6%, transparent); min-width: 0; flex: 0 1 auto; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }++ .ovdk-tabs::-webkit-scrollbar { display: none; }++ .ovdk-tabs button { flex: none; }.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; }@@@ -167,8 +179,10 @@.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-wait span { position: relative; display: inline-block; width: 6px; height: 6px; margin: 0 10px; border: 0; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovdk-hop 1s ease-in-out .15s infinite; }++ .ovdk-wait span::before, .ovdk-wait span::after { content: ''; position: absolute; top: 0; width: 6px; height: 6px; border-radius: 50%; background: inherit; animation: ovdk-hop 1s ease-in-out infinite; }++ .ovdk-wait span::before { left: -10px; } .ovdk-wait span::after { left: 10px; animation-delay: .3s; }++ @keyframes ovdk-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-4px); opacity: 1; } }.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; }@@@ -181,7 +195,7 @@.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; } }++ @container (max-width: 440px) { .ovdk-tabs button { padding: 0 8px; gap: 6px; font-size: 13px; } .ovdk-tabs button svg { display: none; } .ovdk-acts { gap: 0; } .ovdk-acts button { width: 34px; } }.ovdk-muted { color: var(--text-muted, #8b8b96); }.ovdk-i { display: inline-grid; place-items: center; }.ovdk-i svg { width: 18px; height: 18px; }@@@ -256,6 +270,7 @@<header class="ovdk-head"><div class="ovdk-tabs" role="tablist" aria-label="Dock">${TABS.map(([id, label, g]) => `<button type="button" role="tab" id="ovdk-tab-${id}" aria-controls="ovdk-panel-${id}" data-tab="${id}" aria-selected="false" tabindex="-1">${svg(g)}<span>${label}</span><b class="n" data-n="${id}"></b></button>`).join('')}</div><div class="ovdk-acts">++ <button type="button" data-act="timer" title="A timer" aria-label="Start a timer">${svg(G.clock)}</button><button type="button" data-act="pop" hidden title="Open the Dock in a window of its own" aria-label="Open the Dock in a window of its own">${svg(G.open)}</button><button type="button" data-act="pin" aria-pressed="false" title="Keep the Dock's button on every page" aria-label="Keep the Dock's button on every page">${svg(G.pin)}</button><button type="button" data-act="close" title="Close" aria-label="Close the Dock">${svg(G.close)}</button>@@@ -275,6 +290,7 @@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.querySelector('[data-act="timer"]').addEventListener('click', (e) => { const a = e.currentTarget; withTimer((t) => t.menu(a)); });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.@@@ -407,14 +423,24 @@function setTab(id, remember) {tab = TABS.some(([t]) => t === id) ? id : 'activity';if (tab !== 'chats') clearTimeout(chats.timer);++ if (tab !== 'vibe' && vibe.api) vibe.api.pause();el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === tab; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });el.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });-- if (remember && state().tab !== tab) save({ tab });++ if (remember && state().tab !== tab && SAVED_TABS.includes(tab)) save({ tab });fill(tab);}async function fill(id) {const panel = el.querySelector(`[data-panel="${id}"]`);++ if (id === 'vibe') {++ vibe.panel = panel;++ if (vibe.api) return vibe.api.refresh();++ panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';++ const ok = await load('dock-vibe.js', () => !!(root.OpenVibeDockVibe && root.OpenVibeDockVibe.mount));++ if (!ok) { panel.innerHTML = '<div class="ovdk-none">Vibe did not load. Check your connection, then open it again.</div>'; return; }++ vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => isOpen() && tab === 'vibe', styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });++ return;++ }if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); return; }mounts[id] = { refresh: null };@@@ -422,8 +448,12 @@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, {++ // K-D3: the person's own agent stream above the list; it hides itself when there is none.++ panel.innerHTML = `<div class="ovdk-agent"></div><div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;++ load('dock-agent.js', () => !!(root.OpenVibeDockAgent && root.OpenVibeDockAgent.mount)).then((on) => {++ if (on && !mounts[id + 'View'] && panel.isConnected) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.firstChild, { token: o.token, base: agentBase(), active: () => isOpen() && tab === 'agent' });++ });++ const m = root.OpenVibeActionCenter.mount(panel.querySelector('.ovdk-scroll'), {token: o.token, apiBase: o.apiBase, scopes: o.scopes, buckets: ['waiting', 'running'],onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },});@@@ -461,8 +491,9 @@const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };async function chatCall(path, init = {}) {const t = await tokenOf();-- 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 headers = { accept: 'application/json', ...(init.body ? { 'content-type': init.type || 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };++ const { type: _type, ...rest } = init;++ return fetch(chatBase() + path, { ...rest, headers, credentials: 'omit' }).then(async (r) => {const b = await r.json().catch(() => ({}));if (!r.ok) { const e = new Error(b.error || `HTTP ${r.status}`); e.status = r.status; throw e; }return b;@@@ -605,6 +636,29 @@if (isOpen() && tab === 'chats' && chats.open === id) chats.timer = setTimeout(() => pull(list, scroll), 5000);}++ // ── Vibe (plan L48 V2): the person's Vibe, in dock-vibe.js, loaded the first time the tab opens (the Dock is on++ // every page for signed-in people; Vibe's conversation and mic are not needed until then). Alt+V talks to Vibe from++ // any page: it opens the Dock on Vibe and starts listening (only where the browser can listen).++ const vibe = { panel: null, api: null };++ const canListen = () => !!(root.SpeechRecognition || root.webkitSpeechRecognition);++ document.addEventListener('keydown', (e) => {++ if (!e.altKey || e.ctrlKey || e.metaKey || String(e.key).toLowerCase() !== 'v' || !canListen() || !o || typeof o.token !== 'function') return;++ e.preventDefault();++ const talk = () => { if (vibe.api && vibe.api.talk) vibe.api.talk(); };++ if (!isOpen() || tab !== 'vibe') { open({ tab: 'vibe' }); setTimeout(talk, 600); } else talk();++ });++++ // ── The timer (plan L48 R2): dock-timer.js, loaded when the state carries one or the person opens its menu ──++ let timerMod = null, timerLoading = null;++ function withTimer(fn) {++ if (timerMod) return fn(timerMod);++ timerLoading = timerLoading || load('dock-timer.js', () => !!(root.OpenVibeDockTimer && root.OpenVibeDockTimer.attach)).then((ok) => {++ if (ok && !timerMod) timerMod = root.OpenVibeDockTimer.attach({ state, save, host: () => el, repaint: () => paintPill() });++ return timerMod;++ });++ return timerLoading.then((m) => (m ? fn(m) : null));++ }++// ── The pill ─────────────────────────────────────────────let pill = null, readT = 0, bound = false;function store(v) {@@@ -624,13 +678,16 @@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);++ // A running timer: its minutes, unless an agent needs the person (that comes first).++ if (st.timer && !timerMod) withTimer((m) => m.sync());++ const tm = timerMod && !w ? timerMod.pillText() : null;++ const show = !isOpen() && !!(w || r || st.pinned || tm);pill.hidden = !show;pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';-- pill.dataset.tone = w ? 'waiting' : r ? 'running' : 'idle';++ pill.dataset.tone = w ? 'waiting' : tm ? tm.tone : 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');++ pill.querySelector('.ovdk-t').textContent = w ? 'Needs you' : tm ? tm.text : r ? (r > 1 ? `${r} working` : 'Working') : '';++ pill.setAttribute('aria-label', tm ? tm.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);}@@@ -647,7 +704,7 @@update(next);if (!isOpen() && next.state.open && !phone()) open();const c = next.counts || {};-- if (c.running || c.waiting) readT = setTimeout(read, 30000);++ if (c.running || c.waiting || next.state.timer) 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). */@@@ -700,7 +757,7 @@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']) {++ for (const id of ['ovdk-styles', 'ovac-styles', 'openvibe-notif-styles', 'ovda-styles', 'ov-mark-css']) {const s = document.getElementById(id);if (!s) continue;const c = d.createElement('style');@@@ -769,7 +826,7 @@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();++ if (!state().open || (state().tab !== tab && SAVED_TABS.includes(tab))) save({ open: true, ...(SAVED_TABS.includes(tab) ? { tab } : {}) }); else notify();setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]').focus({ preventScroll: true }); } catch { /* */ } }, 0);}function close() {@@@ -794,6 +851,7 @@if (!next || !next.state) return;doc = Object.keys(pending).length ? { ...next, state: state() } : next;store(doc);++ if (timerMod) timerMod.sync();paintPill();if (!el) return;badges();@@@ -24,15 +24,64 @@--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;++ /* Status in words (4.83.0): a theme's -text status colours are its readable ones; the fills above are for shapes. On++ * a light theme the fill green or amber on white was about 2:1 (openvibe.design/kit's "paid" and "expiring"). */++ --ovk-ok-text: var(--success-text, var(--ovk-ok));++ --ovk-warn-text: var(--warning-text, var(--ovk-warn));++ --ovk-bad-text: var(--danger-text, var(--ovk-bad));++ --ovk-live: color-mix(in srgb, var(--live-red, #ef4444) 80%, #000); /* a badge fill under white text: 5.5:1 on #ef4444's 80%, 3.8:1 bare */++ /* Shape and elevation (4.81.0, plan L45 step 2): the kit's corners and shadows as tokens a look can set, so a theme++ * has a shape as well as colours. The defaults are the kit's own corners and shadows; the personalities follow. */++ --ov-radius-1: 10px; /* chips, inputs, rows, small buttons */++ --ov-radius-2: 14px; /* cards, sections, panels */++ --ov-radius-3: 20px; /* sheets, dialogs, big surfaces */++ --ov-elev-1: 0 1px 3px rgba(0, 0, 0, .35); /* a knob, a chip that sits on its surface */++ --ov-elev-2: 0 10px 24px -18px rgba(0, 0, 0, .7); /* a bar that has scrolled content under it */++ --ov-elev-3: 0 14px 32px rgba(0, 0, 0, .4); /* a menu or popover above the page */++ /* Motion (4.82.0): the same tokens ov-fx.js has always set, here so every page's var(--ov-dur-*) resolves and the++ * person's Reduced motion reaches every transition that uses them, not only pages with effects. */++ --ov-dur-1: .12s;++ --ov-dur-2: .2s;++ --ov-dur-3: .32s;++ --ov-dur-4: .52s;++ --ov-ease-out: cubic-bezier(.2, .8, .2, 1);++ --ov-ease-spring: cubic-bezier(.34, 1.56, .64, 1);++ --ov-ease-in-out: cubic-bezier(.65, 0, .35, 1);++ --ovk-radius: var(--ov-radius-2);++ --ovk-radius-sm: var(--ov-radius-1);--ovk-tap: 44px;--ovk-top: var(--ovnav-real-h, 52px); /* the navbar's height: sticky things stop under it */--ovk-bottom: var(--ovnav-appbar-h, 0px); /* the app bar's height on phones (navbar.js sets it) */--ovk-font: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);}++/* Motion feels, as ov-fx.js sets them: the calm looks are slower and never overshoot, the loud ones quicker; the++ * person's Reduced motion, or a device that asks for less, stops every token-timed transition. */++:is([data-theme=paper], [data-theme=daylight], [data-theme=cloud], [data-theme=snow], [data-theme=sand], [data-theme=rose], [data-theme=meadow], [data-theme=peach], [data-theme=lavender], [data-theme=dawn], [data-theme=solarized-light], [data-theme=catppuccin-latte], [data-theme=nord], [data-theme=nord-light]) {++ --ov-dur-1: .15s; --ov-dur-2: .25s; --ov-dur-3: .4s; --ov-dur-4: .62s; --ov-ease-spring: var(--ov-ease-out);++}++:is([data-theme=neon-tokyo], [data-theme=vapor], [data-theme=matrix], [data-theme=hacker], [data-theme=high-contrast], [data-theme=dracula], [data-theme=monokai], [data-theme=abyss]) {++ --ov-dur-1: .09s; --ov-dur-2: .15s; --ov-dur-3: .24s; --ov-dur-4: .42s; --ov-ease-spring: cubic-bezier(.3, 1.8, .6, 1);++}++html[data-ov-motion=reduced] { --ov-dur-1: 0s; --ov-dur-2: 0s; --ov-dur-3: 0s; --ov-dur-4: 0s; }++/* :root:root outranks ov-fx.js's own :root and theme feels, which load later. */++@media (prefers-reduced-motion: reduce) { :root:root { --ov-dur-1: 0s; --ov-dur-2: 0s; --ov-dur-3: 0s; --ov-dur-4: 0s; } }++/* Shape personalities (plan L45: "Paper is quiet and square, Neon glows and bounces"). A quiet look has square corners++ * and lies flat, with a hairline where a shadow would be; a loud one is round and its raised things glow in its accent.++ * The ids are Network's theme catalog (as ov-fx.js's motion feels); any other look keeps the defaults above. */++:is([data-theme=paper], [data-theme=solarized-light], [data-theme=nord], [data-theme=nord-light], [data-theme=matrix], [data-theme=hacker], [data-theme=high-contrast]) {++ --ov-radius-1: 4px; --ov-radius-2: 6px; --ov-radius-3: 10px;++ --ov-elev-1: 0 0 0 1px var(--ovk-line);++ --ov-elev-2: 0 1px 0 var(--ovk-line);++ --ov-elev-3: 0 0 0 1px var(--ovk-line), 0 8px 18px -12px rgba(0, 0, 0, .35);++}++:is([data-theme=neon-tokyo], [data-theme=vapor]) {++ --ov-radius-1: 14px; --ov-radius-2: 20px; --ov-radius-3: 28px;++ --ov-elev-1: 0 0 8px color-mix(in srgb, var(--ovk-accent) 40%, transparent);++ --ov-elev-2: 0 10px 28px -14px color-mix(in srgb, var(--ovk-accent) 55%, transparent);++ --ov-elev-3: 0 0 0 1px color-mix(in srgb, var(--ovk-accent) 40%, transparent), 0 18px 44px -14px color-mix(in srgb, var(--ovk-accent) 55%, transparent);++}++/* 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). */@@@ -45,36 +94,36 @@.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 { 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: calc(var(--ov-radius-1) + 2px); 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 { background: transparent; color: var(--ovk-bad-text); 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 { display: inline-grid; place-items: center; box-sizing: border-box; width: 40px; height: 40px; flex: none; padding: 0; border-radius: calc(var(--ov-radius-1) + 2px); 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.is-done { color: var(--ovk-ok-text); }++.ovk-icon-btn--danger:hover { color: var(--ovk-bad-text); }.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 { display: inline-flex; align-items: center; flex: none; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--ovk-line); font-size: .75rem; font-weight: 800; letter-spacing: .03em; text-transform: uppercase; color: var(--ovk-muted); white-space: nowrap; }.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--ok { color: var(--ovk-ok-text); border-color: color-mix(in srgb, var(--ovk-ok) 50%, transparent); }++.ovk-badge--warn { color: var(--ovk-warn-text); border-color: color-mix(in srgb, var(--ovk-warn) 50%, transparent); }++.ovk-badge--bad { color: var(--ovk-bad-text); 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-scrolled .ovk-head { border-bottom-color: var(--ovk-line); box-shadow: var(--ov-elev-2); }.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; }@@@ -115,8 +164,9 @@.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-row-trail select { max-width: 9.5em; padding: 5px 6px; border-radius: max(0px, calc(var(--ov-radius-1) - 2px)); border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: .86rem; }++/* 32% lightness keeps the white letters at 4.5:1 or more for every hue (38% left greens and yellows near 3.7:1). */++.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% 32%); 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); }@@@ -137,14 +187,14 @@.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-fold--danger > summary { color: var(--ovk-bad-text); }.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 { position: absolute; right: 0; top: calc(100% + 4px); z-index: 60; min-width: 210px; padding: 6px; box-sizing: border-box; border-radius: calc(var(--ov-radius-1) + 2px); border: 1px solid var(--ovk-line); background: var(--ovk-panel); box-shadow: var(--ov-elev-3); }.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 { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 8px 10px; border: 0; border-radius: max(0px, calc(var(--ov-radius-1) - 2px)); 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); }++.ovk-pop-item--danger { color: var(--ovk-bad-text); }/* ── 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; }@@@ -152,17 +202,17 @@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-row select { flex: none; max-width: 52%; margin: 0; padding: 7px 8px; border-radius: var(--ov-radius-1); border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: .9rem; }.ovk-set-range input[type=range] { flex: 0 1 45%; min-width: 120px; margin: 0; accent-color: var(--ovk-fill); }.ovk-set-chips { flex-wrap: wrap; }.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::after { content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: var(--ov-elev-1); transition: transform .15s; }.ovk-switch:checked { background: var(--ovk-fill); }.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 { display: inline-flex; flex: none; flex-wrap: wrap; max-width: 100%; gap: 2px; padding: 3px; border-radius: var(--ov-radius-1); 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 span { display: block; padding: 6px 12px; border-radius: max(0px, calc(var(--ov-radius-1) - 2px)); color: var(--ovk-muted); font-size: .86rem; font-weight: 650; cursor: pointer; }.ovk-seg input:checked + span { background: var(--ovk-fill); color: var(--ovk-on-fill); }.ovk-seg input:focus-visible + span, .ovk-chip input:focus-visible + span { outline: 2px solid var(--ovk-accent); outline-offset: 1px; }.ovk-chips { display: flex; flex-wrap: wrap; gap: 6px; }@@@ -173,7 +223,7 @@ div.ovk-set-row { cursor: default; }.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 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: var(--ov-radius-1); 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; }@@@ -211,7 +261,7 @@ div.ovk-set-row { cursor: default; }/* ── 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 > 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: calc(var(--ov-radius-1) + 2px); 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. */@@@ -225,18 +275,18 @@ div.ovk-set-row { cursor: default; }.ovk-empty > .ovk-i { width: 28px; height: 28px; }.ovk-empty.ovk-empty p { margin: 0; max-width: 46ch; }.ovk-empty .ovk-empty-title { color: var(--ovk-text); font-weight: 750; }--.ovk-notice { display: flex; align-items: flex-start; gap: 10px; margin: 0 0 12px; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); color: var(--ovk-text); font-size: .92rem; }++.ovk-notice { display: flex; align-items: flex-start; gap: 10px; margin: 0 0 12px; padding: 10px 14px; border-radius: calc(var(--ov-radius-1) + 2px); border: 1px solid var(--ovk-line); background: var(--ovk-panel); color: var(--ovk-text); font-size: .92rem; }.ovk-notice > .ovk-i { margin-top: 2px; }.ovk-notice--ok { border-color: color-mix(in srgb, var(--ovk-ok) 45%, transparent); }--.ovk-notice--ok > .ovk-i { color: var(--ovk-ok); }++.ovk-notice--ok > .ovk-i { color: var(--ovk-ok-text); }.ovk-notice--error { border-color: color-mix(in srgb, var(--ovk-bad) 50%, transparent); }--.ovk-notice--error > .ovk-i { color: var(--ovk-bad); }--.ovk-notice--warn > .ovk-i { color: var(--ovk-warn); }++.ovk-notice--error > .ovk-i { color: var(--ovk-bad-text); }++.ovk-notice--warn > .ovk-i { color: var(--ovk-warn-text); }.ovk-notice--info > .ovk-i { color: var(--ovk-accent); }.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-snip > pre { max-height: 50vh; margin: 0 0 10px; padding: 12px 48px 12px 12px; overflow: auto; border-radius: var(--ov-radius-1); border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font-size: .84rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }++.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: calc(var(--ov-radius-3) - 2px); 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); }@@@ -246,7 +296,7 @@ div.ovk-set-row { cursor: default; }/* ── 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-sheet { margin: auto 0 0; width: 100vw; border-radius: calc(var(--ov-radius-3) - 2px) calc(var(--ov-radius-3) - 2px) 0 0; border-bottom: 0; }.ovk-row > a, .ovk-row--static { padding-left: 12px; }.ovk-head-tools { gap: 0; }}@@@ -273,3 +323,109 @@ div.ovk-set-row { cursor: default; }.ovk-go::after { content: ''; position: absolute; inset: -3px; }.ovk-find input, .ovk-find select { min-height: 44px; }}++++/* ── The left sidebar (kit.sidebar, L35): one for every product. A column beside the page from 761 px; on a phone a++ drawer over everything (kit.sidebarButton opens it; the scrim, its close button and Escape close it, kit-web.js). The++ main action and search on top, the navigation as vertical rows, then the sections, which scroll on their own. ── */++.ovk-with-sidebar { display: grid; grid-template-columns: var(--ovk-sidebar-w, 272px) minmax(0, 1fr); min-height: calc(100dvh - var(--navbar-h, 52px)); }++.ovk-with-sidebar-main { min-width: 0; }++.ovk-sidebar { position: sticky; top: var(--navbar-h, 52px); height: calc(100dvh - var(--navbar-h, 52px)); box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: 12px 10px 10px; border-right: 1px solid var(--ovk-line); background: var(--ovk-panel); color: var(--ovk-text); overflow: hidden; }++.ovk-sidebar-top { display: flex; align-items: center; gap: 6px; }++.ovk-sidebar-action { flex: 1; display: flex; align-items: center; gap: 10px; min-height: 42px; padding: 0 12px; border-radius: calc(var(--ov-radius-1) + 2px); border: 1px solid var(--ovk-line); background: var(--ovk-bg); color: var(--ovk-text); font-weight: 650; text-decoration: none; transition: border-color .18s, background .18s; }++.ovk-sidebar-action:hover { border-color: color-mix(in srgb, var(--ovk-accent) 55%, var(--ovk-line)); }++.ovk-sidebar-action .ovk-i { width: 18px; height: 18px; color: var(--ovk-accent); }++.ovk-sidebar-close, .ovk-sidebar-open { display: none; }++.ovk-sidebar-find { position: relative; display: flex; align-items: center; margin: 0; }++.ovk-sidebar-find .ovk-i { position: absolute; left: 11px; width: 16px; height: 16px; color: var(--ovk-muted); pointer-events: none; }++.ovk-sidebar-find input { width: 100%; min-height: 38px; box-sizing: border-box; padding: 0 12px 0 34px; border-radius: var(--ov-radius-1); border: 1px solid transparent; background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 14px; }++.ovk-sidebar-find input:focus { outline: none; border-color: var(--ovk-accent); }++.ovk-sidebar-nav { display: grid; gap: 1px; padding-bottom: 8px; border-bottom: 1px solid var(--ovk-line); }++.ovk-sidebar-link { display: flex; align-items: center; gap: 11px; min-height: 38px; padding: 0 10px; border-radius: var(--ov-radius-1); color: var(--ovk-muted); font-size: 14.5px; font-weight: 600; text-decoration: none; transition: background .15s, color .15s; }++.ovk-sidebar-link .ovk-i { width: 19px; height: 19px; flex: none; }++.ovk-sidebar-link span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovk-sidebar-link:hover { background: var(--ovk-panel-2); color: var(--ovk-text); }++.ovk-sidebar-link.is-here { background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); color: var(--ovk-text); }++.ovk-sidebar-link.is-here .ovk-i { color: var(--ovk-accent); }++.ovk-sidebar-link:focus-visible, .ovk-sidebar-item:focus-visible, .ovk-sidebar-action:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: -2px; }++.ovk-sidebar-count { min-width: 20px; box-sizing: border-box; padding: 1px 6px; border-radius: 999px; background: var(--ovk-fill); color: var(--ovk-on-fill); font-size: 11.5px; font-weight: 700; text-align: center; }++.ovk-sidebar-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--ovk-accent); }++.ovk-sidebar-dot.is-busy { animation: ovk-sidebar-breathe 1.6s ease-in-out infinite; }++.ovk-sidebar-dot.is-needs { background: var(--ovk-warn); }++.ovk-sidebar-dot.is-bad { background: var(--ovk-bad); }++@keyframes ovk-sidebar-breathe { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.75); } }++.ovk-sidebar-secs { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; margin: 0 -4px; padding: 0 4px 8px; }++.ovk-sidebar-h { margin: 12px 10px 4px; font-size: 11.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--ovk-muted); }++.ovk-sidebar-item { display: flex; align-items: center; gap: 9px; min-height: 36px; padding: 4px 10px; border-radius: var(--ov-radius-1); color: var(--ovk-text); font-size: 14px; text-decoration: none; }++.ovk-sidebar-item:hover { background: var(--ovk-panel-2); }++.ovk-sidebar-item.is-here { background: var(--ovk-panel-2); font-weight: 600; }++.ovk-sidebar-t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovk-sidebar-m { flex: none; color: var(--ovk-muted); font-size: 12px; }++/* A row with a quiet second line (4.102.0): a conversation's last message under its name, the mark beside both lines. */++.ovk-sidebar-item.has-sub { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; column-gap: 9px; row-gap: 1px; padding-block: 6px; }++.ovk-sidebar-item.has-sub.has-mark { grid-template-columns: auto minmax(0, 1fr) auto auto; }++.ovk-sidebar-item.has-sub.has-mark > :first-child { grid-row: 1 / span 2; }++.ovk-sidebar-s { grid-column: 1 / -1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ovk-muted); font-size: 12.5px; font-weight: 400; }++.ovk-sidebar-item.has-sub.has-mark .ovk-sidebar-s { grid-column: 2 / -1; }++.ovk-sidebar-empty { margin: 8px 10px; color: var(--ovk-muted); font-size: 13.5px; }++.ovk-sidebar-foot { padding-top: 8px; border-top: 1px solid var(--ovk-line); }++@media (max-width: 760px) {++ .ovk-with-sidebar { grid-template-columns: minmax(0, 1fr); }++ .ovk-sidebar { position: fixed; z-index: 10060; top: 0; left: 0; bottom: 0; height: auto; width: min(320px, 86vw); padding-top: max(12px, env(safe-area-inset-top, 0px)); padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)); border-right: 0; translate: -100% 0; visibility: hidden; transition: translate .25s ease, visibility 0s linear .25s; box-shadow: 12px 0 32px rgba(0, 0, 0, .25); }++ .ovk-sidebar.is-open { translate: 0 0; visibility: visible; transition: translate .25s ease; }++ .ovk-sidebar-close, .ovk-sidebar-open { display: inline-grid; }++ .ovk-sidebar-scrim { position: fixed; inset: 0; z-index: 10050; background: rgba(0, 0, 0, .4); }++}++@media (prefers-reduced-motion: reduce) { .ovk-sidebar, .ovk-sidebar.is-open { transition: none; } .ovk-sidebar-dot.is-busy { animation: none; } }++++/* ── Scroll areas (4.69.0; the owner, 2026-11-01: "you can't scroll down the page even when you're at the bottom of++ the chat… this should be consistent across websites"). The network's rule: a scroll area inside a page that scrolls++ hands the swipe on to the page at its ends; only an area that fills the screen (a sheet, a modal, a full-screen++ chat) holds it. A sideways rail scrolls sideways only, so a vertical swipe on it moves the page. */++.ov-scroll { overflow: auto; overscroll-behavior: auto; -webkit-overflow-scrolling: touch; }++.ov-scroll-fill { overflow: auto; overscroll-behavior: contain; }++.ov-scroll-x { overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; touch-action: pan-x pan-y; scrollbar-width: none; }++.ov-scroll-x::-webkit-scrollbar { display: none; }++@media (max-width: 900px), (hover: none) {++ .ovk-sidebar-secs { overscroll-behavior-y: auto; }++}++++/* ── Placeholders shaped like the content (4.70.0, kit.skeleton; plan L52) ── A soft light passes over grey shapes the size of++ what is coming; nothing spins; still under reduced motion and Calm. A list paints these in its first HTML and fills them in++ place. */++.ovk-skel { display: block; border-radius: max(0px, calc(var(--ov-radius-1) - 2px)); background: color-mix(in srgb, var(--text-primary, #e8edf3) 7%, transparent); background-image: linear-gradient(100deg, transparent 30%, color-mix(in srgb, var(--text-primary, #e8edf3) 9%, transparent) 50%, transparent 70%); background-size: 220% 100%; background-repeat: no-repeat; animation: ovkShimmer 1.6s ease-in-out infinite; }++@keyframes ovkShimmer { from { background-position: 120% 0; } to { background-position: -120% 0; } }++.ovk-skels { display: grid; gap: 12px; }++.ovk-skels-card { grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); }++.ovk-skels-tile { grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); }++.ovk-skels-chip { display: flex; flex-wrap: wrap; gap: 8px; }++.ovk-skel-card { display: grid; gap: 8px; }++.ovk-skel-thumb { aspect-ratio: 16 / 9; border-radius: calc(var(--ov-radius-1) + 2px); }++.ovk-skel-l1 { height: 13px; width: 86%; }++.ovk-skel-l2 { height: 11px; width: 58%; }++.ovk-skel-row { display: flex; align-items: center; gap: 12px; }++.ovk-skel-avatar { flex: none; width: 40px; height: 40px; border-radius: 50%; }++.ovk-skel-lines { flex: 1; display: grid; gap: 7px; min-width: 0; }++.ovk-skel-tile { aspect-ratio: 1; border-radius: var(--ov-radius-2); }++.ovk-skel-chip { width: 84px; height: 30px; border-radius: 999px; }++.ovk-skel-line { height: 12px; }++.ovk-skel-chat { display: flex; align-items: center; gap: 8px; }++.ovk-skel-dot { flex: none; width: 22px; height: 22px; border-radius: 50%; }++.ovk-skel-stat { display: grid; gap: 6px; padding: 12px; border-radius: var(--ov-radius-2); border: 1px solid var(--border, rgba(255,255,255,.08)); }++.ovk-skel-num { height: 22px; width: 48%; }++@media (prefers-reduced-motion: reduce) { .ovk-skel { animation: none; } }++html[data-ov-motion=reduced] .ovk-skel { animation: none; }++++/* ── Moving between pages (4.70.0, plan L52) ── A server-rendered site's pages cross-fade while the navbar and the app bar++ stay put (cross-document view transitions; a browser without them just loads the page). Off under reduced motion. */++@view-transition { navigation: auto; }++::view-transition-old(root), ::view-transition-new(root) { animation-duration: .18s; }++#navbar-mount, .openvibe-navbar { view-transition-name: ov-navbar; }++.ovnav-appbar { view-transition-name: ov-appbar; }++@media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }++++/* Content labels (L1): quiet chips beside a title, the adult one in the warning colour. They say what a thing carries;++ each viewer's own choice (ov-labels.js) decides whether it shows, blurs or hides. */++.ovk-labels { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 0; padding: 0; list-style: none; }++.ovk-label { display: inline-flex; align-items: center; min-height: 20px; padding: 0 8px; border-radius: 999px; font-size: .7rem; font-weight: 700; letter-spacing: .02em;++ color: var(--text-secondary, #a8b3c7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); border: 1px solid var(--border, rgba(255,255,255,.1)); }++.ovk-label--adult { color: var(--warning-text, #fbbf24); border-color: color-mix(in srgb, var(--warning-text, #fbbf24) 45%, transparent); background: color-mix(in srgb, var(--warning-text, #fbbf24) 9%, transparent); }nnew file mode 100644@@@ -0,0 +1,126 @@++/*++ * openvibe-shared/notification-live.js — the notification badge's realtime feed (roadmap WS-E task 3, WS-F task 1;++ * ADR-005 amendment 2). notification-ui.js loads it on demand when a site turns it on (navbar notificationsRealtime).++ * For every (re)connect it asks Network for a realtime ticket (POST <api>/api/v1/realtime/ticket: Bearer token, or the++ * cookie on openvibe.network itself) and opens Events' stream with it, without credentials:++ * <stream_url>?topics=network.notification.*&ticket=<ticket>[&last_event_id=<cursor>]++ * Events streams only the person's own network.notification.created events (subject visibility); one whose subject is++ * not the ticket's is ignored all the same. Each calls onNotification(payload); `event: gap` calls onGap.++ * The cursor is the last SSE id Events sent (opaque, handed back as it came); an event already heard (by event_id) is++ * skipped. A ticket opens one stream: errors close it and reconnect with a fresh ticket from the cursor, backing off 2 s to 15 min++ * with jitter. Stops (the caller's polling is all there is) after 10 failures in a row, on a Content-Security-Policy++ * refusal, when signed out (401/403) or when Network has realtime off (404/503). Hidden 5 min: closed; shown: resumed.++ * OVNotificationLive.create({ ticketUrl, token, credentials, onNotification, onGap, onState }) → { start, stop, restart, state }++ */++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OVNotificationLive) return;++ const TYPE_RE = /^network\.notification\./;++ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++ const MAX_FAILURES = 10; // 2 s, 4 s, … 512 s: about 9 to 17 minutes of retries before polling only++ const SEEN_MAX = 256; // event ids remembered for dedupe++++ function create(o) {++ const rt = { state: 'off', failures: 0, lastId: null, subject: null, origin: null, events: 0, ignored: 0, gaps: 0, tickets: 0 };++ let es = null; let stopped = true; let gen = 0;++ const seen = new Set();++ const t = {}; // timers: retry, hide++ const later = (k, f, ms) => { root.clearTimeout(t[k]); t[k] = root.setTimeout(() => { t[k] = null; f(); }, ms); };++ const call = (f, ...a) => { try { if (typeof f === 'function') f(...a); } catch { /* the page's handler must not break the feed */ } };++ function set(s) { if (rt.state !== s) { rt.state = s; call(o.onState, s); } }++ function close() { const s = es; es = null; if (s) try { s.close(); } catch { /* */ } }++ function halt(s) { close(); root.clearTimeout(t.retry); t.retry = null; set(s); }++++ async function open() {++ if (stopped || es || rt.state === 'ticket') return;++ if (document.hidden) { set('hidden'); return; }++ const g = gen;++ set('ticket');++ let tk;++ try {++ const tok = typeof o.token === 'function' ? await o.token() : o.token;++ const r = await root.fetch(o.ticketUrl, { method: 'POST', credentials: o.credentials || 'omit', cache: 'no-store', headers: tok ? { Authorization: `Bearer ${tok}` } : {} });++ if (g !== gen) return;++ if (r.status === 401 || r.status === 403) return halt('signed-out');++ if (r.status === 404 || r.status === 501 || r.status === 503) return halt('unavailable');++ if (!r.ok) throw new Error('ticket');++ tk = await r.json();++ if (!tk || typeof tk.ticket !== 'string' || !SUBJECT_RE.test(String(tk.subject)) || !Array.isArray(tk.topics)) throw new Error('ticket');++ } catch {++ if (g === gen) fail();++ return;++ }++ if (g !== gen) return;++ let u;++ try {++ u = new URL(tk.stream_url);++ if (!/^https?:$/.test(u.protocol)) throw new Error('url');++ u.searchParams.set('topics', tk.topics.join(','));++ u.searchParams.set('ticket', tk.ticket);++ if (rt.lastId != null) u.searchParams.set('last_event_id', rt.lastId);++ } catch { fail(); return; }++ rt.tickets++; rt.origin = u.origin;++ rt.subject = tk.subject;++ set('connecting');++ let s;++ try { s = es = new root.EventSource(u.href); } catch { es = null; fail(); return; }++ s.onopen = () => { if (es === s) { rt.failures = 0; set('open'); } };++ s.onmessage = (e) => { if (es === s) heard(e); };++ s.onerror = () => { if (es === s) fail(); };++ s.addEventListener('gap', (e) => {++ if (es !== s) return;++ let gap = null; try { gap = JSON.parse(e.data); } catch { /* */ }++ rt.gaps++;++ call(o.onGap, gap);++ });++ }++ function heard(e) {++ let m; try { m = JSON.parse(e.data); } catch { return; }++ const ev = m && m.event;++ const id = ev && typeof ev.event_id === 'string' ? ev.event_id : null;++ if (id) {++ if (seen.has(id)) return; // already heard (a replay after a reconnect)++ seen.add(id);++ if (seen.size > SEEN_MAX) seen.delete(seen.values().next().value);++ }++ if (e.lastEventId) rt.lastId = String(e.lastEventId);++ // Events already sends only this person's own; anything else is never shown.++ if (!ev || !TYPE_RE.test(String(ev.event_type)) || !ev.subject || ev.subject.type !== 'user' || ev.subject.id !== rt.subject) { rt.ignored++; return; }++ rt.events++;++ call(o.onNotification, ev.payload && typeof ev.payload === 'object' ? ev.payload : {}, ev);++ }++ // A ticket opens one stream, so the browser's own reconnect (same URL) is never used: close, back off, new ticket.++ function fail() {++ close();++ if (stopped || rt.state === 'blocked') return;++ if (++rt.failures >= MAX_FAILURES) { set('failed'); return; }++ set('backoff');++ later('retry', () => { if (!stopped) { if (document.hidden) set('hidden'); else open(); } }, Math.min(9e5, 2e3 * 2 ** (rt.failures - 1)) * (0.5 + Math.random() / 2));++ }++++ // A Content-Security-Policy whose connect-src leaves Events out: one refusal, then polling only.++ document.addEventListener('securitypolicyviolation', (e) => {++ if (stopped || !rt.origin || String(e.blockedURI || '').indexOf(rt.origin) !== 0) return;++ halt('blocked');++ });++ document.addEventListener('visibilitychange', () => {++ if (stopped) return;++ if (document.hidden) { if (es) later('hide', () => { if (document.hidden && es) { close(); set('hidden'); } }, 5 * 60 * 1000); return; }++ root.clearTimeout(t.hide); t.hide = null;++ if (rt.state === 'hidden') open();++ });++ root.addEventListener('online', () => { if (!stopped && (rt.state === 'failed' || rt.state === 'backoff')) { rt.failures = 0; root.clearTimeout(t.retry); set('off'); open(); } });++++ const api = {++ start() { if (!stopped) return api; stopped = false; set('off'); open(); return api; },++ stop() { stopped = true; gen++; close(); Object.keys(t).forEach((k) => { root.clearTimeout(t[k]); t[k] = null; }); set('off'); return api; },++ /** Another account (or a new token): forget the cursor and the subject, start again. */++ restart() { api.stop(); rt.lastId = null; seen.clear(); rt.subject = null; rt.failures = 0; return api.start(); },++ state: () => ({ ...rt }),++ };++ return api;++ }++++ root.OVNotificationLive = { create };++ if (typeof module !== 'undefined' && module.exports) module.exports = root.OVNotificationLive;++})(typeof window !== 'undefined' ? window : globalThis);@@@ -65,7 +65,9 @@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"/>',++ bell: '<path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15ZM10 20a2 2 0 0 0 4 0"/>',++ // The set's solid bell (openvibe-shared/glyphs): shown where the person prefers solid icons (4.71.0).++ bellSolid: '<path d="M5.8 16.2V11a6.2 6.2 0 0 1 12.4 0v5.2l1.7 2.3H4.1ZM9.9 19.6h4.2a2.1 2.1 0 0 1-4.2 0Z"/>',key: '<circle cx="8.2" cy="14.8" r="4"/><path d="M11.1 12l8.2-8.2M16.4 6.7l2.3 2.3M14.2 8.9l1.8 1.8"/>',spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',};@@@ -116,7 +118,7 @@@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); } }++ @keyframes ovn-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-4px); opacity: 1; } }/* 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;@@@ -164,7 +166,8 @@.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; }++ html[data-ov-icons=solid] .openvibe-bell svg.has-s { stroke-width: 0; } html[data-ov-icons=solid] .openvibe-bell svg > .ovg-s { display: inline; fill: currentColor; stroke: none; } .openvibe-bell svg > .ovg-s { display: none; }++ .openvibe-bell svg { color: var(--text-secondary, #b0b0b8); width: 22px; height: 22px; fill: none; stroke: var(--text-secondary, #b0b0b8); stroke-width: 1.9; 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;@@@ -280,7 +283,9 @@.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-more .spinner { position: relative; display: inline-block; width: 6px; height: 6px; margin: 0 10px; border: 0; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovn-hop 1s ease-in-out .15s infinite; }++ .openvibe-nv-more .spinner::before, .openvibe-nv-more .spinner::after { content: ''; position: absolute; top: 0; width: 6px; height: 6px; border-radius: 50%; background: inherit; animation: ovn-hop 1s ease-in-out infinite; }++ .openvibe-nv-more .spinner::before { left: -10px; } .openvibe-nv-more .spinner::after { left: 10px; animation-delay: .3s; }.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); }@@@ -783,7 +788,7 @@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.innerHTML = `<svg class="ovnav-ovg has-s" viewBox="0 0 24 24" aria-hidden="true">${GLYPH.bell}<g class="ovg-s">${GLYPH.bellSolid}</g></svg><span class="badge hidden">0</span>`;bell.addEventListener('click', () => togglePanel());bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } });_bellEl = bell;@@@ -1,92 +1,184 @@/**-- * 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.++ * ov-mark.js — the OpenVibe mark as a drop-in.++ * <span class="ov-mark"></span> → a 32px mark (the box is square, the mark centred in it)++ * <span class="ov-mark" data-size="56"> → any size (the box's width); add data-static="1" for no motion++ * <span class="ov-mark" data-wide> → the box hugs the mark (92 × 56) instead of a square++ * <span class="ov-mark" data-variant="live"> → the site's own touch (Live's record light in the O, see below)++ * <span class="ov-mark" data-tone="ink|white"> → one colour: the text's (stamps, UI) or white (on a colour tile)++ * <span class="ov-mark" data-form="heavy"> → force a form (default: heavy up to 22px wide, the pair above)++ * data-state="busy|ok|error|no|notify|live|sleep" → what it shows (island.js sets busy/ok/error on the navbar's)++ * data-progress="0.4" with style="--ovm-p:.4" → a known progress: the ring draws round to that much++ * OVMark.busy(button) → { done, fail, reset }: a button at work shows the mark busy, then the tick or the "!". A form++ * posted the old way (POST) does this by itself until the next page; data-ov-busy="off" on a form opts out.++ * Self-contained: injects its own CSS once, works on any page (Live, Network, static pages). The same mark as static++ * SVG, for servers and icons: openvibe-shared/mark (mark.js), whose numbers this file draws.*-- * 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 mark is the Pair (the owner, 2026-10-31: "Pair, halo … this should be our new logo"): a ring O and a round V,++ * with a gap of the ground around the V where it crosses the ring. The gap is a mask, so the mark sits on any ground.++ * The V is as tall as the O, so it sits level beside a word in the navbar and centred in a tile. Two forms: the pair,++ * and the heavy (thicker strokes) for 22px and under, where the pair's gap would close.++ *++ * Everything it does means something, and nothing spins:++ * - intro: the O rises and draws itself round, the V drops in on a spring, then a glint of light crosses the mark;++ * - hover: the V leans back, the O hops, a glint crosses and a soft glow of its two colours comes up behind it;++ * - press: it gives a little and springs back;++ * Each stroke runs light to deep in its own colour (data-glow keeps the glow on, for heroes and brand pages).++ * - a page on its way (navbar): the ring empties and draws itself round again as the next page loads, paced by how++ * long this site's pages usually take in this browser (learned per site, boost moves and full loads apart); the++ * V nods while it waits and hops when the page is here (window.OVMark.navStart/navEnd for single-page apps);++ * - busy: the V bobs and the O breathes, in turn; ok: the V becomes a tick, green, and hops (the tick only ever++ * means "done"); error: the V becomes "!", red, and the mark shakes; no: the V becomes "×", grey, and the O++ * shakes its head; notify: a dot pops up by the V and the O bounces; live: the O is the record light, red, and++ * ripples go out from it; sleep: the O closes like an eye and the V sinks.++ *++ * 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 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 a state, a page load or++ * progress. Otherwise its CSS animations are paused where they are. It never moves while the page is hidden, while it++ * is off-screen (IntersectionObserver), with data-static, under prefers-reduced-motion or under the network's own++ * reduced-motion setting (html[data-ov-motion=reduced], theme-loader.js); its colours and shapes still change.** 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.++ * themes off, on any theme): the mark wears it, the O a pumpkin with its stem and leaf in October, caps of snow in++ * December. Now and then (every 4 to 8 minutes) a mark on screen flickers for a moment, and on the first visit of the++ * day a few leaves (snowflakes in December) drift down from the first mark seen. Never under either reduced-motion setting.*/(function () {'use strict';if (window.__ovMark) return; window.__ovMark = true;let n = 0;++ const f = (v) => +v.toFixed(2);++ // The Pair in a 100-unit box, centred on (50, 50) (openvibe-shared/mark: the same numbers; test/mark.test.js).++ function pair(r, sw, vsw, a = 0.86, t = 1.5, b = 2.16) {++ const R = r + sw / 2, right = b * R + vsw / 2, cx = 50 - (right - R) / 2, cy = 50;++ const A = [f(cx + a * R), f(cy - R + vsw / 2)], T = [f(cx + t * R), f(cy + R - vsw / 2)], B = [f(cx + b * R), f(cy - R + vsw / 2)];++ const ring = `M${f(cx)} ${f(cy - r)}A${r} ${r} 0 1 1 ${f(cx)} ${f(cy + r)}A${r} ${r} 0 1 1 ${f(cx)} ${f(cy - r)}`;++ const x0 = f(cx - R), x1 = f(B[0] + vsw / 2), pad = 2, w = f(x1 - x0 + 2 * pad);++ const box = `${f(50 - w / 2)} ${f(50 - w / 2)} ${w} ${w}`, wideBox = `${f(50 - w / 2)} ${f(50 - R - pad)} ${w} ${f(2 * R + 2 * pad)}`;++ // The V's two strokes, each from its first point, for every shape it takes.++ const vw = B[0] - A[0], vh = T[1] - A[1], mx = A[0] + vw / 2, my = A[1] + vh * 0.55, s = Math.min(vw, vh) * 0.42, tick = [A[0] + vw * 0.46, T[1]];++ const shapes = {++ v: [T[0], T[1], A[0], A[1], T[0], T[1], B[0], B[1]],++ tip: [T[0], T[1], T[0], T[1], T[0], T[1], T[0], T[1]],++ ok: [tick[0], tick[1], A[0] + vw * 0.16, A[1] + vh * 0.52, tick[0], tick[1], B[0] + vw * 0.06, A[1]],++ no: [mx - s + vw * 0.1, my - s, mx + s + vw * 0.1, my + s, mx - s + vw * 0.1, my + s, mx + s + vw * 0.1, my - s],++ err: [mx, A[1], mx, T[1] - vsw * 1.28, mx, T[1], mx, T[1] + 0.01],++ };++ for (const k in shapes) shapes[k] = shapes[k].map(f);++ return { cx: f(cx), cy, r, sw, R: f(R), vsw, halo: vsw + 7, A, T, B, ring, box, wideBox, shapes };++ }++ const FORMS = { pair: pair(20, 13, 13), heavy: pair(18, 18, 17) };++ const formOf = (v) => (v === 'heavy' || v === 'solid' ? 'heavy' : 'pair');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)}}++@property --ovm-fill{syntax:'<number>';inherits:true;initial-value:1}++.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;flex:none;vertical-align:middle;line-height:0;--ovm-o:#4f7cff;--ovm-v:#8b5cf6;--ovm-fill:1;transition:--ovm-fill .55s cubic-bezier(.3,.8,.2,1)}++.ov-mark[data-wide]{height:auto;aspect-ratio:94/57}++.ov-mark svg{width:100%;height:100%;overflow:visible}++.ov-mark .m,.ov-mark .og,.ov-mark .vg{transform-box:view-box}++.ov-mark .m{transform-origin:50px 50px}++.ov-mark .o,.ov-mark .ot{fill:none;stroke:var(--ovm-o);transition:stroke .35s}++.ov-mark .ot{opacity:0;transition:opacity .3s,stroke .35s}++.ov-mark .va,.ov-mark .vb{fill:none;stroke:var(--ovm-v);stroke-linecap:round;stroke-linejoin:round;transition:stroke .35s,opacity .5s}++.ov-mark .ha,.ov-mark .hb{fill:none;stroke:#000;stroke-linecap:round}++/* Depth (4.65.0): each stroke runs from a lighter to a deeper tone of its own colour, so states, seasons, tones and a++ page's own colour still decide it. Without color-mix the strokes are flat. */++.ov-mark .g1,.ov-mark .g2{stop-color:var(--ovm-o);transition:stop-color .35s}.ov-mark .g3,.ov-mark .g4{stop-color:var(--ovm-v);transition:stop-color .35s}++@supports (color:color-mix(in srgb,red 50%,blue)){.ov-mark .g1{stop-color:color-mix(in srgb,var(--ovm-o) 68%,#fff)}.ov-mark .g2{stop-color:color-mix(in srgb,var(--ovm-o) 84%,#000)}.ov-mark .g3{stop-color:color-mix(in srgb,var(--ovm-v) 70%,#fff)}.ov-mark .g4{stop-color:color-mix(in srgb,var(--ovm-v) 86%,#000)}++.ov-mark[data-tone] .g1,.ov-mark[data-tone] .g2{stop-color:var(--ovm-o)}.ov-mark[data-tone] .g3,.ov-mark[data-tone] .g4{stop-color:var(--ovm-v)}}++/* The glint: a band of light crosses the mark (its own shape is the mask) on the intro and on hover. */++.ov-mark .gr{transform-box:view-box;transform-origin:50px 50px;transform:translateX(-80px) skewX(-20deg)}++/* The glow: a soft light of the mark's two colours behind it, on hover; always on with data-glow (heroes, brand pages). */++.ov-mark{position:relative;isolation:isolate}++.ov-mark::before{content:"";position:absolute;inset:6%;z-index:-1;border-radius:50%;pointer-events:none;background:radial-gradient(closest-side at 36% 50%,var(--ovm-o),transparent),radial-gradient(closest-side at 70% 50%,var(--ovm-v),transparent);filter:blur(8px);opacity:0;transform:scale(.7);transition:opacity .45s,transform .6s cubic-bezier(.3,.8,.2,1)}++.ov-mark[data-glow]::before{opacity:.42;transform:none}++a:hover>.ov-mark::before,button:hover>.ov-mark::before,.ov-mark:hover::before,a:focus-visible>.ov-mark::before{opacity:.55;transform:scale(1.14)}++.ov-mark[data-tone]::before,.ov-mark[data-state=sleep]::before{display:none}++/* A press: the mark gives a little and springs back. */++.ov-mark .m{transition:transform .42s cubic-bezier(.34,1.56,.64,1)}++a:active>.ov-mark .m,button:active>.ov-mark .m,.ov-mark:active .m{transform:scale(.9);transition-duration:.1s}++.ov-mark .cd{display:none;fill:var(--ovm-dot,#ff3b5c);transform-box:fill-box;transform-origin:center}++.ov-mark .nd{fill:var(--danger,#ef4444);opacity:0;transform:scale(0);transform-box:fill-box;transform-origin:center;transition:opacity .2s,transform .42s cubic-bezier(.34,1.56,.64,1)}++.ov-mark .rp{fill:none;stroke:#ff3b5c;stroke-width:3;opacity:0;transform-box:fill-box;transform-origin:center}++.ov-mark[data-tone=ink]{--ovm-o:currentColor;--ovm-v:currentColor}++.ov-mark[data-tone=white]{--ovm-o:#fff;--ovm-v:#fff}++/* The season: the O is a pumpkin in October (states below still colour it). */.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}++/* As weak as the base rule, so a state, a tone or a page's own colour (a brand page) wins over the season. */++:where(html[data-ov-season-mark=halloween]) .ov-mark{--ovm-o:#f97316}++.ov-mark .ss{fill:none;stroke:#5fae3c;stroke-width:4.4;stroke-linecap:round}++.ov-mark .sl{fill:#7fd04e;transform-box:fill-box;transform-origin:0 80%}++.ov-mark .sc{fill:none;stroke:#f4fbff;stroke-width:5.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}++/* Hover and focus: the V leans back and the O hops. */++a:hover>.ov-mark:not([data-state]) .vg,button:hover>.ov-mark:not([data-state]) .vg,.ov-mark:not([data-state]):hover .vg,a:focus-visible>.ov-mark:not([data-state]) .vg{animation:ovmLean .62s ease-out 1}++a:hover>.ov-mark:not([data-state]) .og,button:hover>.ov-mark:not([data-state]) .og,.ov-mark:not([data-state]):hover .og,a:focus-visible>.ov-mark:not([data-state]) .og{animation:ovmHopO .62s ease-out 1}++a:hover>.ov-mark .gr,button:hover>.ov-mark .gr,.ov-mark:hover .gr,a:focus-visible>.ov-mark .gr{animation:ovmGlint .8s cubic-bezier(.45,0,.25,1) .05s 1}++/* The intro: the O rises and draws itself round, the V drops in on a spring, and a glint crosses the two. */++.ov-mark[data-ovm-intro] .og{animation:ovmRise .6s cubic-bezier(.34,1.4,.64,1) backwards}++.ov-mark[data-ovm-intro] .o{animation:ovmDraw .8s cubic-bezier(.65,0,.25,1) backwards}++.ov-mark[data-ovm-intro] .vg{animation:ovmDrop .62s cubic-bezier(.34,1.56,.64,1) .32s backwards}++.ov-mark[data-ovm-intro] .gr{animation:ovmGlint .9s cubic-bezier(.45,0,.25,1) .8s backwards}++html[data-ov-season-mark] .ov-mark[data-ovm-intro] .sl{animation:ovmLeaf .9s cubic-bezier(.3,1.6,.5,1) .5s both}++/* A page on its way, or a known progress: the ring draws itself round to the level over a faint track. */++.ov-mark[data-nav],.ov-mark[data-progress]{transition:--ovm-fill .3s linear}++.ov-mark[data-progress]{--ovm-fill:var(--ovm-p,0)}++.ov-mark[data-nav] .o,.ov-mark[data-progress] .o{stroke-dasharray:100 100;stroke-dashoffset:calc((1 - var(--ovm-fill)) * 100px)}++.ov-mark[data-nav] .ot,.ov-mark[data-progress] .ot{opacity:.24}++.ov-mark[data-nav]:not([data-ovm-arrive]) .vg{animation:ovmNod .9s ease-in-out infinite}++.ov-mark[data-ovm-arrive] .vg{animation:ovmHop .5s ease-out 1}++.ov-mark[data-ovm-arrive] .og{animation:ovmPulse .5s ease-out 1}++/* The states. */++.ov-mark[data-state=busy] .vg{animation:ovmBob .9s ease-in-out infinite}++.ov-mark[data-state=busy] .og{animation:ovmBreathe .9s ease-in-out .45s infinite}++.ov-mark[data-state=ok]{--ovm-o:var(--success,#22c55e);--ovm-v:var(--success,#22c55e)}++.ov-mark[data-state=ok] .vg{animation:ovmHop .56s ease-out .14s 1 both}++.ov-mark[data-state=error]{--ovm-o:var(--danger,#ef4444);--ovm-v:var(--danger,#ef4444)}++.ov-mark[data-state=error] .m{animation:ovmShake .56s ease-in-out 1}++.ov-mark[data-state=no]{--ovm-o:var(--text-muted,#94a3b8);--ovm-v:var(--text-muted,#94a3b8)}++.ov-mark[data-state=no] .og{animation:ovmNo .52s ease-in-out .1s 1}++.ov-mark[data-state=notify] .nd{opacity:1;transform:scale(1)}++.ov-mark[data-state=notify] .og{animation:ovmBounce .76s ease-out .14s 1}++.ov-mark[data-state=live]{--ovm-o:#ff3b5c}++.ov-mark[data-state=live] .og{animation:ovmPulse 1.6s ease-in-out infinite}++.ov-mark[data-state=live] .rp{animation:ovmRipple 1.6s ease-out infinite}++.ov-mark[data-state=live] .rp2{animation-delay:.8s}++.ov-mark[data-state=sleep] svg{filter:grayscale(1);opacity:.7}++.ov-mark[data-state=sleep] .og{transform:scaleY(.14);transition:transform .52s ease-in-out}++.ov-mark[data-state=sleep] .vg{transform:translateY(4px);transition:transform .52s ease-in-out}++.ov-mark[data-state=sleep] .va,.ov-mark[data-state=sleep] .vb{opacity:.5}++/* Per-site touches: one family, each site with its own light in the O and its own pace. */++.ov-mark[data-variant=live] .cd,.ov-mark[data-variant=vip] .cd,.ov-mark[data-variant=tips] .cd,.ov-mark[data-variant=deals] .cd,.ov-mark[data-variant=coupons] .cd{display:inline}++.ov-mark[data-variant=live][data-ovm-run]:not([data-state]) .cd{animation:ovmOnAir 1.4s ease-in-out infinite}++.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=games][data-state=busy] .vg,.ov-mark[data-variant=games][data-state=busy] .og{animation-duration:.6s}++.ov-mark[data-variant=chat][data-state=busy] .vg,.ov-mark[data-variant=chat][data-state=busy] .og{animation-duration:.55s}++.ov-mark[data-variant=codes][data-state=busy] .vg{animation-timing-function:steps(4,end)}++.ov-mark[data-variant=blog][data-state=busy] .vg,.ov-mark[data-variant=wiki][data-state=busy] .vg,.ov-mark[data-variant=news][data-state=busy] .vg,.ov-mark[data-variant=blog][data-state=busy] .og,.ov-mark[data-variant=wiki][data-state=busy] .og,.ov-mark[data-variant=news][data-state=busy] .og{animation-duration:1.2s}++@keyframes ovmRise{from{transform:scale(.55);opacity:0}}++@keyframes ovmDraw{from{stroke-dasharray:100 100;stroke-dashoffset:100}to{stroke-dasharray:100 100;stroke-dashoffset:0}}++@keyframes ovmDrop{from{transform:translateY(-18px) scale(.5);opacity:0}}++@keyframes ovmGlint{from{transform:translateX(-80px) skewX(-20deg)}to{transform:translateX(80px) skewX(-20deg)}}++@keyframes ovmLean{0%,100%{transform:rotate(0)}45%{transform:rotate(-11deg)}}++@keyframes ovmHopO{0%,100%{transform:translateY(0)}45%{transform:translateY(-3px)}}++@keyframes ovmNod{0%,100%{transform:rotate(0)}50%{transform:rotate(-7deg)}}++@keyframes ovmHop{0%,100%{transform:translateY(0) scaleY(1)}35%{transform:translateY(-9px) scaleY(1.04)}70%{transform:translateY(0) scaleY(.96)}}++@keyframes ovmBob{0%,100%{transform:translateY(0)}50%{transform:translateY(6px)}}++@keyframes ovmBreathe{0%,100%{transform:scale(1)}50%{transform:scale(.88)}}++@keyframes ovmPulse{0%,100%{transform:scale(1)}50%{transform:scale(1.06)}}++@keyframes ovmShake{0%,100%{transform:translateX(0)}15%{transform:translateX(-7px)}30%{transform:translateX(7px)}45%{transform:translateX(-6px)}60%{transform:translateX(6px)}80%{transform:translateX(-3px)}}++@keyframes ovmNo{0%,100%{transform:translateX(0)}20%{transform:translateX(-4px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)}}++@keyframes ovmBounce{0%,80%,100%{transform:translateY(0)}20%{transform:translateY(-6px)}40%{transform:translateY(0)}60%{transform:translateY(-4px)}}++@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.8}50%{transform:scale(1.3);opacity:1}}++@keyframes ovmRipple{0%{transform:scale(1);opacity:.7}100%{transform:scale(1.9);opacity:0}}++.ov-mark[data-static] *{animation:none!important}++.ov-mark[data-ov]:not([data-ovm-run]) *{animation-play-state:paused!important}.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}++.ov-mark[data-ovm-flourish] .vg{animation:ovmLean .7s ease-out 1}.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%}@@@ -94,22 +186,59 @@ html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:@keyframes ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}}@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}@keyframes ovmFall{0%{opacity:0;transform:none}12%{opacity:1}72%{opacity:1;transform:translate(var(--x),var(--y)) rotate(var(--r))}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}--@media (prefers-reduced-motion:reduce){.ov-mark 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>`;++@media (prefers-reduced-motion:reduce){.ov-mark *{animation:none!important}.ov-mark,.ov-mark *,.ov-mark::before{transition:none!important}.ovm-fall{display:none}}++html[data-ov-motion=reduced] .ov-mark *{animation:none!important}++.ov-busy{cursor:progress;pointer-events:none}++.ov-busy-mark{display:inline-block;vertical-align:-.2em;margin-right:.45em;flex:none}++`;++ const seg = (p, i) => `M${f(p[i])} ${f(p[i + 1])}L${f(p[i + 2])} ${f(p[i + 3])}`;++ function svg(id, G, wide) {++ const p = G.shapes.v, oc = `transform-origin:${G.cx}px ${G.cy}px`, vc = `transform-origin:${G.T[0]}px ${G.T[1]}px`;++ const ri = G.r - G.sw / 2, top = G.cy - G.R;++ // The season: a pumpkin's stem and leaf on the O; snow on the O and on the V's caps.++ const stem = `M${f(G.cx + 0.6)} ${f(top + 2.5)}c.3-4.6 1.6-8 5-10.6`, leaf = `M${f(G.cx + 5.2)} ${f(top - 7.4)}c4.4-4.8 11-5.2 15.8-2.4-3.6 5.2-10.4 6.8-15.8 2.4z`;++ const capR = f(G.r + G.sw / 2 - 1.6), at = (deg) => [f(G.cx + capR * Math.cos(deg * Math.PI / 180)), f(G.cy + capR * Math.sin(deg * Math.PI / 180))];++ const c0 = at(-150), c1 = at(-40);++ return `<svg viewBox="${wide ? G.wideBox : G.box}" aria-hidden="true"><defs>++<mask id="${id}h" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/><g class="vg" style="${vc}"><path class="ha" d="${seg(p, 0)}" stroke-width="${G.halo}"/><path class="hb" d="${seg(p, 4)}" stroke-width="${G.halo}"/></g></mask>++<linearGradient id="${id}go" gradientUnits="userSpaceOnUse" x1="${f(G.cx - G.R)}" y1="${f(G.cy - G.R)}" x2="${f(G.cx + G.R)}" y2="${f(G.cy + G.R)}"><stop class="g1" offset="0"/><stop class="g2" offset="1"/></linearGradient>++<linearGradient id="${id}gv" gradientUnits="userSpaceOnUse" x1="0" y1="${G.A[1]}" x2="0" y2="${G.T[1]}"><stop class="g3" offset="0"/><stop class="g4" offset="1"/></linearGradient>++<linearGradient id="${id}gg"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".8"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>++<mask id="${id}s" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><g mask="url(#${id}h)"><g class="og" style="${oc}"><path d="${G.ring}" fill="none" stroke="#fff" stroke-width="${G.sw}"/></g></g><g class="vg" style="${vc}"><path class="ma" d="${seg(p, 0)}" fill="none" stroke="#fff" stroke-linecap="round" stroke-width="${G.vsw}"/><path class="mb" d="${seg(p, 4)}" fill="none" stroke="#fff" stroke-linecap="round" stroke-width="${G.vsw}"/></g></mask></defs>++<g class="m"><circle class="rp" cx="${G.cx}" cy="${G.cy}" r="${G.R}"/><circle class="rp rp2" cx="${G.cx}" cy="${G.cy}" r="${G.R}"/>++<g mask="url(#${id}h)"><g class="og" style="${oc}"><path class="ot" d="${G.ring}" stroke-width="${G.sw}"/><path class="o" d="${G.ring}" pathLength="100" stroke-width="${G.sw}" style="stroke:url(#${id}go)"/><circle class="cd" cx="${G.cx}" cy="${G.cy}" r="${f(ri * 0.62)}"/>++<g class="sw2"><path class="sc" d="M${c0[0]} ${c0[1]}A${capR} ${capR} 0 0 1 ${c1[0]} ${c1[1]}"/></g></g></g>++<g class="vg" style="${vc}"><path class="va" d="${seg(p, 0)}" stroke-width="${G.vsw}" style="stroke:url(#${id}gv)"/><path class="vb" d="${seg(p, 4)}" stroke-width="${G.vsw}" style="stroke:url(#${id}gv)"/>++<g class="sw2"><circle class="sd" cx="${G.A[0]}" cy="${f(G.A[1] - G.vsw * 0.18)}" r="${f(G.vsw * 0.36)}"/><circle class="sd" cx="${G.B[0]}" cy="${f(G.B[1] - G.vsw * 0.18)}" r="${f(G.vsw * 0.36)}"/></g></g>++<g mask="url(#${id}s)"><rect class="gr" x="38" y="-10" width="24" height="120" fill="url(#${id}gg)"/></g>++<circle class="nd" cx="${f(G.B[0] + 5.5)}" cy="${f(G.B[1] - 9)}" r="${f(G.vsw * 0.55)}"/>++<g class="sh"><path class="ss" d="${stem}"/><path class="sl" d="${leaf}"/></g>++<g class="sw2"><circle class="sd" cx="${f(G.cx - 12)}" cy="${f(top - 6)}" r="1.8"/><circle class="sd" cx="${f(G.B[0] - 6)}" cy="${f(G.B[1] - 12)}" r="1.5"/></g></g></svg>`;}++++ // ── The V's shapes: a state turns it into a tick, a cross or "!" and back, the gap in the ring following it ──++ const SHAPE_OF = { ok: 'ok', error: 'err', no: 'no' };++ const backOut = (p) => { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(p - 1, 3) + c1 * Math.pow(p - 1, 2); };++ function drawV(q, p) {++ q.pts = p;++ const a = seg(p, 0), b = seg(p, 4);++ q.va.setAttribute('d', a); q.ha.setAttribute('d', a); q.vb.setAttribute('d', b); q.hb.setAttribute('d', b);++ if (q.ma) { q.ma.setAttribute('d', a); q.mb.setAttribute('d', b); }++ }++ function shape(el, animate) {++ const q = el.__ovm; if (!q) return;++ const name = SHAPE_OF[el.getAttribute('data-state')] || 'v';++ if (q.name === name) return;++ q.name = name;++ const to = FORMS[q.form].shapes[name], from = q.pts.slice();++ const raf = typeof window.requestAnimationFrame === 'function' ? window.requestAnimationFrame.bind(window) : null;++ if (q.raf && window.cancelAnimationFrame) window.cancelAnimationFrame(q.raf);++ if (!animate || !raf) { drawV(q, to); return; }++ const t0 = Date.now(), ms = 420;++ const step = () => { const t = Math.min(1, (Date.now() - t0) / ms), e = backOut(t); drawV(q, from.map((v, i) => v + (to[i] - v) * e)); q.raf = t < 1 ? raf(step) : 0; };++ q.raf = raf(step);++ }++const INTRO_MS = 8000, LINGER_MS = 1200;const state = new WeakMap(); // mark → { introEnd, lingerEnd, hover, focus, onScreen, timer }let io = null, rm = null;@@@ -133,16 +262,15 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;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');++ // Anything the mark is showing keeps it moving: a state, a page on its way, a known progress.++ const showing = (s && s !== 'idle') || el.hasAttribute('data-nav') || el.hasAttribute('data-progress');++ const intro = now < st.introEnd;++ const run = may && (intro || st.hover || st.focus || now < st.lingerEnd || showing);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 */ }++ // The intro's pop and growth are on only inside the intro (paused with the rest while the mark cannot move).++ if ((may && intro) !== el.hasAttribute('data-ovm-intro') && (may || !intro)) { if (may && intro) el.setAttribute('data-ovm-intro', ''); else el.removeAttribute('data-ovm-intro'); }++ shape(el, run);++ try { if (run) svgEl.unpauseAnimations(); else 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);@@@ -220,8 +348,17 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;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 (size) { el.style.width = size + 'px'; if (!el.hasAttribute('data-wide')) el.style.height = size + 'px'; }++ // The form follows the size unless the page names one: under 22px the pair's gap closes, the heavy holds.++ if (!el.hasAttribute('data-form')) {++ let px = size;++ if (!px) { try { px = Math.round(el.getBoundingClientRect().width); } catch { px = 0; } }++ el.setAttribute('data-form', px && px <= 22 ? 'heavy' : 'pair');++ }++ const form = formOf(el.getAttribute('data-form'));++ el.innerHTML = svg('ovm' + (++n) + '_', FORMS[form], el.hasAttribute('data-wide'));++ const pick = (c) => el.querySelector(c);++ el.__ovm = { form, name: 'v', pts: FORMS[form].shapes.v.slice(), raf: 0, va: pick('.va'), vb: pick('.vb'), ha: pick('.ha'), hb: pick('.hb'), ma: pick('.ma'), mb: pick('.mb') };}if (!state.has(el) && el.isConnected) track(el);});@@@ -237,6 +374,7 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;try { rm = window.matchMedia('(prefers-reduced-motion: reduce)'); rm.addEventListener('change', updateAll); } catch { rm = null; }document.addEventListener('visibilitychange', updateAll);mount();++ hookNav();scheduleFlourish();try {new MutationObserver((records) => {@@@ -251,9 +389,154 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;});}if (added) mount();-- }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-ov-motion', 'data-ov-season-mark'] });++ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-nav', 'data-progress', 'data-ov-motion', 'data-ov-season-mark'] });} catch { /* */ }}++ // ── A page on its way: the navbar's mark draws its ring round as the next page loads ──++ // The pace is learned: the usual time of this site's boost moves and full page loads in this browser (a running++ // average in localStorage). The ring reaches about four fifths at the usual time and never closes before the page++ // is really here; then it closes, the V hops, and the track fades on the new page.++ const loc = () => window.location || { host: '', href: 'http://localhost/', origin: 'http://localhost', pathname: '/', search: '' };++ const NAV_KEY = 'ov-nav-ms', NAV_FLAG = 'ov-nav-click', NAV_DEFAULT = { boost: 450, page: 1600 };++ const navMarks = () => [...document.querySelectorAll('.openvibe-navbar-brand .ov-mark, .navbar .nav-brand .ov-mark, .ov-mark[data-ov-nav]')].filter((m) => !m.closest('.openvibe-network-badge'));++ const navStore = () => { try { return JSON.parse(window.localStorage.getItem(NAV_KEY) || '{}') || {}; } catch { return {}; } };++ function usual(kind) { const v = Number(navStore()[`${loc().host}:${kind}`]); return v > 0 ? v : NAV_DEFAULT[kind] || 1000; }++ function learn(kind, ms) {++ if (!(ms > 30 && ms < 30000)) return;++ try {++ const all = navStore(), k = `${loc().host}:${kind}`;++ all[k] = Math.round(all[k] > 0 ? all[k] * 0.7 + ms * 0.3 : ms);++ window.localStorage.setItem(NAV_KEY, JSON.stringify(all));++ } catch { /* no storage: the default pace */ }++ }++ let nav = null;++ function setFill(marks, p) { marks.forEach((m) => { m.setAttribute('data-nav', ''); m.style.setProperty('--ovm-fill', String(p)); }); }++ function navStart(kind = 'page') {++ navStop();++ const marks = navMarks(); if (!marks.length) return;++ marks.forEach((m) => m.removeAttribute('data-ovm-arrive'));++ const t0 = Date.now(), avg = usual(kind);++ const me = { kind, t0, timer: 0 };++ const tick = () => {++ if (nav !== me) return;++ const t = Date.now() - t0;++ setFill(navMarks(), Math.min(0.94, 0.08 + 0.86 * (1 - Math.exp(-1.6 * t / avg))).toFixed(3));++ me.timer = setTimeout(tick, 120);++ };++ nav = me;++ tick();++ }++ function navStop() { if (nav) { clearTimeout(nav.timer); nav = null; } }++ /** The page is here: the ring closes and the V hops. A navbar re-rendered by the move gets the same ending. */++ // The ending runs after every other listener of the arrival (the navbar re-renders on it), and no timer keeps a mark:++ // each step looks the marks up again, so a replaced navbar is never held in memory.++ const clearNav = () => { if (!nav) navMarks().forEach((m) => { m.removeAttribute('data-nav'); m.removeAttribute('data-ovm-arrive'); m.style.removeProperty('--ovm-fill'); }); };++ function navEnd(kind) {++ const was = nav; navStop();++ if (was && kind) learn(kind, Date.now() - was.t0);++ setTimeout(() => {++ if (nav) return; mount();++ const marks = navMarks(); setFill(marks, 1); marks.forEach((m) => m.setAttribute('data-ovm-arrive', ''));++ setTimeout(clearNav, 520);++ }, 0);++ }++ function hookNav() {++ document.addEventListener('ov:boost:before', () => navStart('boost'));++ document.addEventListener('ov:boost:load', () => navEnd('boost'));++ // A full page load: a plain click on a link that leaves this page, in this tab, not taken by boost or the app.++ document.addEventListener('click', (e) => {++ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;++ const a = e.target && e.target.closest && e.target.closest('a[href]');++ if (!a || a.hasAttribute('download') || (a.target && a.target !== '_self')) return;++ const here = loc(); let u; try { u = new URL(a.href, here.href); } catch { return; }++ if (!/^https?:$/.test(u.protocol) || (u.origin === here.origin && u.pathname === here.pathname && u.search === here.search)) return;++ navStart('page');++ try { if (u.origin === here.origin) sessionStorage.setItem(NAV_FLAG, String(Date.now())); } catch { /* */ }++ });++ // Back to a page from the back/forward cache: nothing is loading any more.++ window.addEventListener('pageshow', (e) => { if (e.persisted) { navStop(); clearNav(); } });++ // This page came from a click on the last one: learn how long it took, and finish the ring here.++ let t = 0; try { t = Number(sessionStorage.getItem(NAV_FLAG)) || 0; sessionStorage.removeItem(NAV_FLAG); } catch { /* */ }++ if (t && Date.now() - t < 20000) {++ learn('page', Date.now() - t);++ const finish = () => { const marks = navMarks(); if (!marks.length) return false; setFill(marks, 0.9); setTimeout(() => navEnd(null), 60); return true; };++ let tries = 0; const again = () => { if (!finish() && ++tries <= 20) setTimeout(again, 100); }; again();++ }++ }++ /** Replay the intro on one mark (a brand page, a splash): the O draws itself round, the V drops in, a glint crosses. */++ function intro(el) {++ const st = el && state.get(el); if (!st) return;++ el.removeAttribute('data-ovm-intro');++ try { void el.getBoundingClientRect(); } catch { /* */ }++ st.introEnd = Date.now() + 2000; update(el);++ }++ // ── Busy buttons (the plan: "buttons' busy, then done or failed, from the mark's states", 2026-11-01) ──++ // A button that starts work shows the mark at work beside its label, keeps its width and takes no second press.++ // Script work ends it with done (the tick, and "Done" or a word) or fail (the "!", and a word), then the label++ // comes back; a form posted the old way stays busy until the next page arrives. The button is never disabled:++ // a disabled submitter's name and value would drop out of the form it posts.++ // OVMark.busy(button) → { done(text?), fail(text?), reset() }++ const BUSY = new Map();++ function busy(btn) {++ if (!btn || !btn.isConnected) return null;++ if (BUSY.has(btn)) return BUSY.get(btn);++ const isInput = btn.tagName === 'INPUT';++ const before = isInput ? btn.value : btn.innerHTML;++ const width = btn.offsetWidth;++ if (width) btn.style.minWidth = width + 'px';++ btn.setAttribute('aria-busy', 'true');++ btn.classList.add('ov-busy');++ let mark = null;++ if (!isInput) {++ mark = document.createElement('span');++ mark.className = 'ov-mark ov-busy-mark';++ mark.setAttribute('data-size', '18'); mark.setAttribute('data-tone', 'ink'); mark.setAttribute('data-state', 'busy'); mark.setAttribute('aria-hidden', 'true');++ btn.insertBefore(mark, btn.firstChild);++ mount(btn);++ }++ let timer = 0;++ const reset = () => {++ clearTimeout(timer);++ if (!BUSY.has(btn)) return;++ BUSY.delete(btn);++ if (isInput) btn.value = before; else btn.innerHTML = before;++ btn.style.minWidth = '';++ btn.removeAttribute('aria-busy');++ btn.classList.remove('ov-busy');++ const form = btn.form || (btn.closest && btn.closest('form'));++ if (form) delete form.__ovBusy;++ };++ const end = (st, text, ms) => {++ if (!BUSY.has(btn)) return;++ if (mark) {++ mark.setAttribute('data-state', st);++ // The label after the mark says what happened, in the button's own place.++ while (mark.nextSibling) btn.removeChild(mark.nextSibling);++ btn.appendChild(document.createTextNode(text));++ } else btn.value = text;++ btn.setAttribute('aria-busy', 'false');++ clearTimeout(timer); timer = setTimeout(reset, ms);++ };++ const api = { done: (text) => end('ok', text || 'Done', 1400), fail: (text) => end('error', text || 'Failed', 2600), reset };++ BUSY.set(btn, api);++ return api;++ }++ // A form posted the old way (POST, no script of the page's own took the submit): its button shows the mark at work++ // until the next page arrives, and a second submit does nothing. data-ov-busy="off" on a form opts out.++ document.addEventListener('submit', (e) => {++ const form = e.target;++ if (!form || form.tagName !== 'FORM' || form.getAttribute('data-ov-busy') === 'off') return;++ if (String(form.getAttribute('method') || 'get').toLowerCase() !== 'post') return;++ if (form.__ovBusy) { e.preventDefault(); return; }++ if (e.defaultPrevented) return;++ const btn = e.submitter || form.querySelector('button[type=submit],button:not([type]),input[type=submit]');++ if (!btn) return;++ form.__ovBusy = true;++ busy(btn);++ });++ // Back to a page from the back/forward cache: nothing is busy any more.++ window.addEventListener('pageshow', (e) => { if (e.persisted) [...BUSY.values()].forEach((b) => b.reset()); });++ window.OVMark = { mount, navStart, navEnd, usual, intro, busy, FORMS };window.ovMarkMount = mount;if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();})();@@@ -1,6 +1,6 @@{"name": "openvibe-extensions",-- "version": "0.8.1",++ "version": "0.9.0","private": true,"description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.","scripts": {@@@ -9,16 +9,20 @@* Reviews": activeTab is the only host access; Firefox's sidebar_action, the* side panel there, needs no permission of its own; identity starts the Dock's one-click* OAuth sign-in)-- * 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)++ * host_permissions exactly the API origin, the Network origin, the Chat origin and the Codes++ * origin, all https://openvibe.<tld> sites (the coupon API, the Dock's state,++ * the Dock's Chats tab, and the Dock's agent view)* allowed key key (Chrome's public key that fixes the extension id; dropped from the Firefox zip)* forbidden keys content_scripts, background, web_accessible_resources,* externally_connectable, optional_permissions, optional_host_permissions,* declarative_net_request, chrome_url_overrides, devtools_page, sandbox,* 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-*++ * CSP script-src 'self' only; style-src 'self' 'unsafe-inline' (the Dock draws its++ * own styles in a <style> element); img-src 'self' blob: (the agent view's++ * still is a local object URL); connect-src only the OpenVibe origins;++ * frame-src only Live's agent embed; no other unsafe-*, wildcard, data: or++ * remote http* 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@@@ -67,7 +71,16 @@ function auditManifest(m, { apiOrigin, origins } = {}) {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');++ if (JSON.stringify(dirs['style-src']) !== JSON.stringify(["'self'", "'unsafe-inline'"])) problems.push("CSP style-src must be exactly 'self' 'unsafe-inline' (the Dock's own <style>)");++ if (JSON.stringify(dirs['img-src']) !== JSON.stringify(["'self'", 'blob:'])) problems.push("CSP img-src must be exactly 'self' blob: (the agent view's still)");++ if (JSON.stringify(dirs['frame-src']) !== JSON.stringify(['https://openvibe.live'])) problems.push('CSP frame-src must be exactly https://openvibe.live (the agent embed)');++ for (const [name, values] of Object.entries(dirs)) {++ if (name === 'style-src') continue;++ for (const v of values) {++ const devLocal = /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(v);++ if (/unsafe-|\*|data:/.test(v) || (v === 'blob:' && name !== 'img-src') || (/^http:/.test(v) && !devLocal)) problems.push(`CSP ${name} must not allow ${v}`);++ }++ }}const gecko = m.browser_specific_settings && m.browser_specific_settings.gecko;if (!gecko || !gecko.id) problems.push('browser_specific_settings.gecko.id is required (Firefox)');@@@ -94,13 +107,14 @@ function auditDir(dir) {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 codes = (configSrc.match(/CODES_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`);-- 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`);++ const origins = [origin, network, chat, codes].filter(Boolean);++ for (const o of [network, chat, codes].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 };nnew file mode 100644@@@ -0,0 +1,222 @@++#!/usr/bin/env node++'use strict';++/**++ * The side panel in a real browser: headless Chrome (CDP over Node's own WebSocket, no dependencies) loads the++ * unpacked extension, opens panel.html with a stubbed Network/Chat/Codes and a live agent view, and checks what a++ * person would see: the Dock on its Agents tab, the agent's stream frame, the line and the meta, the four controls++ * (Pause/Resume, Curtain now, Mute/Unmute, End), and the Dock's own styles and nothing a CSP refused.++ *++ * node scripts/check-panel.js (CHROME=/path/to/chrome to choose the browser; OV_EXT_DIR=an unpacked build, default browser/)++ *++ * test/panel.test.js runs it when a Chrome is installed and says so when it is not.++ */++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { spawn } = require('child_process');++++const CHROME = process.env.CHROME || ['/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser'].find((p) => fs.existsSync(p));++const ROOT = path.join(__dirname, '..');++const EXT_DIR = process.env.OV_EXT_DIR || path.join(ROOT, 'browser');++const MANIFEST = JSON.parse(fs.readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf8'));++++/** Chrome's extension id from the public key in the manifest (the same rule test/manifest.test.js checks). */++function extensionId(key) {++ const hash = crypto.createHash('sha256').update(Buffer.from(key, 'base64')).digest();++ let id = '';++ for (let i = 0; i < 16; i++) id += String.fromCharCode(97 + (hash[i] >> 4)) + String.fromCharCode(97 + (hash[i] & 15));++ return id;++}++const EXT_ID = extensionId(MANIFEST.key);++++const TOKEN = 'test.' + Buffer.from(JSON.stringify({ sub: 'usr_test', username: 'ada' })).toString('base64url') + '.signature';++const AGENT_ID = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const AGENT = {++ paused: true, paused_by: 'ada', paused_by_role: 'owner', mode: 'watch',++ id: AGENT_ID, state: 'live', title: 'Ship the Dock',++ task: { title: 'Ship the Dock', url: 'https://openvibe.codes/tasks/1' },++ curtain: { up: false }, viewers: 2, worked_seconds: 754, visibility: 'private',++ started_at: new Date(Date.now() - 754000).toISOString(), ended_at: null,++ page_url: `https://openvibe.live/@ada/agents/${AGENT_ID}`,++ embed_url: `https://openvibe.live/embed/agent/${AGENT_ID}?vt=wvt_test_ticket`,++ last_frame_url: null, voice_muted: true,++};++const DOCK = {++ state: {++ open: true, tab: 'agent', pinned: true, agent: AGENT_ID, chat: null,++ place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } },++ updated_at: new Date().toISOString(),++ },++ counts: { running: 0, waiting: 0, unread: 0 },++};++++/** A CDP client over Node's WebSocket: requests by id, and events to listeners. */++function cdp(url) {++ const ws = new WebSocket(url);++ let id = 0;++ const waiting = new Map();++ const events = new Map();++ ws.addEventListener('message', (ev) => {++ const m = JSON.parse(ev.data);++ if (m.id && waiting.has(m.id)) {++ const w = waiting.get(m.id); waiting.delete(m.id);++ if (m.error) w.reject(new Error(m.error.message)); else w.resolve(m.result);++ } else if (m.method) {++ for (const fn of events.get(m.method) || []) fn(m.params);++ }++ });++ const ready = new Promise((resolve, reject) => { ws.addEventListener('open', resolve); ws.addEventListener('error', reject); });++ return {++ ready,++ send(method, params = {}) { return new Promise((resolve, reject) => { const n = ++id; waiting.set(n, { resolve, reject }); ws.send(JSON.stringify({ id: n, method, params })); }); },++ on(method, fn) { if (!events.has(method)) events.set(method, []); events.get(method).push(fn); },++ close() { ws.close(); },++ };++}++++async function evaluate(c, expression) {++ const r = await c.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });++ if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception ? r.exceptionDetails.exception.description : r.exceptionDetails.text);++ return r.result.value;++}++++const pause = (ms) => new Promise((r) => setTimeout(r, ms));++const json = (v) => ({ status: 200, type: 'application/json', body: JSON.stringify(v) });++++/** What the stubbed first parties answer. A route this file does not know gets an empty object, never a refusal. */++function answer(url, method) {++ const u = new URL(url);++ const p = u.pathname;++ if (method === 'OPTIONS') return { status: 204, type: 'text/plain', body: '' };++ if (u.origin === 'https://openvibe.live') {++ return { status: 200, type: 'text/html', body: '<!doctype html><html><body><p id="stub-embed">embed</p></body></html>' };++ }++ if (u.origin === 'https://openvibe.network' && p === '/api/v1/me/dock') return json(DOCK);++ if (u.origin === 'https://openvibe.network' && p === '/api/v1/me/actions') return json({ items: [], counts: {} });++ if (u.origin === 'https://openvibe.codes' && p === '/api/v1/me/agent-view') return json({ agent: AGENT });++ return json({});++}++++async function main() {++ if (!CHROME) return { skipped: 'no Chrome or Chromium installed (set CHROME)' };++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-panel-'));++ const proc = spawn(CHROME, ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--no-sandbox',++ '--enable-unsafe-extension-debugging', `--user-data-dir=${dir}`, '--remote-debugging-port=0', 'about:blank'], { stdio: 'ignore' });++ let page = null;++ try {++ let port = null;++ for (let i = 0; i < 100 && !port; i++) {++ await pause(100);++ try { port = fs.readFileSync(path.join(dir, 'DevToolsActivePort'), 'utf8').split('\n')[0]; } catch { /* not yet */ }++ }++ if (!port) throw new Error('Chrome did not start');++ const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();++ const target = targets.find((t) => t.type === 'page');++ if (!target) throw new Error('Chrome has no page target');++ const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();++ const browser = cdp(version.webSocketDebuggerUrl);++ await browser.ready;++ let loaded;++ try {++ loaded = await browser.send('Extensions.loadUnpacked', { path: EXT_DIR });++ } catch (err) {++ // An older Chrome has no Extensions domain; that is this browser's limit, not the panel's.++ if (/method|not available|not found|unsupported/i.test(String(err && err.message))) {++ browser.close();++ return { skipped: `this Chrome cannot load an unpacked extension over CDP (${err.message})` };++ }++ throw err;++ }++ if (loaded.id !== EXT_ID) throw new Error(`Chrome loaded ${loaded.id}, the manifest key says ${EXT_ID}`);++ browser.close();++++ page = cdp(target.webSocketDebuggerUrl);++ await page.ready;++ await page.send('Page.enable');++ await page.send('Runtime.enable');++ await page.send('Log.enable');++ const security = [];++ page.on('Log.entryAdded', ({ entry }) => { if (entry && entry.source === 'security') security.push(entry.text); });++ const paused = [];++ page.on('Fetch.requestPaused', (p) => { paused.push(p.request.url); void (async () => {++ const a = answer(p.request.url, p.request.method);++ const headers = [++ { name: 'Access-Control-Allow-Origin', value: '*' },++ { name: 'Access-Control-Allow-Headers', value: 'authorization, content-type' },++ { name: 'Access-Control-Allow-Methods', value: 'GET, POST, OPTIONS' },++ { name: 'Content-Type', value: a.type },++ ];++ const params = { requestId: p.requestId, responseCode: a.status, responseHeaders: headers };++ if (a.status !== 204) params.body = Buffer.from(a.body).toString('base64');++ await page.send('Fetch.fulfillRequest', params);++ })().catch(() => { /* the page went away */ }); });++ await page.send('Fetch.enable', { patterns: [++ { urlPattern: 'https://openvibe.network/*' }, { urlPattern: 'https://openvibe.chat/*' },++ { urlPattern: 'https://openvibe.codes/*' }, { urlPattern: 'https://openvibe.live/*' },++ ] });++++ await page.send('Page.navigate', { url: `chrome-extension://${EXT_ID}/panel.html` });++ for (let i = 0; i < 100 && (await evaluate(page, 'document.readyState')) !== 'complete'; i++) await pause(50);++ await evaluate(page, `chrome.storage.session.set(${JSON.stringify({ dockAccess: { token: TOKEN, expires_at: Date.now() + 3600000, scope: 'dock activity chat.read', username: 'ada' } })}).then(() => true)`);++ await evaluate(page, `chrome.storage.local.set(${JSON.stringify({ dockAccount: { username: 'ada', sub: 'usr_test' } })}).then(() => true)`);++ await page.send('Page.reload');++++ let seen = null;++ for (let i = 0; i < 100; i++) {++ await pause(100);++ seen = await evaluate(page, `(() => {++ const q = (s) => document.querySelector(s);++ const text = (n) => n ? n.textContent.trim() : null;++ return {++ gateHidden: q('#gate') ? q('#gate').hidden : null,++ dock: !!q('.ovdk'),++ agent: !!q('.ovda.on'),++ line: text(q('.ovda-line')),++ meta: text(q('.ovda-meta')),++ acts: [...document.querySelectorAll('.ovda-acts [data-act]')].map((b) => b.dataset.act + ':' + b.textContent.trim()),++ frame: (q('iframe.ovda-frame') || {}).src || null,++ tab: text(q('[role="tab"][aria-selected="true"]')),++ styles: !!document.getElementById('ovdk-styles') && !!document.getElementById('ovda-styles') && !!document.getElementById('ovac-styles'),++ lineDisplay: q('.ovda-line') ? getComputedStyle(q('.ovda-line')).display : null,++ };++ })()`);++ if (seen && seen.agent) break;++ }++++ for (let i = 0; i < 20 && !(paused.some((u) => u.startsWith('https://openvibe.live/embed/agent/')) && paused.some((u) => u.startsWith('https://openvibe.codes/api/v1/me/agent-view'))); i++) await pause(100);++++ const failures = [];++ if (!seen || !seen.dock) failures.push('the panel did not mount the Dock');++ if (!seen || !seen.gateHidden) failures.push('the sign-in gate stayed up with a signed-in Dock');++ if (!seen || !seen.agent) failures.push('the Agents tab did not draw the agent view');++ if (seen && seen.tab !== 'Agents') failures.push(`the Dock opened on "${seen.tab}", not Agents`);++ if (seen && seen.line !== 'Ship the Dock') failures.push(`the agent's line is "${seen.line}"`);++ if (seen && !/Paused by @ada/.test(seen.meta || '')) failures.push(`the meta does not say who paused it: "${seen.meta}"`);++ if (seen && !/2 watching/.test(seen.meta || '')) failures.push(`the meta does not say how many watch: "${seen.meta}"`);++ const wantActs = ['pause:Resume', 'curtain:Curtain now', 'mute:Unmute', 'end:End'];++ if (seen && JSON.stringify(seen.acts) !== JSON.stringify(wantActs)) failures.push(`the four controls are ${JSON.stringify(seen.acts)}`);++ if (seen && seen.frame !== AGENT.embed_url) failures.push(`the stream frame is "${seen.frame}"`);++ if (seen && !seen.styles) failures.push("the Dock's own styles are missing");++ if (seen && seen.lineDisplay !== 'flex') failures.push(`the agent's styles did not apply (line display ${seen.lineDisplay})`);++ if (security.length) failures.push(`CSP refused: ${security.join(' | ')}`);++ if (!paused.some((u) => u.startsWith('https://openvibe.live/embed/agent/'))) failures.push('the panel never asked for the agent embed');++ if (!paused.some((u) => u.startsWith('https://openvibe.codes/api/v1/me/agent-view'))) failures.push('the panel never asked Codes for the agent view');++ return { failures, staged: paused.length, seen };++ } finally {++ if (page) page.close();++ proc.kill();++ await pause(200);++ fs.rmSync(dir, { recursive: true, force: true });++ }++}++++module.exports = { main };++++if (require.main === module) {++ main().then((r) => {++ if (r.skipped) { console.log(`side panel in Chrome: skipped (${r.skipped})`); return; }++ if (r.failures.length) { console.error(r.failures.join('\n')); process.exit(1); }++ console.log(`side panel in Chrome: the Dock opened on Agents with the agent view and its four controls (${r.staged} stubbed requests)`);++ }).catch((err) => { console.error(err); process.exit(1); });++}@@@ -16,8 +16,11 @@ reads, sends or stores, change this file, the README's "What it can see" table a| `scripting` | Four buttons inject a script packaged in the extension into the active tab's top frame, only after the press: "Try these codes on this page" runs `lib/autoapply.js` (finds the coupon box, types each code, reads whether the order total changed), "Save its rating to OpenVibe Reviews" runs `lib/rating.js` (reads the rating the page states), and "Keep on my Rent board" and "Check the rent" run `lib/listing.js` (reads the price, city and bedrooms a listing states). None makes a request or reads cookies or storage. || `storage` | Keeps the optional `cpx_` install token, and the Dock's sign-in: the OAuth access token in `storage.session` (memory) and the rotating refresh token in `storage.local` (never synced). A pasted 0.7.0 `networkToken` is kept only as that fallback. || `identity` | Starts the Dock's one-click sign-in (`launchWebAuthFlow`) at openvibe.network; the code returns to the browser's own extension redirect URL. No password is read by the extension. |--| Host permission `https://openvibe.coupons/*` | The one API the extension calls: look up a merchant by hostname, list its codes, and report whether a code worked. |--| Remote code | **No.** All JavaScript is in the package; the CSP is `script-src 'self'` with no `eval`. |++| Host permission `https://openvibe.coupons/*` | The Coupons API: look up a merchant by hostname, list its codes, and report whether a code worked. |++| Host permission `https://openvibe.network/*` | The Dock's state (`GET`/`PUT /api/v1/me/dock`) and its notifications, with the person's own token. |++| Host permission `https://openvibe.chat/*` | The Dock's Chats tab: the person's conversations and DMs (`GET /api/dm/conversations`), with the same token. |++| Host permission `https://openvibe.codes/*` | The Dock's agent view: the person's own desk stream, its still, and the Pause/Resume, Curtain now, Mute/Unmute and End controls (`/api/v1/me/agent-view`), with the same token. |++| Remote code | **No.** All JavaScript is in the package; the CSP is `script-src 'self'` with no `eval`. The agent view's `https://openvibe.live/embed/agent/…` frame is the only remote frame (`frame-src`), and it is Live's own page, not script the extension runs. |## Data usage (Chrome Web Store "Privacy practices" tab)@@@ -26,8 +29,8 @@ Tick these data types, because each leaves the device in some case:| Type | Ticked | What, when, where ||---|---|---|| Web history | **Yes** | The active tab's **hostname** goes to openvibe.coupons when the popup opens on a site. On a hand-off press, the page's address (credentials, fragment and tracking parameters removed) and title go to openvibe.rent, openvibe.watch, openvibe.food or openvibe.reviews in the address of a tab the person sees. |--| Website content | **Yes** | On "Save its rating to OpenVibe Reviews": the business name, rating, scale, count of ratings and city the page states, in the address of the openvibe.reviews tab. On "Compare delivery apps": the restaurant or store name taken from the page title, to openvibe.food. On "Keep on my Rent board" and "Check the rent": the price, its currency, the city and region (never the street) and the bedrooms the listing states, in the address of the openvibe.rent tab. |--| Authentication information | **Yes** | Only if the person connects the extension: the `cpx_` install token they pasted is sent to openvibe.coupons as `Authorization: Bearer` on its API calls. The Dock's one-click sign-in sends an OAuth access token to openvibe.network and openvibe.chat as `Authorization: Bearer` (scopes `dock`, `activity`, `chat.read`) and keeps a refresh token on this device. |++| Website content | **Yes** | The Dock reads the person's own agent view (the task line, the worked time, the stream's state) from openvibe.codes and frames the stream from openvibe.live; it is their own content, not the page's. On "Save its rating to OpenVibe Reviews": the business name, rating, scale, count of ratings and city the page states, in the address of the openvibe.reviews tab. On "Compare delivery apps": the restaurant or store name taken from the page title, to openvibe.food. On "Keep on my Rent board" and "Check the rent": the price, its currency, the city and region (never the street) and the bedrooms the listing states, in the address of the openvibe.rent tab. |++| Authentication information | **Yes** | Only if the person connects the extension: the `cpx_` install token they pasted is sent to openvibe.coupons as `Authorization: Bearer` on its API calls. The Dock's one-click sign-in sends an OAuth access token to openvibe.network, openvibe.chat and openvibe.codes as `Authorization: Bearer` (scopes `dock`, `activity`, `chat.read`) and keeps a refresh token on this device. || User activity | No | No clicks, keystrokes or scrolls are recorded. A report sends a code id, `worked`/`failed` and an optional reason the person chose, which is the person's own feedback. || Financial and payment information | No | `lib/autoapply.js` reads the order total in the page to tell which codes lowered it; the total stays in the popup and is never sent. || Personally identifiable information, health, personal communications, location | No | Not read. (The city sent with a rating is the business's, and with a listing the home's, from the page; never the person's.) |@@@ -47,4 +50,5 @@ these to the person at install. Nothing is optional, because nothing is collectedidn't press for, and the token is the person's own choice on the settings page.In Firefox, MV3 host permissions can be withheld at install; the settings page then shows "Allow--access to openvibe.coupons".++access to openvibe.coupons". The Dock needs its three OpenVibe origins at install; Firefox asks for++them together with the extension.@@@ -11,16 +11,16 @@ 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'];++const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes'];(async () => {const { manifest, origin, problems } = auditDir(DIR);-- await check('the shipped manifest passes the audit: activeTab + identity + scripting + storage + sidePanel, three OpenVibe host permissions, MV3', async () => {++ await check('the shipped manifest passes the audit: activeTab + identity + scripting + storage + sidePanel, four OpenVibe host permissions, MV3', async () => {assert.deepStrictEqual(problems, []);assert.strictEqual(origin, ORIGIN);assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'identity', 'scripting', 'storage']);-- assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*', 'https://openvibe.network/*', 'https://openvibe.chat/*']);++ assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', '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`);@@@ -90,7 +90,10 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https: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 https:\/\/openvibe\.network https:\/\/openvibe\.chat;/);++ assert.match(csp, /connect-src https:\/\/openvibe\.coupons https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes;/);++ assert.match(csp, /style-src 'self' 'unsafe-inline';/);++ assert.match(csp, /img-src 'self' blob:;/);++ assert.match(csp, /frame-src https:\/\/openvibe\.live;/);assert.strictEqual(auditManifest(manifest, { apiOrigin: 'https://evil.example.net' }).length > 0, true, 'another origin is caught');});@@@ -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', origins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat'] }), []);++ assert.deepStrictEqual(auditManifest(JSON.parse(files.get('manifest.json')), { apiOrigin: 'https://openvibe.coupons', origins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes'] }), []);});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/*', '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.deepStrictEqual(m.host_permissions, ['http://localhost:4850/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*']);++ assert.match(m.content_security_policy.extension_pages, /connect-src http:\/\/localhost:4850 https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes;/);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\//);@@@ -69,7 +69,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {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 () => null } }, auth: fakeAuth(), net, dock, sibling: (n) => 'chrome-extension://abc/shared/' + n });++ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat', CODES_ORIGIN: 'https://openvibe.codes' }, platform: { storage: { getNetworkToken: async () => null } }, auth: fakeAuth(), 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');@@@ -78,6 +78,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {assert.strictEqual(dock.mounted.me, 'ada', 'the account name');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.agentBase, 'https://openvibe.codes', "and Codes, where the agent view's stream and four controls are");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');@@@ -95,7 +96,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {const doc = fakeDoc();const dock = fakeDock();const net = createNetworkClient({ origin: net2.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 () => null } }, auth: fakeAuth(), net, dock });++ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat', CODES_ORIGIN: 'https://openvibe.codes' }, platform: { storage: { getNetworkToken: async () => null } }, auth: fakeAuth(), net, dock });await panel.start();assert.strictEqual(dock.openedWith.doc.state.tab, 'chats', 'the side panel opens on the page\'s tab');assert.deepStrictEqual(dock.openedWith.doc.counts, first.counts, 'and the unread counts the page sees');@@@ -113,7 +114,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {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 } }, auth: fakeAuth({ mode: 'none' }), net, dock });++ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat', CODES_ORIGIN: 'https://openvibe.codes' }, platform: { storage: { getNetworkToken: async () => null } }, auth: fakeAuth({ mode: 'none' }), 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');@@@ -127,7 +128,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {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 () => null } }, auth: fakeAuth({ token: bad }), net, dock });++ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat', CODES_ORIGIN: 'https://openvibe.codes' }, platform: { storage: { getNetworkToken: async () => null } }, auth: fakeAuth({ token: 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');@@@ -139,7 +140,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {const dock = fakeDock();const auth = fakeAuth({ mode: 'none' });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: {} }, auth, net, dock });++ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat', CODES_ORIGIN: 'https://openvibe.codes' }, platform: { storage: {} }, auth, net, dock });await panel.start();doc.nodes['gate-button'].onclick();// The click handler is async; wait until the grant is "connected" and the Dock mounted.@@@ -154,7 +155,8 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {}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']) {++ for (const f of ['shared/dock.js', 'shared/dock-agent.js', 'shared/dock-timer.js', 'shared/dock-vibe.js',++ 'shared/action-center.js', 'shared/notification-ui.js', 'shared/notification-live.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');@@@ -163,5 +165,11 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {assert.match(html, /shared\/dock\.js/);});++ await check('in a real browser: the panel opens on Agents with the agent view and its four controls (skipped without Chrome)', async () => {++ const r = await require('../scripts/check-panel').main();++ if (r.skipped) { console.log(` skipped: ${r.skipped}`); return; }++ assert.deepStrictEqual(r.failures, []);++ });++done();})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+# Changelog
+
+The OpenVibe Companion's releases. Each one is a `browser/manifest.json` version and a [GitHub
+release](https://github.com/OpenVibers/OpenVibe.Extensions/releases); older entries are in the git
+history.
+
+## 0.9.0 — 2026-11-07
+
+- **The side panel's Dock is the current released one.** `browser/shared/` moves from OpenVibe.Shared
+ 4.57.0 to 4.124.0: the Vibe tab, the timer, the realtime notification feed and the Agents tab's
+ **agent view** — the person's desk stream, the task line and worked time, and the owner's
+ **Pause/Resume**, **Curtain now**, **Mute/Unmute** and **End** controls. The new files are
+ `dock-vibe.js`, `dock-agent.js`, `dock-timer.js` and `notification-live.js`; `dock.js`,
+ `action-center.js`, `notification-ui.js`, `ov-mark.js` and `kit.css` are updated in place.
+- **Manifest:** one host permission, `https://openvibe.codes/*`, for the agent view's API and its
+ still. The CSP now allows that origin in `connect-src`, `frame-src https://openvibe.live` for the
+ embed, `img-src 'self' blob:` for the still, and `style-src 'self' 'unsafe-inline'` because the
+ Dock draws its own styles in a `<style>` element (scripts stay `'self'` only).
+- `panel.js` passes OpenVibe.Codes' origin (`agentBase`) to the Dock; `lib/config.js` names it.
+- `scripts/check-panel.js` and `test/panel.test.js` load the unpacked extension in headless Chrome
+ and check the Agents tab draws the agent view with its four controls, its styles and no CSP
+ refusal.
+- Known, pending outside this repository: OpenVibe.Codes' session check refuses the Dock's
+ `<network>/dock`-issued OAuth token, and OpenVibe.Live's `frame-ancestors` does not list an
+ extension origin, so the live view stays hidden in production until one of them accepts it.
+
+## 0.8.1 — 2026-11-06
+
+- The side panel opens on the page's Dock state and follows it: `dock.open({ doc })` gets the tab,
+ chat, agent and unread counts the page had, and the panel re-reads them every 30 s and when it
+ comes back.
+
+## 0.8.0 — 2026-11-06
+
+- One-click Dock sign-in: the Network's OAuth public client `companion` (PKCE S256, no secret),
+ read-only scopes `dock`, `activity` and `chat.read`; the access token in `storage.session`, the
+ rotating refresh token in `storage.local`, and Sign out revokes the family. The 0.7.0 pasted
+ token remains a fallback.
+
+## 0.7.0 — 2026-10-30
+
+- The OpenVibe Dock in the side panel (Chrome `side_panel`, Firefox `sidebar_action`) on every site:
+ the released OpenVibe.Shared Dock (4.54.0, then 4.57.0), bundled because the CSP allows no remote
+ script, reading and writing the same Network state as the Dock on a site.
@@ -2,9 +2,9 @@
@@ -2,9 +2,9 @@
> OpenVibe's first-party client surfaces: today, the **OpenVibe Companion** browser extension.
> OpenVibe's first-party client surfaces: today, the **OpenVibe Companion** browser extension.
-**Status:** alpha (roadmap Wave 18, client part). The extension builds, its tests pass, and Chromium
-loads the unpacked 0.3.0 build with no console or CSP errors (2026-10-15, Playwright's Chromium: branded Chrome no
-longer loads unpacked extensions from the command line). A development build of
+**Status:** alpha (roadmap Wave 18, client part). The extension builds, its tests pass, and headless
+Chrome loads the unpacked 0.9.0 build with no console or CSP errors (2026-11-07, Chrome 156 via
+CDP `Extensions.loadUnpacked`; the side panel's Agents tab draws the Dock's agent view against stubbed first parties, see the credential note below). A development build of
the coupon part looked up codes and sent a report against a local OpenVibe.Coupons. Mozilla's
the coupon part looked up codes and sent a report against a local OpenVibe.Coupons. Mozilla's
`web-ext lint` (what addons.mozilla.org runs on upload) passes with no errors, warnings or notices
`web-ext lint` (what addons.mozilla.org runs on upload) passes with no errors, warnings or notices
and runs in CI; it hasn't been loaded in a running Firefox yet. It is **not published** to any
and runs in CI; it hasn't been loaded in a running Firefox yet. It is **not published** to any
@@ -75,13 +75,13 @@ the Companion never rewrites links or adds referral tags.
@@ -75,13 +75,13 @@ the Companion never rewrites links or adds referral tags.
| | |
| | |
|---|---|
|---|---|
-| **Permissions** | `activeTab`, `identity`, `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). `identity` starts the Dock's one-click OAuth sign-in (`chrome.identity.launchWebAuthFlow` / Firefox's `browser.identity`). `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`, `identity`, `scripting`, `storage`, and `sidePanel` in the Chrome build (Firefox's `sidebar_action` needs no permission of its own). The host permissions are four OpenVibe origins: `https://openvibe.coupons/*` (the coupon API), `https://openvibe.network/*` (the Dock's state), `https://openvibe.codes/*` (the Dock's agent view and its four controls) and `https://openvibe.chat/*` (the Dock's Chats tab). `identity` starts the Dock's one-click OAuth sign-in (`chrome.identity.launchWebAuthFlow` / Firefox's `browser.identity`). `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** | The Coupons part sends 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'`. The Dock sends the person's own bearer to `https://openvibe.network` (its state, `/api/v1/me/dock`), `https://openvibe.chat` (Chats), and, when the Agents tab needs it, `https://openvibe.codes` (the agent view and its controls). |
| **Hand-offs (only when you press the button)** | "Keep on my Rent board", "Check the rent", "Watch this page", "Compare delivery apps" and "Save its rating to OpenVibe Reviews" open **a new tab you see** on `https://openvibe.rent/board`, `https://openvibe.rent/fair`, `https://openvibe.watch/watches/new`, `https://openvibe.food/delivery` or `https://openvibe.reviews/save`, with the page's address and title (and, for Reviews, the rating read from it; for Rent, the price, city and bedrooms read from it) in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. |
| **Hand-offs (only when you press the button)** | "Keep on my Rent board", "Check the rent", "Watch this page", "Compare delivery apps" and "Save its rating to OpenVibe Reviews" open **a new tab you see** on `https://openvibe.rent/board`, `https://openvibe.rent/fair`, `https://openvibe.watch/watches/new`, `https://openvibe.food/delivery` or `https://openvibe.reviews/save`, with the page's address and title (and, for Reviews, the rating read from it; for Rent, the price, city and bedrooms read from it) in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. |
| **Stored** | The optional `cpx_` install token in `storage.local` (never synced); for the Dock, the one-click OAuth access token in `storage.session` (memory, cleared when the browser closes) and the rotating refresh token in `storage.local` (this device, not synced). A pasted 0.7.0 `networkToken` is still accepted as a fallback until a later release removes it. |
| **Stored** | The optional `cpx_` install token in `storage.local` (never synced); for the Dock, the one-click OAuth access token in `storage.session` (memory, cleared when the browser closes) and the rotating refresh token in `storage.local` (this device, not synced). A pasted 0.7.0 `networkToken` is still accepted as a fallback until a later release removes it. |
-| **CSP** | `default-src 'none'; script-src 'self'; connect-src https://openvibe.coupons; object-src 'none'`. There is no inline script, no remote code, no `eval`, and no `innerHTML`: the popup builds DOM nodes and sets text. |
+| **CSP** | `default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; connect-src https://openvibe.coupons https://openvibe.network https://openvibe.chat https://openvibe.codes; frame-src https://openvibe.live; object-src 'none'; base-uri 'none'`. The Dock draws its own styles in a `<style>` element (styles only; script stays `'self'`), the agent view shows its still from a local `blob:`, and its Live embed is the only frame. There is no inline script, no remote code, no `eval`, and no `innerHTML` in the extension's own code: the popup builds DOM nodes and sets text. |
### The credential: an install token, never a session
### The credential: an install token, never a session
@@ -106,13 +106,21 @@ covered in Coupons' own tests: lookups are identical for every caller, and the A
@@ -106,13 +106,21 @@ covered in Coupons' own tests: lookups are identical for every caller, and the A
### The Dock in the side panel
### 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
+The **OpenVibe Dock** — the pill, the card with its Vibe, Agents, Chats and Activity tabs — sits in
+the browser's side panel (Chrome's `side_panel`, Firefox's `sidebar_action`), on every site. It is the
+released OpenVibe.Shared Dock 4.124.0, bundled under `browser/shared/` because the extension's CSP allows no
remote script, and it reads and writes the same state on the Network as the Dock on a site, so it
remote script, and it reads and writes the same state on the Network as the Dock on a site, so it
opens where the person left off. It hands that state to the Dock as it opens (the page's tab, chat, agent and
opens where the person left off. It hands that state to the Dock as it opens (the page's tab, chat, agent and
unread counts) and re-reads it every 30 s, so a change on the page reaches the open side panel.
unread counts) and re-reads it every 30 s, so a change on the page reaches the open side panel.
+The Agents tab draws the person's own desk stream the same way a site's Dock does: the Live embed, the
+task line, and the owner's **Pause/Resume**, **Curtain now**, **Mute/Unmute** and **End** controls. The
+view is read with the person's token from OpenVibe.Codes (`GET /api/v1/me/agent-view`); the extension
+allows Codes and Live's embed for exactly that. Codes' normal session check does not yet accept the
+Dock's read-only OAuth token (issuer `<network>/dock`), so a production side panel keeps the view
+hidden until Codes or the Network accepts that credential; `scripts/check-panel.js` proves the bundled
+Dock itself draws it. Live's embed also allows first-party site origins, not an extension origin yet.
+
It needs the person's own OpenVibe sign-in. Press **Sign in** in the panel or the settings page: the
It needs the person's own OpenVibe sign-in. Press **Sign in** in the panel or the settings page: the
Network's account chooser opens, the extension is a public OAuth client (`companion`, PKCE S256, no
Network's account chooser opens, the extension is a public OAuth client (`companion`, PKCE S256, no
secret), and the Dock gets a read-only token (`dock`, `activity`, `chat.read`) that refreshes itself.
secret), and the Dock gets a read-only token (`dock`, `activity`, `chat.read`) that refreshes itself.
@@ -128,10 +136,12 @@ browser/
@@ -128,10 +136,12 @@ browser/
manifest.json MV3; Chromium (minimum_chrome_version 114) 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.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)
panel.css the Dock fills the panel (no floating card, no pill)
- shared/ the released OpenVibe.Shared Dock (v4.57.0), bundled: the CSP allows no remote script
+ shared/ the released OpenVibe.Shared Dock 4.124.0, bundled: the CSP allows no remote script
+ (dock.js, dock-vibe.js, dock-agent.js, dock-timer.js, action-center.js,
+ notification-ui.js, notification-live.js, ov-mark.js, kit.css)
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 (Coupons), NETWORK_ORIGIN and CHAT_ORIGIN (the Dock), CODES_ORIGIN (its agent view); HANDOFF_ORIGINS (Rent, Watch, Food, Reviews)
lib/host.js URL → hostname (everything else dropped)
lib/host.js URL → hostname (everything else dropped)
lib/handoff.js the Rent board, Watch, Food delivery and Reviews addresses, built from a cleaned URL; which sites are rentals and review sites
lib/handoff.js the Rent board, Watch, Food delivery and Reviews addresses, built from a cleaned URL; which sites are rentals and review sites
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
@@ -148,6 +158,7 @@ scripts/
@@ -148,6 +158,7 @@ scripts/
audit.js the permission audit (manifest, CSP, pages)
audit.js the permission audit (manifest, CSP, pages)
pack.js zip for loading, or --stores: the Chrome and Firefox store zips (runs the audit first)
pack.js zip for loading, or --stores: the Chrome and Firefox store zips (runs the audit first)
check-autoapply.js lib/autoapply.js on test/fixtures/checkout-*.html in headless Chrome (CDP, no dependencies)
check-autoapply.js lib/autoapply.js on test/fixtures/checkout-*.html in headless Chrome (CDP, no dependencies)
+ check-panel.js the unpacked extension in headless Chrome: the panel's Agents tab draws the agent view (CDP, no dependencies)
icons.js draws the icons (no dependencies)
icons.js draws the icons (no dependencies)
```
```
@@ -159,13 +170,14 @@ npm test
@@ -159,13 +170,14 @@ 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`, `lib/network-api.js` and `lib/auth.js` fetch. | `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, any style source beyond `'unsafe-inline'`, any image beyond `'self' blob:`, any frame beyond Live, MV2. The first-party origins agree across config, host permissions 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`, `lib/network-api.js`, `lib/auth.js` and the bundled Dock fetch. | `test/manifest.test.js` |
+| The side panel in a real headless Chrome: the unpacked extension opens `panel.html` on the Agents tab with a stubbed Network/Chat/Codes, and the agent view draws its stream frame, line, meta and Pause/Resume, Curtain now, Mute/Unmute and End, with its styles applied and no CSP refusal. Skipped when no Chrome is installed. | `test/panel.test.js` (`scripts/check-panel.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 Coupons part 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, and only `cpx_` tokens are stored there. | `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` |
| `scripts/pack.js`: the zip is exactly `browser/`, audited and reproducible. A development build rewrites the origin in all three places. Remote `http` origins are refused. | `test/pack.test.js` |
| `scripts/pack.js`: the zip is exactly `browser/`, audited and reproducible. A development build rewrites the origin in all three places. Remote `http` origins are refused. | `test/pack.test.js` |
-Beyond the automated tests, this was checked by hand on 2026-09-22:
+Beyond the automated tests, the coupon flow was checked by hand on 2026-09-22:
- Headless Chrome 150, loaded through CDP `Extensions.loadUnpacked`, accepted the manifest and
- Headless Chrome 150, loaded through CDP `Extensions.loadUnpacked`, accepted the manifest and
rendered both pages with no console errors or CSP violations.
rendered both pages with no console errors or CSP violations.
@@ -173,7 +185,12 @@ Beyond the automated tests, this was checked by hand on 2026-09-22:
@@ -173,7 +185,12 @@ Beyond the automated tests, this was checked by hand on 2026-09-22:
configured. It resolved a shop, listed its code, and posted a report with a `cpx_` token. The
configured. It resolved a shop, listed its code, and posted a report with a `cpx_` token. The
report was stored with channel `extension`.
report was stored with channel `extension`.
-That check is not in CI. Firefox hasn't been tried.
+On 2026-11-07 the same CDP load ran the side panel in headless Chrome 156 (now `test/panel.test.js`
+and `scripts/check-panel.js`): with a stubbed Network, Chat and Codes, `panel.html` opened on the
+Agents tab, drew the agent view's Live frame, task line and `Paused by @ada` meta, and showed
+Pause/Resume, Curtain now, Mute/Unmute and End, with the Dock's styles applied and no CSP refusal.
+The 2026-09-22 coupon flow is not in CI; the panel check is, and it skips when no Chrome is installed.
+Firefox hasn't been tried.
## Packing (never published from here)
## Packing (never published from here)
@@ -198,7 +215,9 @@ shows "Allow access to openvibe.coupons".
@@ -198,7 +215,9 @@ shows "Allow access to openvibe.coupons".
## Before a store release (for the lead)
## Before a store release (for the lead)
1. **OpenVibe.Coupons is already public** (see its README; live at `https://openvibe.coupons` since
1. **OpenVibe.Coupons is already public** (see its README; live at `https://openvibe.coupons` since
- 2026-10-09). The release build only talks to `https://openvibe.coupons`.
+ 2026-10-09). The release build talks to `https://openvibe.coupons` for codes and to
+ `https://openvibe.network`, `https://openvibe.chat` and `https://openvibe.codes` for the Dock,
+ each named in the manifest and the CSP and nothing else.
2. **Store listings:** `npm run pack:stores` builds `dist/openvibe-companion-<version>-chrome.zip`
2. **Store listings:** `npm run pack:stores` builds `dist/openvibe-companion-<version>-chrome.zip`
(no `browser_specific_settings`) and `-firefox.zip` (no `minimum_chrome_version`), byte for byte
(no `browser_specific_settings`) and `-firefox.zip` (no `minimum_chrome_version`), byte for byte
reproducible. Upload them with the fields in [store/listing.md](store/listing.md), the single
reproducible. Upload them with the fields in [store/listing.md](store/listing.md), the single
@@ -223,6 +242,14 @@ shows "Allow access to openvibe.coupons".
@@ -223,6 +242,14 @@ shows "Allow access to openvibe.coupons".
- `GET /api/v1/merchants/:id/coupons`
- `GET /api/v1/merchants/:id/coupons`
- `POST /api/v1/coupons/:id/report`
- `POST /api/v1/coupons/:id/report`
- `cpx_` install tokens from `/connect-extension`
- `cpx_` install tokens from `/connect-extension`
+- [OpenVibe.Codes](https://github.com/OpenVibers/OpenVibe.Codes) `/api/v1/me/agent-view` (the
+ Dock's agent view: the state, the still, and the pause, curtain, mute and end controls). Its
+ session check does not accept the Dock's `<network>/dock`-issued OAuth token yet, so the view is
+ bundled and verified against a stub but stays hidden in production until Codes or Network accepts
+ that credential.
+- [OpenVibe.Live](https://github.com/OpenVibers/OpenVibe.Live) `/embed/agent/<ags_id>?vt=…`, the
+ agent view's frame. Its `frame-ancestors` list is first-party site origins; an extension origin is
+ not on it yet.
- No npm dependencies. Tests and scripts use Node's standard library.
- No npm dependencies. Tests and scripts use Node's standard library.
---
---
@@ -5,17 +5,17 @@
@@ -5,17 +5,17 @@
"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.8.1",
+ "version": "0.9.0",
"surfaces": {
"surfaces": {
"browser": {
"browser": {
"name": "OpenVibe Companion",
"name": "OpenVibe Companion",
- "version": "0.8.1",
+ "version": "0.9.0",
"manifest": 3,
"manifest": 3,
"targets": [
"targets": [
"chromium >= 114",
"chromium >= 114",
"firefox >= 128"
"firefox >= 128"
],
],
- "api": "https://openvibe.coupons (OpenVibe.Coupons public API), https://openvibe.network (the Dock's state), https://openvibe.chat (the Dock's Chats tab)",
+ "api": "https://openvibe.coupons (OpenVibe.Coupons public API), https://openvibe.network (the Dock's state), https://openvibe.chat (the Dock's Chats tab), https://openvibe.codes (the Dock's agent view and its four controls); the agent embed is framed from https://openvibe.live",
"permissions": [
"permissions": [
"activeTab",
"activeTab",
"identity",
"identity",
@@ -26,11 +26,12 @@
@@ -26,11 +26,12 @@
"hostPermissions": [
"hostPermissions": [
"https://openvibe.coupons/*",
"https://openvibe.coupons/*",
"https://openvibe.network/*",
"https://openvibe.network/*",
+ "https://openvibe.codes/*",
"https://openvibe.chat/*"
"https://openvibe.chat/*"
],
],
"credential": "optional cpx_ install token (coupons.lookup, coupons.report); the Dock one-click OAuth public client (scopes dock, activity, chat.read; access in storage.session, rotating refresh in storage.local); the 0.7.0 pasted OpenVibe token is still accepted as a fallback until a later release",
"credential": "optional cpx_ install token (coupons.lookup, coupons.report); the Dock one-click OAuth public client (scopes dock, activity, chat.read; access in storage.session, rotating refresh in storage.local); the 0.7.0 pasted OpenVibe token is still accepted as a fallback until a later release",
"publishedToStores": false,
"publishedToStores": false,
- "loadedInChrome": "yes: headless Chromium loads the build (popup and options render, no console errors); lib/autoapply.js passes three fixture checkout pages in headless Chrome; the one-click Dock sign-in is covered by test/auth.test.js",
+ "loadedInChrome": "yes: headless Chrome 156 loads the 0.9.0 build via CDP Extensions.loadUnpacked; the side panel's Agents tab draws the Dock's agent view against a stubbed Network/Chat/Codes (the Live stream frame, the task line and meta, and Pause/Resume, Curtain now, Mute/Unmute and End) with its styles applied and no CSP refusal (scripts/check-panel.js); lib/autoapply.js passes three fixture checkout pages in headless Chrome; the one-click Dock sign-in is covered by test/auth.test.js",
"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",
"handoffs": [
"handoffs": [
"https://openvibe.rent/board (keep a listing)",
"https://openvibe.rent/board (keep a listing)",
@@ -43,19 +44,21 @@
@@ -43,19 +44,21 @@
"storeListing": "store/ (listing, privacy and permission answers, screenshot plan); support URL https://openvibe.coupons/companion",
"storeListing": "store/ (listing, privacy and permission answers, screenshot plan); support URL https://openvibe.coupons/companion",
"features": [
"features": [
"OpenVibe Dock one-click sign-in: public OAuth client companion (PKCE S256, no secret), read-only scopes dock/activity/chat.read, access token in storage.session, rotating refresh token in storage.local, Sign out revokes the family, the account page lists it under Signed-in apps",
"OpenVibe Dock one-click sign-in: public OAuth client companion (PKCE S256, no secret), read-only scopes dock/activity/chat.read, access token in storage.session, rotating refresh token in storage.local, Sign out revokes the family, the account page lists it under Signed-in apps",
- "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock (v4.57.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), and re-reading it every 30 s so the page, the pop-out and the panel agree; approvals/sends are hidden with the read-only scopes"
+ "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock 4.124.0 bundled under browser/shared/ (dock.js, dock-vibe.js, dock-agent.js, dock-timer.js, action-center.js, notification-ui.js, notification-live.js, ov-mark.js, kit.css), reading and writing the same Network state as the Dock on a site, with the same tabs (Vibe, Agents, Chats, Activity), and re-reading it every 30 s so the page, the pop-out and the panel agree; the Agents tab draws the person's agent view with Pause/Resume, Curtain now, Mute/Unmute and End, and approvals/sends are hidden with the read-only scopes"
],
],
"pending": [
"pending": [
"owner: create the Chrome Web Store and AMO developer listings and upload the store zips with store/'s answers and screenshots; the Chrome id is fixed by browser/manifest.json key",
"owner: create the Chrome Web Store and AMO developer listings and upload the store zips with store/'s answers and screenshots; the Chrome id is fixed by browser/manifest.json key",
"Firefox: the extension loaded and run in a real Firefox (web-ext lint passes; nothing has run it yet)",
"Firefox: the extension loaded and run in a real Firefox (web-ext lint passes; nothing has run it yet)",
- "use: on 2026-10-22 OpenVibe.Coupons had issued no install token and received no code report; it held 169 codes for 16 of its 37 merchants, all from the DealNews feed through OpenVibe.Sources"
+ "use: on 2026-10-22 OpenVibe.Coupons had issued no install token and received no code report; it held 169 codes for 16 of its 37 merchants, all from the DealNews feed through OpenVibe.Sources",
+ "Codes/Live credential: the Dock's agent view now asks OpenVibe.Codes with the one-click OAuth token, but Codes' session check refuses the <network>/dock issuer, and Live's embed frame-ancestors allows first-party sites but not an extension origin; the view is bundled and verified against a stub, and needs one of those two to accept the extension before it shows in production"
]
]
}
}
},
},
"pending": [
"pending": [
"owner: create the Chrome Web Store and AMO developer listings and upload the store zips with store/'s answers and screenshots; the Chrome id is fixed by browser/manifest.json key",
"owner: create the Chrome Web Store and AMO developer listings and upload the store zips with store/'s answers and screenshots; the Chrome id is fixed by browser/manifest.json key",
"Firefox: the extension loaded and run in a real Firefox (web-ext lint passes; nothing has run it yet)",
"Firefox: the extension loaded and run in a real Firefox (web-ext lint passes; nothing has run it yet)",
- "use: on 2026-10-22 OpenVibe.Coupons had issued no install token and received no code report; it held 169 codes for 16 of its 37 merchants, all from the DealNews feed through OpenVibe.Sources"
+ "use: on 2026-10-22 OpenVibe.Coupons had issued no install token and received no code report; it held 169 codes for 16 of its 37 merchants, all from the DealNews feed through OpenVibe.Sources",
+ "Codes/Live credential: the Dock's agent view now asks OpenVibe.Codes with the one-click OAuth token, but Codes' session check refuses the <network>/dock issuer, and Live's embed frame-ancestors allows first-party sites but not an extension origin; the view is bundled and verified against a stub, and needs one of those two to accept the extension before it shows in production"
],
],
"deployed": false,
"deployed": false,
"features": [
"features": [
@@ -64,10 +67,10 @@
@@ -64,10 +67,10 @@
"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 an OpenVibe session; and the Dock one-click OAuth sign-in (scopes dock, activity, chat.read; access in storage.session, rotating refresh in storage.local; the 0.7.0 pasted token is still a fallback until a later release)",
"an optional cpx_ install token, never an OpenVibe session; and the Dock one-click OAuth sign-in (scopes dock, activity, chat.read; access in storage.session, rotating refresh in storage.local; the 0.7.0 pasted token is still a fallback until a later release)",
- "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock (v4.57.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), and re-reading it every 30 s so the page, the pop-out and the panel agree; approvals/sends are hidden with the read-only scopes",
+ "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock 4.124.0 bundled under browser/shared/ (dock.js, dock-vibe.js, dock-agent.js, dock-timer.js, action-center.js, notification-ui.js, notification-live.js, ov-mark.js, kit.css), reading and writing the same Network state as the Dock on a site, with the same tabs (Vibe, Agents, Chats, Activity), and re-reading it every 30 s so the page, the pop-out and the panel agree; the Agents tab draws the person's agent view with Pause/Resume, Curtain now, Mute/Unmute and End, and approvals/sends are hidden with the read-only scopes",
"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-11-06"
+ "updated": "2026-11-07"
}
}
@@ -1,9 +1,10 @@
@@ -1,9 +1,10 @@
/*
/*
* OpenVibe Companion — configuration.
* OpenVibe Companion — configuration.
*
*
- * API_ORIGIN is the ONLY origin this extension ever sends a request to. It must match the manifest's
- * host_permissions and the connect-src of its CSP; scripts/pack.js rewrites all three together for
- * a development build (--api http://localhost:4850) and test/manifest.test.js checks they agree.
+ * API_ORIGIN is where the Coupons API lives. It must match the manifest's host_permissions and the
+ * connect-src of its CSP; scripts/pack.js rewrites all three together for a development build
+ * (--api http://localhost:4850) and test/manifest.test.js checks they agree. NETWORK_ORIGIN,
+ * CHAT_ORIGIN and CODES_ORIGIN are the Dock's: its state, its Chats tab and its agent view.
*
*
* HANDOFF_ORIGINS are the sites the Companion may open a tab on when you press one of its buttons
* HANDOFF_ORIGINS are the sites the Companion may open a tab on when you press one of its buttons
* (lib/handoff.js): the Rent board, a new Watch, Food's delivery comparison, and Reviews' page for saving the rating
* (lib/handoff.js): the Rent board, a new Watch, Food's delivery comparison, and Reviews' page for saving the rating
@@ -31,6 +32,9 @@
@@ -31,6 +32,9 @@
// origins the side panel may reach besides the API origin.
// origins the side panel may reach besides the API origin.
NETWORK_ORIGIN: 'https://openvibe.network',
NETWORK_ORIGIN: 'https://openvibe.network',
CHAT_ORIGIN: 'https://openvibe.chat',
CHAT_ORIGIN: 'https://openvibe.chat',
+ // CODES_ORIGIN is OpenVibe.Codes' API: the Dock's agent view (GET /api/v1/me/agent-view and the
+ // four controls), and the still Codes serves when nothing is live.
+ CODES_ORIGIN: 'https://openvibe.codes',
// One-click Dock sign-in (OAuth public client, no secret). The redirect URI is the browser's own
// One-click Dock sign-in (OAuth public client, no secret). The redirect URI is the browser's own
// extension URL; the exact values are also in the Network client row. A build whose runtime
// extension URL; the exact values are also in the Network client row. A build whose runtime
// identity.getRedirectURL() is not one of these refuses to sign in rather than send an
// identity.getRedirectURL() is not one of these refuses to sign in rather than send an
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"manifest_version": 3,
"manifest_version": 3,
"name": "OpenVibe Companion",
"name": "OpenVibe Companion",
"short_name": "OpenVibe",
"short_name": "OpenVibe",
- "version": "0.8.1",
+ "version": "0.9.0",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqVJohdlaysL2HgSsj2Fsd8PxPvXJkZHjEvNyGSYNSEpZBoIeZd7Cm7xznGVikDBbe7WseaNLt+HheX4WhN94jU/FrS2016AqQG9NTHoymJR8XSrPPDyhc85EUpICOFwqfFsSOykzaRyHZVcrvoepYwntwFjWdt1ul4VozwkqKovZoKUgCn398aNvZ6AH/J0xb0xWMiOiL9CvUWN/jK4wv0ShnJevpmyBBHL273CkQvfMS3VUBvBjq3seMU8BAHkE5RI3c2y/MRdjwYGsCoMU6MPYwt46PQZ0S8SPnFFozJndkpoqc/ue9oXhd77b12GKlJ1BPhHgn8BSx4IAvjeL1wIDAQAB",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqVJohdlaysL2HgSsj2Fsd8PxPvXJkZHjEvNyGSYNSEpZBoIeZd7Cm7xznGVikDBbe7WseaNLt+HheX4WhN94jU/FrS2016AqQG9NTHoymJR8XSrPPDyhc85EUpICOFwqfFsSOykzaRyHZVcrvoepYwntwFjWdt1ul4VozwkqKovZoKUgCn398aNvZ6AH/J0xb0xWMiOiL9CvUWN/jK4wv0ShnJevpmyBBHL273CkQvfMS3VUBvBjq3seMU8BAHkE5RI3c2y/MRdjwYGsCoMU6MPYwt46PQZ0S8SPnFFozJndkpoqc/ue9oXhd77b12GKlJ1BPhHgn8BSx4IAvjeL1wIDAQAB",
"description": "Coupon codes for the shop you are on, tried in its coupon box when you ask; the same restaurant on every delivery app; a review site's rating saved to OpenVibe.Reviews; 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.",
"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",
@@ -38,10 +38,11 @@
@@ -38,10 +38,11 @@
"host_permissions": [
"host_permissions": [
"https://openvibe.coupons/*",
"https://openvibe.coupons/*",
"https://openvibe.network/*",
"https://openvibe.network/*",
+ "https://openvibe.codes/*",
"https://openvibe.chat/*"
"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 https://openvibe.network https://openvibe.chat; object-src 'none'; base-uri 'none'"
+ "extension_pages": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; connect-src https://openvibe.coupons https://openvibe.network https://openvibe.chat https://openvibe.codes; frame-src https://openvibe.live; object-src 'none'; base-uri 'none'"
},
},
"minimum_chrome_version": "114",
"minimum_chrome_version": "114",
"browser_specific_settings": {
"browser_specific_settings": {
@@ -5,7 +5,9 @@
@@ -5,7 +5,9 @@
* The Dock is the released OpenVibe.Shared one, bundled under browser/shared/ because the
* 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
* 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
* 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.
+ * same tabs (Vibe, Agents, Chats, Activity) and the same place. The Agents tab's agent view reads
+ * the person's desk stream from OpenVibe.Codes (the extension's `agentBase`) and frames its embed
+ * from OpenVibe.Live.
*
*
* The sign-in is the Network's OAuth public client (lib/auth.js): one press, PKCE, a scoped access
* The sign-in is the Network's OAuth public client (lib/auth.js): one press, PKCE, a scoped access
* token in storage.session and a rotating refresh token in storage.local. The 0.7.0 pasted
* token in storage.session and a rotating refresh token in storage.local. The 0.7.0 pasted
@@ -70,6 +72,7 @@
@@ -70,6 +72,7 @@
me: account && account.username ? account.username : '',
me: account && account.username ? account.username : '',
apiBase: opts.config.NETWORK_ORIGIN,
apiBase: opts.config.NETWORK_ORIGIN,
chatBase: opts.config.CHAT_ORIGIN,
chatBase: opts.config.CHAT_ORIGIN,
+ agentBase: opts.config.CODES_ORIGIN,
sibling: opts.sibling,
sibling: opts.sibling,
});
});
// Hand the state we just read to the Dock as it opens, so its own first read cannot race it back to the
// Hand the state we just read to the Dock as it opens, so its own first read cannot race it back to the
@@ -96,8 +96,8 @@
@@ -96,8 +96,8 @@
.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 { 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: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 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 button.spin svg { animation: ovac-pulse 1s ease-in-out infinite; }
+ @keyframes ovac-pulse { 0%, 100% { opacity: .35; } 50% { opacity: 1; } }
.ovac-acts [data-act="close"] { display: none; }
.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 { 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::-webkit-scrollbar { display: none; }
@@ -137,7 +137,10 @@
@@ -137,7 +137,10 @@
.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 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-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 { 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-wait span { position: relative; display: inline-block; width: 6px; height: 6px; margin: 0 10px; border: 0; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovac-hop 1s ease-in-out .15s infinite; }
+ .ovac-wait span::before, .ovac-wait span::after { content: ''; position: absolute; top: 0; width: 6px; height: 6px; border-radius: 50%; background: inherit; animation: ovac-hop 1s ease-in-out infinite; }
+ .ovac-wait span::before { left: -10px; } .ovac-wait span::after { left: 10px; animation-delay: .3s; }
+ @keyframes ovac-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-4px); opacity: 1; } }
.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 { 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; }
.ovac-foot a { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; text-decoration: none; }
@media (max-width: 640px) {
@media (max-width: 640px) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,268 @@
@@ -0,0 +1,268 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — the Dock's agent view (Dock plan 2026-10-30, K-D3; Agent Streams S1), Shared 4.120.0
+//
+// The person's own agent desk stream at the top of the Dock's Agents tab. dock.js loads this file the first time that
+// tab opens:
+// OpenVibeDockAgent.mount(container, { token, base, active }) → { refresh(), destroy() }
+//
+// `base` is OpenVibe.Codes' API (GET /api/v1/me/agent-view, the same POST for pause, Curtain now, mute and End, and
+// the frame). Agent Streams S5 adds the four controls: a moderator cannot resume the owner's pause on Live, and the
+// Dock is the owner's own view, so it may always resume.
+// Codes mints the view ticket server-side with the person's own token — an iframe on another site has no Live cookie —
+// so the frame it answers is already Live's /embed/agent/<ags_id>?vt=wvt_…: the picture, the "AI agent" label, the
+// curtain state and the viewer count. The embed renews its own ticket while it plays.
+//
+// Nothing live: the last frame through the same API (an <img> cannot carry a token), with the task and the worked time.
+// There is no step line yet: steps are Agent Streams S2, and this view shows none until Live serves them.
+// Everything is set as text, never markup; the frame's address is the one Codes answered, as an https Live address.
+// ═══════════════════════════════════════════════════════════════
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OpenVibeDockAgent) return;
+
+ const POLL_MS = 20000;
+ const EMBED = /^https:\/\/openvibe\.live\/embed\/agent\/ags_[0-9A-HJKMNP-TV-Z]{26}\?vt=wvt_[A-Za-z0-9_-]+$/;
+ const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
+ const G = {
+ eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="3"/><path d="M4 4l16 16"/>',
+ pause: '<rect x="7" y="5" width="3.5" height="14" rx="1"/><rect x="13.5" y="5" width="3.5" height="14" rx="1"/>',
+ play: '<path d="M8 5.5v13l10-6.5z"/>',
+ mute: '<path d="M4 9.5h4L13 5v14l-5-4.5H4z"/><path d="M17 9l4 4m0-4l-4 4"/>',
+ sound: '<path d="M4 9.5h4L13 5v14l-5-4.5H4z"/><path d="M17 9.5a4 4 0 0 1 0 5"/>',
+ stop: '<rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/>',
+ open: '<path d="M14 4h6v6M20 4l-9 9M19 14v5a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 4 19V7a1.5 1.5 0 0 1 1.5-1.5H10"/>',
+ };
+
+ // The style goes into the document the view lives in: the page, or the Dock's own window when it is popped out.
+ function styles(doc) {
+ const d = doc || (typeof document !== 'undefined' ? document : null);
+ if (!d || d.getElementById('ovda-styles')) return;
+ const st = d.createElement('style');
+ st.id = 'ovda-styles';
+ st.textContent = `
+ .ovda { display: none; flex: none; padding: 8px 8px 2px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }
+ .ovda.on { display: block; }
+ .ovda-stage { position: relative; overflow: hidden; aspect-ratio: 16 / 9; border-radius: 14px; background: #0a0c11; border: 1px solid var(--border, rgba(255,255,255,.1)); }
+ .ovda-frame, .ovda-still { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; object-fit: contain; background: #0a0c11; }
+ .ovda-still { object-fit: cover; opacity: .92; }
+ .ovda-wait { position: absolute; inset: 0; display: grid; place-items: center; color: var(--text-muted, #8b8b96); font-size: 12.5px; text-align: center; padding: 12px; }
+ .ovda-line { display: flex; align-items: center; gap: 6px; min-height: 34px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }
+ .ovda-line b { color: var(--text-primary, #ececf1); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .ovda-line b a { color: inherit; text-decoration: none; }
+ .ovda-line b a:hover { text-decoration: underline; }
+ .ovda-line .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); flex: none; }
+ .ovda-line .dot.live { background: var(--live-red, #e5484d); animation: ovda-pulse 1.6s ease-in-out infinite; }
+ .ovda-line .dot.off { background: var(--text-muted, #8b8b96); }
+ .ovda-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 11.5px; color: var(--text-muted, #8b8b96); }
+ .ovda-acts { display: flex; gap: 6px; margin-left: auto; }
+ .ovda-acts button, .ovda-acts a { display: inline-flex; align-items: center; gap: 5px; min-height: 30px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12));
+ background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; text-decoration: none; }
+ .ovda-acts button:hover, .ovda-acts a:hover { color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); }
+ .ovda-acts button:disabled { opacity: .5; cursor: default; }
+ .ovda-acts svg { width: 14px; height: 14px; }
+ .ovda-acts button[data-act="end"] { color: color-mix(in srgb, var(--live-red, #e5484d) 65%, var(--text-primary, #ececf1)); }
+ .ovda svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+ @keyframes ovda-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
+ @media (prefers-reduced-motion: reduce) { .ovda-line .dot.live { animation: none; } }
+ `;
+ (d.head || d.documentElement).appendChild(st);
+ }
+
+ const mins = (s) => {
+ const m = Math.max(0, Math.round((Number(s) || 0) / 60));
+ return m < 1 ? 'under a minute' : m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
+ };
+
+ function mount(container, opts = {}) {
+ const d = container.ownerDocument || document;
+ styles(d);
+ const o = { ...opts };
+ const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
+ let gone = false, timer = 0, busy = false, agent = null, frameUrl = null, lastFrame = '';
+ container.innerHTML = '';
+ container.classList.add('ovda');
+ const stage = h('div', 'ovda-stage');
+ const line = h('div', 'ovda-line');
+ const meta = h('div', 'ovda-meta');
+ const acts = h('div', 'ovda-acts');
+ const head = h('div');
+ head.append(line, meta, acts);
+ const body = h('div');
+ body.append(stage, head);
+ container.append(body);
+
+ async function api(path, init = {}) {
+ const base = String(o.base || 'https://openvibe.codes').replace(/\/+$/, '');
+ const t = typeof o.token === 'function' ? await o.token() : o.token;
+ const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
+ const r = await fetch(base + path, { ...init, headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });
+ const b = await r.json().catch(() => ({}));
+ if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status });
+ return b;
+ }
+
+ /** The still is behind the person's token, so it is read here and shown as a local object URL. */
+ function still(url) {
+ if (!url || frameUrl === url) return;
+ frameUrl = url;
+ Promise.resolve(typeof o.token === 'function' ? o.token() : o.token).then((t) => fetch(url, { headers: t ? { authorization: `Bearer ${t}` } : {}, credentials: 'omit' }))
+ .then((r) => (r.ok ? r.blob() : null))
+ .then((blob) => {
+ if (!blob || gone || frameUrl !== url) return;
+ const next = URL.createObjectURL(blob);
+ const img = h('img', 'ovda-still');
+ img.alt = 'The last frame of your agent';
+ img.src = next;
+ img.decoding = 'async';
+ const old = stage.querySelector('img.ovda-still');
+ if (old) { stage.removeChild(old); }
+ stage.append(img);
+ if (lastFrame) URL.revokeObjectURL(lastFrame);
+ lastFrame = next;
+ })
+ .catch(() => { /* no still: the line says enough */ });
+ }
+
+ function paint() {
+ if (gone) return;
+ const has = !!agent;
+ container.classList.toggle('on', has);
+ if (!has) { stage.innerHTML = ''; line.innerHTML = ''; meta.textContent = ''; acts.innerHTML = ''; return; }
+
+ const live = agent.state === 'live';
+ const ended = agent.state === 'ended';
+ // The picture: the live embed when there is one, else the still.
+ const embed = typeof agent.embed_url === 'string' && EMBED.test(agent.embed_url) ? agent.embed_url : null;
+ const shown = stage.querySelector('iframe');
+ if (embed && (!shown || shown.getAttribute('src') !== embed)) {
+ stage.innerHTML = '';
+ const f = h('iframe', 'ovda-frame');
+ f.src = embed;
+ f.title = 'Your agent\'s screen';
+ f.setAttribute('allow', 'autoplay; fullscreen; picture-in-picture');
+ f.setAttribute('referrerpolicy', 'no-referrer');
+ stage.append(f);
+ } else if (!embed) {
+ if (shown) stage.innerHTML = '';
+ if (agent.last_frame_url) still(agent.last_frame_url);
+ else if (!stage.querySelector('img')) stage.append(h('div', 'ovda-wait', ended ? 'Nothing to show' : 'Waiting for the desk'));
+ }
+
+ // The line: what it is doing, one row.
+ line.innerHTML = '';
+ line.append(h('span', `dot ${live ? 'live' : 'off'}`));
+ const title = h('b');
+ const url = agent.task && /^https:\/\//.test(String(agent.task.url || '')) ? agent.task.url : (/^https:\/\//.test(String(agent.page_url || '')) ? agent.page_url : null);
+ const text = (agent.task && agent.task.title) || agent.title || 'Your agent';
+ if (url) { const a = h('a', null, text); a.href = url; a.rel = 'noopener'; a.target = '_blank'; title.append(a); }
+ else title.textContent = text;
+ line.append(title);
+
+ // The meta: the state, the viewers, the curtain, the worked time.
+ meta.textContent = '';
+ const bits = [live ? 'Live' : ended ? 'Ended' : 'Waiting for the desk'];
+ if (live && agent.viewers > 0) bits.push(`${agent.viewers} watching`);
+ if (agent.paused) bits.push(agent.paused_by ? `Paused by @${agent.paused_by}` : 'Paused');
+ if (live && agent.curtain && agent.curtain.up) bits.push('Private screen');
+ bits.push(`worked ${mins(agent.worked_seconds)}`);
+ meta.textContent = bits.join(' · ');
+
+ // S5: the four controls, for the owner (the Dock is their own view, so resume is always theirs).
+ acts.innerHTML = '';
+ if (!ended) {
+ const pause = h('button', null);
+ pause.type = 'button';
+ pause.dataset.act = 'pause';
+ pause.innerHTML = `${svg(agent.paused ? G.play : G.pause)}${agent.paused ? 'Resume' : 'Pause'}`;
+ pause.title = agent.paused && agent.paused_by ? `Paused by @${agent.paused_by}` : 'Pause the agent at the next safe point';
+ acts.append(pause);
+ }
+ if (live && !(agent.curtain && agent.curtain.up)) {
+ const b = h('button', null);
+ b.type = 'button';
+ b.dataset.act = 'curtain';
+ b.innerHTML = `${svg(G.eye)}Curtain now`;
+ b.title = 'Hide the screen now; your desk lifts it when it is clear';
+ acts.append(b);
+ }
+ if (!ended) {
+ const mute = h('button', null);
+ mute.type = 'button';
+ mute.dataset.act = 'mute';
+ mute.innerHTML = `${svg(agent.voice_muted ? G.sound : G.mute)}${agent.voice_muted ? 'Unmute' : 'Mute'}`;
+ mute.title = agent.voice_muted ? 'Let the agent speak again' : 'Mute the agent\'s voice';
+ acts.append(mute);
+ }
+ if (!ended) {
+ const b = h('button', null);
+ b.type = 'button';
+ b.dataset.act = 'end';
+ b.innerHTML = `${svg(G.stop)}End`;
+ acts.append(b);
+ }
+ if (agent.page_url && /^https:\/\/openvibe\.live\//.test(agent.page_url)) {
+ const a = h('a');
+ a.href = agent.page_url;
+ a.target = '_blank';
+ a.rel = 'noopener';
+ a.setAttribute('aria-label', 'Open the stream on OpenVibe.Live');
+ a.title = 'Open the stream on OpenVibe.Live';
+ a.innerHTML = svg(G.open);
+ acts.append(a);
+ }
+ }
+
+ async function refresh() {
+ if (gone || busy) return;
+ if (typeof o.active === 'function' && !o.active()) { schedule(); return; }
+ busy = true;
+ clearTimeout(timer);
+ try {
+ const doc = await api('/api/v1/me/agent-view');
+ if (gone) return;
+ agent = doc && doc.agent && typeof doc.agent === 'object' ? doc.agent : null;
+ paint();
+ } catch (e) {
+ // Codes away, or Live away: no stream, and the rest of the tab is untouched.
+ if (!gone) { agent = null; paint(); }
+ } finally {
+ busy = false;
+ schedule();
+ }
+ }
+ function schedule() {
+ clearTimeout(timer);
+ if (!gone && agent && agent.state !== 'ended') timer = setTimeout(refresh, POLL_MS);
+ }
+
+ async function control(what, btn, body = {}) {
+ if (!btn || btn.disabled) return;
+ btn.disabled = true;
+ try {
+ const doc = await api(`/api/v1/me/agent-view/${what}`, { method: 'POST', json: true, body });
+ agent = doc && doc.agent ? doc.agent : agent;
+ if (what === 'end') frameUrl = '';
+ paint();
+ } catch (e) {
+ btn.disabled = false;
+ btn.title = e.message || 'That did not work. Try again.';
+ return;
+ }
+ if (agent && agent.state !== 'ended') schedule();
+ }
+
+ acts.addEventListener('click', (e) => {
+ const b = e.target.closest('button[data-act]');
+ if (!b) return;
+ if (b.dataset.act === 'pause') control('pause', b, { paused: !(agent && agent.paused) });
+ else if (b.dataset.act === 'curtain') control('curtain', b);
+ else if (b.dataset.act === 'mute') control('mute', b, { muted: !(agent && agent.voice_muted) });
+ else if (b.dataset.act === 'end') control('end', b);
+ });
+
+ refresh();
+ return { refresh, destroy() { gone = true; clearTimeout(timer); if (lastFrame) URL.revokeObjectURL(lastFrame); container.innerHTML = ''; container.classList.remove('ovda'); } };
+ }
+
+ root.OpenVibeDockAgent = { mount };
+})(typeof window !== 'undefined' ? window : globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -0,0 +1,186 @@
+/**
+ * openvibe-shared/dock-timer.js: the Dock's timer (plan L48 R2; the owner, 11-01: "a reminder system… timer or however
+ * often they want"). dock.js loads it when the person's Dock state carries a timer, or when they open the timer menu.
+ *
+ * OpenVibeDockTimer.attach({ state, save, host, icon }) → { sync(), pillText(), menu(anchor), stop() }
+ *
+ * The timer lives in the Network's Dock state (network.dock-state@1 `timer`, contracts 0.271.0), so it counts down on
+ * every OpenVibe site the person has open and follows them to the next. Each page counts from the same end time; the
+ * page that sees the end rings (a soft chime, only on a page that is showing, and a notification when the person
+ * allowed them) and moves a focus timer on to its break or its next round. Every page computes that next phase the same
+ * way from the same end time, so two pages writing it at once write the same thing. Nothing spins: the pill shows the
+ * minutes, and a line fills as the time goes.
+ */
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OpenVibeDockTimer) return;
+
+ const PRESETS = [[5, 'timer'], [10, 'timer'], [25, 'timer']];
+ const CSS = `
+.ovdk-timer { flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 10px; padding: 10px 12px 12px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }
+.ovdk-timer-t { min-width: 0; display: grid; gap: 1px; }
+.ovdk-timer-t b { font-size: 22px; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: .01em; line-height: 1.1; }
+.ovdk-timer-t span { color: var(--text-muted, #8b8b96); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovdk-timer-acts { display: flex; gap: 6px; }
+.ovdk-timer-acts button, .ovdk-tmenu button { min-height: 32px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; }
+.ovdk-timer-acts button:hover, .ovdk-tmenu button:hover { border-color: var(--accent, #3b82f6); }
+.ovdk-timer-bar { grid-column: 1 / -1; height: 4px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #ececf1) 9%, transparent); overflow: hidden; }
+.ovdk-timer-bar i { display: block; height: 100%; width: 0; border-radius: inherit; background: var(--accent, #3b82f6); transition: width 1s linear; }
+.ovdk-timer[data-kind="break"] .ovdk-timer-bar i { background: #22c55e; }
+.ovdk-timer.is-done b { color: var(--accent, #3b82f6); }
+.ovdk-tmenu { position: absolute; z-index: 5; right: 8px; top: 52px; width: min(300px, calc(100% - 16px)); display: grid; gap: 10px; padding: 12px; border-radius: 14px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-card, var(--bg-secondary, #16161d)); box-shadow: 0 16px 40px rgba(0,0,0,.4); }
+.ovdk-tmenu p { margin: 0; color: var(--text-muted, #8b8b96); font-size: 11px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
+.ovdk-tmenu .row { display: flex; flex-wrap: wrap; gap: 6px; }
+.ovdk-tmenu .own { display: grid; grid-template-columns: 70px minmax(0, 1fr) auto; gap: 6px; }
+.ovdk-tmenu input { min-height: 32px; min-width: 0; box-sizing: border-box; padding: 0 10px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: inherit; font: inherit; font-size: 13px; }
+.ovdk-tmenu .go { background: var(--accent, #3b82f6); border-color: transparent; color: var(--on-accent, #fff); }
+.ovdk-pill[data-tone="timer"] .ovdk-t { font-variant-numeric: tabular-nums; }
+.ovdk-pill[data-tone="timer-done"] { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); }
+@media (prefers-reduced-motion: reduce) { .ovdk-timer-bar i { transition: none; } }
+`;
+ const pad = (n) => String(n).padStart(2, '0');
+ const left = (ms) => {
+ const s = Math.max(0, Math.ceil(ms / 1000));
+ const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60;
+ return h ? `${h}:${pad(m)}:${pad(r)}` : `${m}:${pad(r)}`;
+ };
+ const iso = (ms) => new Date(ms).toISOString();
+
+ /** What comes after a timer that has ended: the break after a focus round, the next round, or nothing. */
+ function nextOf(t) {
+ if (!t || !t.rounds || t.kind === 'timer') return null;
+ const end = Date.parse(t.ends_at);
+ const round = t.round || 1;
+ if (t.kind === 'focus' && round < t.rounds) return { ...t, kind: 'break', started_at: iso(end), ends_at: iso(end + (t.break_min || 5) * 60000) };
+ if (t.kind === 'break') return { ...t, kind: 'focus', round: round + 1, started_at: iso(end), ends_at: iso(end + (t.focus_min || 25) * 60000) };
+ return null;
+ }
+ const titleOf = (t) => (t.kind === 'break' ? 'Break' : t.kind === 'focus' ? `Focus${t.rounds > 1 ? ` ${t.round || 1} of ${t.rounds}` : ''}` : 'Timer');
+
+ function chime() {
+ try {
+ const A = root.AudioContext || root.webkitAudioContext;
+ if (!A) return;
+ const ctx = new A();
+ [[660, 0], [880, 0.22], [990, 0.44]].forEach(([f, at]) => {
+ const o = ctx.createOscillator(), g = ctx.createGain();
+ o.type = 'sine'; o.frequency.value = f;
+ g.gain.setValueAtTime(0.0001, ctx.currentTime + at);
+ g.gain.exponentialRampToValueAtTime(0.18, ctx.currentTime + at + 0.02);
+ g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + at + 0.5);
+ o.connect(g).connect(ctx.destination);
+ o.start(ctx.currentTime + at); o.stop(ctx.currentTime + at + 0.55);
+ });
+ setTimeout(() => { try { ctx.close(); } catch { /* */ } }, 1500);
+ } catch { /* no sound here */ }
+ }
+
+ function attach(c) {
+ const ctx = c || {};
+ let tick = 0, rang = '', doneUntil = 0, doneLabel = '';
+ const now = () => Date.now();
+ const timer = () => { const t = (ctx.state() || {}).timer; return t && Date.parse(t.ends_at) ? t : null; };
+ const style = () => {
+ const st = ctx.host && ctx.host() && ctx.host().ownerDocument.getElementById('ovdk-styles') || document.getElementById('ovdk-styles');
+ if (st && !st.textContent.includes('.ovdk-timer {')) st.textContent += CSS;
+ };
+
+ function ended(t) {
+ if (rang === t.ends_at) return;
+ rang = t.ends_at;
+ const next = nextOf(t);
+ const said = next ? (next.kind === 'break' ? `Focus done: a ${next.break_min || 5}-minute break` : `Break over: focus ${next.round} of ${next.rounds}`) : `${t.label || titleOf(t)}: time's up`;
+ if (!document.hidden) chime();
+ try { if (root.Notification && Notification.permission === 'granted' && !document.hidden) new Notification('OpenVibe', { body: said, tag: `ov-timer-${t.ends_at}` }); } catch { /* */ }
+ doneUntil = now() + 60000; doneLabel = said;
+ ctx.save({ timer: next });
+ }
+
+ function paintStrip() {
+ const host = ctx.host && ctx.host();
+ if (!host) return;
+ const body = host.querySelector('.ovdk-body');
+ let strip = host.querySelector('.ovdk-timer');
+ const t = timer();
+ const done = !t && now() < doneUntil;
+ if (!t && !done) { if (strip) strip.remove(); return; }
+ if (!strip) {
+ strip = document.createElement('div'); strip.className = 'ovdk-timer'; strip.setAttribute('role', 'timer'); strip.setAttribute('aria-live', 'off');
+ strip.innerHTML = '<div class="ovdk-timer-t"><b></b><span></span></div><div class="ovdk-timer-acts"><button type="button" data-t="more">+5 min</button><button type="button" data-t="stop">Stop</button></div><div class="ovdk-timer-bar"><i></i></div>';
+ strip.addEventListener('click', (e) => {
+ const b = e.target.closest('[data-t]'); if (!b) return;
+ const cur = timer();
+ if (b.dataset.t === 'stop') { doneUntil = 0; ctx.save({ timer: null }); }
+ if (b.dataset.t === 'more' && cur) ctx.save({ timer: { ...cur, ends_at: iso(Math.max(Date.parse(cur.ends_at), now()) + 5 * 60000) } });
+ if (b.dataset.t === 'ok') { doneUntil = 0; }
+ paintStrip();
+ });
+ if (body) body.parentNode.insertBefore(strip, body); else host.appendChild(strip);
+ }
+ strip.classList.toggle('is-done', done);
+ strip.dataset.kind = t ? t.kind : 'done';
+ strip.querySelector('b').textContent = t ? left(Date.parse(t.ends_at) - now()) : 'Done';
+ strip.querySelector('span').textContent = t ? `${titleOf(t)}${t.label ? ` · ${t.label}` : ''}` : doneLabel;
+ const total = t ? Date.parse(t.ends_at) - Date.parse(t.started_at) : 1;
+ strip.querySelector('.ovdk-timer-bar i').style.width = t ? `${Math.min(100, Math.max(0, 100 * (now() - Date.parse(t.started_at)) / Math.max(1, total)))}%` : '100%';
+ const acts = strip.querySelector('.ovdk-timer-acts');
+ acts.innerHTML = t ? '<button type="button" data-t="more">+5 min</button><button type="button" data-t="stop">Stop</button>' : '<button type="button" data-t="ok">OK</button>';
+ }
+
+ function step() {
+ const t = timer();
+ if (t && now() >= Date.parse(t.ends_at)) ended(t);
+ paintStrip();
+ if (ctx.repaint) ctx.repaint();
+ if (!timer() && now() >= doneUntil) { clearInterval(tick); tick = 0; }
+ }
+ function sync() {
+ style();
+ if ((timer() || now() < doneUntil) && !tick) tick = setInterval(step, 1000);
+ step();
+ }
+ /** The pill's words while a timer runs (null when there is none): minutes left, or what just ended. */
+ function pillText() {
+ const t = timer();
+ if (t) return { text: left(Date.parse(t.ends_at) - now()), tone: 'timer', label: `${titleOf(t)}${t.label ? `, ${t.label}` : ''}: ${left(Date.parse(t.ends_at) - now())} left` };
+ if (now() < doneUntil) return { text: "Time's up", tone: 'timer-done', label: doneLabel };
+ return null;
+ }
+ function start(minutes, kind, label, rounds) {
+ const m = Math.max(1, Math.min(180, Math.round(Number(minutes) || 0)));
+ const at = now();
+ const t = { kind, started_at: iso(at), ends_at: iso(at + m * 60000), ...(label ? { label: String(label).slice(0, 60) } : {}) };
+ if (kind === 'focus') Object.assign(t, { round: 1, rounds: rounds || 4, focus_min: m, break_min: 5 });
+ rang = ''; doneUntil = 0;
+ // Asked once, at the moment the person starts one (a click): may the end show as a notification?
+ try { if (root.Notification && Notification.permission === 'default') Notification.requestPermission().catch(() => {}); } catch { /* */ }
+ ctx.save({ timer: t });
+ sync();
+ }
+ function menu(anchor) {
+ style();
+ const host = ctx.host && ctx.host();
+ if (!host) return;
+ let m = host.querySelector('.ovdk-tmenu');
+ if (m) { m.remove(); return; }
+ m = document.createElement('div'); m.className = 'ovdk-tmenu'; m.setAttribute('role', 'dialog'); m.setAttribute('aria-label', 'Start a timer');
+ m.innerHTML = `<p>Timer</p><div class="row">${PRESETS.map(([n]) => `<button type="button" data-min="${n}">${n} min</button>`).join('')}</div>
+<p>Focus</p><div class="row"><button type="button" data-focus="25">25 min, 5 min break, ×4</button><button type="button" data-focus="50">50 / 5, ×2</button></div>
+<p>Your own</p><div class="own"><input type="number" min="1" max="180" value="15" aria-label="Minutes" data-own-min><input type="text" maxlength="60" placeholder="What for (optional)" aria-label="What it is for" data-own-label><button type="button" class="go" data-own>Start</button></div>`;
+ m.addEventListener('click', (e) => {
+ const b = e.target.closest('button'); if (!b) return;
+ if (b.dataset.min) start(b.dataset.min, 'timer');
+ else if (b.dataset.focus) start(b.dataset.focus, 'focus', '', b.dataset.focus === '50' ? 2 : 4);
+ else if (b.hasAttribute('data-own')) start(m.querySelector('[data-own-min]').value, 'timer', m.querySelector('[data-own-label]').value.trim());
+ else return;
+ m.remove();
+ });
+ m.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); m.remove(); if (anchor) anchor.focus(); } });
+ host.appendChild(m);
+ const first = m.querySelector('button'); if (first) first.focus();
+ }
+ return { sync, pillText, menu, start, stop: () => { clearInterval(tick); tick = 0; } };
+ }
+
+ root.OpenVibeDockTimer = { attach, nextOf };
+})(typeof window !== 'undefined' ? window : globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,288 @@
@@ -0,0 +1,288 @@
+/**
+ * openvibe-shared/dock-vibe.js: the Dock's Vibe tab (plan L48 V2), loaded by dock.js the first time the tab opens.
+ * OpenVibeDockVibe.mount(panel, { chatCall, active, styles, icons }) → { refresh(), pause(), talk() }
+ * chatCall is the Dock's call to OpenVibe.Chat with the person's Network token; active() says whether the Vibe tab is
+ * the one showing; styles() is the Dock's own <style> (this file adds its rules there, so a popped-out Dock has them).
+ */
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OpenVibeDockVibe) return;
+ const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
+ const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
+ const MIC = '<rect x="9" y="3.5" width="6" height="10.5" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5v3"/>';
+ const CSS = `
+ .ovdk-vibe-h .ovdk-i { color: var(--accent, #3b82f6); margin-left: 6px; }
+ .ovdk-away { margin-left: auto; font-size: 12.5px; font-weight: 650; color: var(--accent, #3b82f6); text-decoration: none; white-space: nowrap; }
+ .ovdk-away:hover { text-decoration: underline; }
+ .ovdk-msg.is-vibe .ovdk-text a { color: var(--accent, #3b82f6); text-decoration: underline; word-break: break-all; }
+ .ovdk-typing .ovdk-text { display: inline-flex; gap: 4px; align-items: center; padding: 10px 12px; }
+ .ovdk-typing i { width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted, #8b8b96); animation: ovdk-hop 1s ease-in-out infinite; }
+ .ovdk-typing i:nth-child(2) { animation-delay: .15s; } .ovdk-typing i:nth-child(3) { animation-delay: .3s; }
+ .ovdk-vibe-start { list-style: none; display: grid; gap: 6px; padding: 18px 6px 8px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }
+ .ovdk-vibe-start b { color: var(--text-primary, #ececf1); font-size: 15.5px; }
+ .ovdk-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin-top: 8px; }
+ .ovdk-chip { padding: 7px 12px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; cursor: pointer; }
+ .ovdk-chip:hover { border-color: var(--accent, #3b82f6); }
+ .ovdk-mic { width: 40px; height: 40px; flex: none; margin-left: -8px; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center; background: transparent; color: var(--text-muted, #8b8b96); touch-action: none; }
+ .ovdk-mic:hover { color: var(--text-primary, #ececf1); }
+ .ovdk-mic.is-on { color: var(--on-accent, #fff); background: var(--live-red, #e5484d); box-shadow: 0 0 0 4px color-mix(in srgb, var(--live-red, #e5484d) 28%, transparent); }
+ .ovdk-res { display: grid; gap: 6px; margin-top: 6px; max-width: 100%; }
+ .ovdk-res-c { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 4px 10px; padding: 8px 10px; border-radius: 12px;
+ border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 35%, var(--border, rgba(255,255,255,.12))); background: color-mix(in srgb, var(--accent, #3b82f6) 8%, transparent); }
+ .ovdk-res-c .ovdk-i { color: var(--accent, #3b82f6); }
+ .ovdk-res-t { min-width: 0; font-size: 13px; line-height: 1.35; color: var(--text-primary, #ececf1); }
+ .ovdk-res-t b { font-weight: 750; }
+ .ovdk-res-t small { display: block; color: var(--text-muted, #8b8b96); font-size: 12px; }
+ .ovdk-res-a { display: flex; gap: 4px; }
+ .ovdk-res-a button, .ovdk-res-a a { min-height: 30px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent;
+ color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; }
+ .ovdk-res-a button:hover, .ovdk-res-a a:hover { border-color: var(--accent, #3b82f6); }
+ .ovdk-res-a button:disabled { opacity: .55; cursor: default; }
+ .ovdk-res-c.is-stopped { border-color: var(--border, rgba(255,255,255,.12)); background: transparent; }
+ .ovdk-res-c.is-stopped .ovdk-i, .ovdk-res-c.is-stopped .ovdk-res-t { color: var(--text-muted, #8b8b96); }
+ @media (prefers-reduced-motion: reduce) { .ovdk-typing i { animation: none; } }
+`;
+ let ctx = null, G = {};
+ const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
+
+ // ── Vibe (plan L48 V2; the owner, 11-01: "access your agent or the AI… from across all websites… use a voice
+ // feature to tell it to set a reminder… have a conversation with your Vibe AI") ──
+ // The same conversation as openvibe.chat/vibe, through Chat's /api/vibe with the person's Network token: what is
+ // asked on Live carries on on Chat and back. Vibe answers in the background; the tab waits for it (a quick poll
+ // for a minute and a half after a message, then every 15 s while the tab is open). The mic is the browser's own
+ // speech recognition, hold to talk or Alt+V, and only while the person asks: no wake word, never always on. A
+ // browser without it (Firefox) records instead, up to 30 s, and Chat's /api/vibe/listen turns that into text
+ // (OpenVibe.AI's speech-to-text on this network's own machines; the recording is never kept).
+ // Everything is set as text; a link is a link only when it is https.
+ const vibe = { panel: null, chat: null, msgs: [], last: 0, timer: 0, busy: false, waitUntil: 0, rec: null, talk: null };
+ const SR = root.SpeechRecognition || root.webkitSpeechRecognition || null;
+ const MR = !SR && root.MediaRecorder && root.navigator && root.navigator.mediaDevices && root.navigator.mediaDevices.getUserMedia ? root.MediaRecorder : null;
+ const TALK = Boolean(SR || MR);
+ const REC_TYPES = ['audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4', 'audio/webm', 'audio/ogg'];
+ const REC_MAX_MS = 30000;
+ const STARTERS = ['Remind me to stretch every 2 hours', 'What is on today?', 'Who is live right now?', 'Remind me tomorrow at 9 to call Sam'];
+ function linkified(text) {
+ const span = h('span', 'ovdk-text');
+ String(text || '').split(/(https:\/\/[^\s<>"')]+)/).forEach((part, i) => {
+ if (i % 2) { const a = h('a', '', part); a.href = part; a.target = '_blank'; a.rel = 'noopener noreferrer'; span.append(a); } else if (part) span.append(part);
+ });
+ return span;
+ }
+ const DAY = 24 * 3600 * 1000;
+ /** One thing Vibe did (Chat's results on its answer): what, when, and Undo / Open. */
+ function resultChip(m, r, i) {
+ const set = r.action === 'set';
+ const c = h('div', `ovdk-res-c${set ? '' : ' is-stopped'}`);
+ c.append(icon(r.kind === 'timer' ? G.clock : G.bell));
+ const t = h('div', 'ovdk-res-t');
+ const what = r.kind === 'timer' ? (set ? 'Timer' : 'Timer stopped') : (set ? 'Reminder set' : 'Reminder stopped');
+ t.append(h('b', '', `${what}: `), String(r.title || ''));
+ if (r.when && set) t.append(h('small', '', String(r.when)));
+ const a = h('div', 'ovdk-res-a');
+ const young = !m.at || Date.now() - Date.parse(m.at) < DAY;
+ if (set && young && Number(m.id) > 0) {
+ const undo = h('button', '', 'Undo'); undo.type = 'button';
+ undo.setAttribute('aria-label', `Undo: ${what} ${r.title || ''}`.trim());
+ undo.addEventListener('click', async () => {
+ undo.disabled = true;
+ try {
+ const out = await ctx.chatCall('/api/vibe/undo', { method: 'POST', body: JSON.stringify({ message_id: Number(m.id), index: i }) });
+ m.results = (out && out.results) || m.results.map((x, k) => (k === i ? { ...x, action: 'stopped' } : x));
+ c.replaceWith(resultChip(m, m.results[i], i));
+ if (r.kind === 'timer' && ctx.refresh) ctx.refresh();
+ } catch (e) {
+ undo.disabled = false; undo.textContent = e && e.status === 410 ? 'Too old to undo' : 'Try again';
+ if (e && (e.status === 409 || e.status === 410)) undo.disabled = true;
+ }
+ });
+ a.append(undo);
+ }
+ if (r.url && /^https:\/\//.test(r.url)) { const o = h('a', '', 'Open'); o.href = r.url; o.target = '_blank'; o.rel = 'noopener'; a.append(o); }
+ c.append(t, a);
+ return c;
+ }
+ function results(m) {
+ const box = h('div', 'ovdk-res');
+ m.results.slice(0, 4).forEach((r, i) => { if (r && (r.kind === 'reminder' || r.kind === 'timer')) box.append(resultChip(m, r, i)); });
+ return box;
+ }
+ function vbubble(m, prev) {
+ const mine = m.from === 'you';
+ const li = h('li', `ovdk-msg${mine ? ' is-mine' : ' is-vibe'}${prev && prev.from === m.from ? ' is-cont' : ''}`);
+ if (!mine && !(prev && prev.from === m.from)) li.append(h('span', 'ovdk-who', 'Vibe'));
+ li.append(linkified(m.text));
+ if (!mine && Array.isArray(m.results) && m.results.length) li.append(results(m));
+ if (m.at) li.title = new Date(m.at).toLocaleString();
+ return li;
+ }
+ async function openVibe() {
+ clearTimeout(vibe.timer);
+ const panel = vibe.panel; if (!panel) return;
+ vibe.msgs = []; vibe.last = 0;
+ panel.textContent = '';
+ const head = h('div', 'ovdk-thread-h ovdk-vibe-h');
+ const title = h('b', '', 'Vibe');
+ const away = h('a', 'ovdk-away', 'Open in Chat'); away.href = 'https://openvibe.chat/vibe'; away.target = '_blank'; away.rel = 'noopener';
+ head.append(icon(G.vibe), title, away);
+ const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');
+ const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);
+ const typing = h('li', 'ovdk-msg is-vibe ovdk-typing'); typing.innerHTML = '<span class="ovdk-text"><i></i><i></i><i></i></span>'; typing.setAttribute('aria-label', 'Vibe is writing');
+ const form = h('form', 'ovdk-send');
+ const box = h('textarea'); box.rows = 1; box.maxLength = 8000; box.placeholder = TALK ? 'Ask Vibe, or hold the mic' : 'Ask Vibe'; box.setAttribute('aria-label', 'Message Vibe');
+ const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));
+ const err = h('p', 'ovdk-err'); err.hidden = true;
+ let mic = null;
+ if (TALK) {
+ mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));
+ mic.setAttribute('aria-label', 'Talk to Vibe: hold, or press Alt+V'); mic.title = 'Hold to talk (Alt+V)'; mic.setAttribute('aria-pressed', 'false');
+ form.append(mic);
+ }
+ 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 })); } });
+
+ async function send(text) {
+ if (!text || vibe.busy) return;
+ vibe.busy = true; go.disabled = true; err.hidden = true;
+ try {
+ let timezone = null; try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { /* */ }
+ const out = await ctx.chatCall('/api/vibe/ask', { method: 'POST', body: JSON.stringify({ message: text, slug: vibe.chat ? vibe.chat.slug : undefined, timezone }) });
+ if (out.chat) { vibe.chat = out.chat; away.href = out.chat.url; }
+ box.value = ''; fit();
+ const empty = list.querySelector('.ovdk-vibe-start'); if (empty) empty.remove();
+ if (out.message && !vibe.msgs.some((x) => x.id === out.message.id)) { list.append(vbubble(out.message, vibe.msgs[vibe.msgs.length - 1])); vibe.msgs.push(out.message); vibe.last = Math.max(vibe.last, out.message.id || 0); }
+ vibe.waitUntil = Date.now() + 90000;
+ list.append(typing);
+ scroll.scrollTop = scroll.scrollHeight;
+ clearTimeout(vibe.timer); vibe.timer = setTimeout(() => pull(), 1200);
+ } catch (x) {
+ err.textContent = x.status === 429 ? 'That is a lot of questions at once: wait a minute, then ask again.' : x.status === 401 ? 'Sign in again to talk to Vibe.' : 'That did not reach Vibe. Try again.';
+ err.hidden = false;
+ } finally { vibe.busy = false; go.disabled = false; box.focus(); }
+ }
+ form.addEventListener('submit', (e) => { e.preventDefault(); send(box.value.trim()); });
+
+ async function pull(first) {
+ let got;
+ try {
+ got = await ctx.chatCall(`/api/vibe/chat${vibe.chat ? `?slug=${encodeURIComponent(vibe.chat.slug)}${vibe.last ? `&after=${vibe.last}` : ''}` : ''}`);
+ } catch (e) {
+ if (first) { list.textContent = ''; list.append(h('li', 'ovdk-none', e.status === 401 ? 'Sign in to OpenVibe.Chat to talk to Vibe.' : 'Vibe did not answer just now. Try again in a moment.')); }
+ return;
+ }
+ if (!ctx.active() || vibe.panel !== panel) return;
+ if (got.chat) { vibe.chat = got.chat; away.href = got.chat.url; }
+ if (first) list.textContent = '';
+ const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;
+ let answered = false;
+ (got.messages || []).forEach((m) => {
+ if (vibe.msgs.some((x) => x.id === m.id)) return;
+ list.insertBefore(vbubble(m, vibe.msgs[vibe.msgs.length - 1]), typing.parentNode === list ? typing : null);
+ vibe.msgs.push(m); vibe.last = Math.max(vibe.last, Number(m.id) || 0);
+ if (m.from === 'vibe') answered = true;
+ if (!first && Array.isArray(m.results) && m.results.some((r) => r && r.kind === 'timer') && ctx.refresh) ctx.refresh();
+ });
+ if (answered || Date.now() > vibe.waitUntil) { typing.remove(); vibe.waitUntil = 0; }
+ if (first && !vibe.msgs.length) {
+ const start = h('li', 'ovdk-vibe-start');
+ start.append(h('b', '', 'Ask Vibe anything'), h('span', '', TALK ? 'Type, or hold the mic and say it. It sets reminders, finds what is live, and answers from the whole network.' : 'It sets reminders, finds what is live, and answers from the whole network.'));
+ const chips = h('div', 'ovdk-chips');
+ STARTERS.forEach((s) => { const c = h('button', 'ovdk-chip', s); c.type = 'button'; c.addEventListener('click', () => send(s)); chips.append(c); });
+ start.append(chips);
+ list.append(start);
+ }
+ if (first || atEnd || answered) scroll.scrollTop = scroll.scrollHeight;
+ clearTimeout(vibe.timer);
+ if (ctx.active()) vibe.timer = setTimeout(() => pull(), vibe.waitUntil ? 1500 : 15000);
+ }
+
+ // The mic: hold to talk (or a click to start and another to stop), Alt+V from anywhere while the Dock is open.
+ if (mic) {
+ let said = '', heard = false;
+ const stop = () => { if (vibe.rec) { try { vibe.rec.stop(); } catch { /* */ } } };
+ const on = (yes) => { mic.classList.toggle('is-on', yes); mic.setAttribute('aria-pressed', yes ? 'true' : 'false'); };
+ const fail = (text) => { err.textContent = text; err.hidden = false; };
+ // No speech recognition in this browser: record, then Chat's /api/vibe/listen says what was said.
+ const record = async () => {
+ const type = REC_TYPES.find((t) => { try { return MR.isTypeSupported(t); } catch { return false; } }) || '';
+ let stream;
+ try { stream = await root.navigator.mediaDevices.getUserMedia({ audio: true }); } catch (e) {
+ return fail(e && e.name === 'NotAllowedError' ? 'The microphone is off for this site: allow it in the browser to talk to Vibe.' : 'No microphone was found.');
+ }
+ const chunks = [];
+ let r;
+ try { r = new MR(stream, { ...(type ? { mimeType: type } : {}), audioBitsPerSecond: 32000 }); } catch { stream.getTracks().forEach((t) => t.stop()); return fail('This browser cannot record here.'); }
+ const cap = setTimeout(stop, REC_MAX_MS);
+ r.ondataavailable = (ev) => { if (ev.data && ev.data.size) chunks.push(ev.data); };
+ r.onstop = async () => {
+ clearTimeout(cap); vibe.rec = null; on(false);
+ stream.getTracks().forEach((t) => t.stop());
+ const kind = String(r.mimeType || type || 'audio/webm').split(';')[0];
+ const blob = new Blob(chunks, { type: kind });
+ if (blob.size < 800) { box.placeholder = 'Ask Vibe, or hold the mic'; return; }
+ box.placeholder = 'Working out what you said…'; mic.disabled = true;
+ try {
+ const lang = String(document.documentElement.lang || root.navigator.language || 'en').slice(0, 2).toLowerCase();
+ const out = await ctx.chatCall(`/api/vibe/listen?lang=${encodeURIComponent(/^[a-z]{2}$/.test(lang) ? lang : 'en')}`, { method: 'POST', body: blob, type: kind });
+ const said = String((out && out.text) || '').trim();
+ if (said) { box.value = said; fit(); send(said); } else fail('I could not hear that. Try again, a little closer.');
+ } catch (e) {
+ fail(e && e.status === 429 ? 'That is a lot of talking for one minute: type it, or try again shortly.' : e && e.status === 413 ? 'Keep it under 30 seconds.' : 'I could not hear that just now. Try again, or type it.');
+ } finally { mic.disabled = false; box.placeholder = 'Ask Vibe, or hold the mic'; }
+ };
+ vibe.rec = r; err.hidden = true; on(true); box.placeholder = 'Listening… let go or tap to send';
+ try { r.start(); } catch { vibe.rec = null; on(false); stream.getTracks().forEach((t) => t.stop()); }
+ };
+ const start = () => {
+ if (vibe.rec) return stop();
+ if (!SR) return record();
+ const r = new SR();
+ r.lang = document.documentElement.lang || navigator.language || 'en-US';
+ r.interimResults = true; r.continuous = false;
+ said = ''; heard = false;
+ r.onresult = (ev) => {
+ let text = '';
+ for (let i = 0; i < ev.results.length; i++) text += ev.results[i][0].transcript;
+ said = text.trim(); heard = true; box.value = said; fit();
+ };
+ r.onerror = (ev) => {
+ if (ev.error === 'not-allowed' || ev.error === 'service-not-allowed') { err.textContent = 'The microphone is off for this site: allow it in the browser to talk to Vibe.'; err.hidden = false; }
+ };
+ r.onend = () => {
+ vibe.rec = null; mic.classList.remove('is-on'); mic.setAttribute('aria-pressed', 'false');
+ if (heard && said) send(said);
+ };
+ vibe.rec = r;
+ mic.classList.add('is-on'); mic.setAttribute('aria-pressed', 'true');
+ try { r.start(); } catch { vibe.rec = null; mic.classList.remove('is-on'); }
+ };
+ let held = 0;
+ mic.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; held = Date.now(); start(); });
+ mic.addEventListener('pointerup', () => { if (held && Date.now() - held > 400) stop(); held = 0; });
+ mic.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); start(); } });
+ vibe.talk = start;
+ }
+ list.append(h('li', 'ovdk-wait', null));
+ list.firstChild.innerHTML = '<span></span>';
+ await pull(true);
+ setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);
+ }
+
+ function mount(panel, c) {
+ ctx = c || {};
+ G = { mic: MIC, ...(ctx.icons || {}) };
+ const st = typeof ctx.styles === 'function' ? ctx.styles() : null;
+ if (st && !st.textContent.includes('.ovdk-vibe-start')) st.textContent += CSS;
+ else if (!st && !document.getElementById('ovdk-vibe-styles')) { const s = document.createElement('style'); s.id = 'ovdk-vibe-styles'; s.textContent = CSS; document.head.appendChild(s); }
+ vibe.panel = panel;
+ openVibe();
+ return {
+ refresh: () => { vibe.panel = panel; return openVibe(); },
+ pause: () => { clearTimeout(vibe.timer); if (vibe.rec) { try { vibe.rec.abort(); } catch { /* */ } } },
+ talk: () => { if (vibe.talk) vibe.talk(); },
+ };
+ }
+ root.OpenVibeDockVibe = { mount };
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -13,9 +13,11 @@
@@ -13,9 +13,11 @@
// State: GET/PUT <apiBase>/api/v1/me/dock (network.dock-result@1, network.dock-state@1): open, tab, pinned and where it
// 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
// 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.
// 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.
+// Tabs: Agents (action-center.js mount(): what needs you, then what runs, approve or decline in place; K-D3: above it,
+// the person's own desk stream — dock-agent.js loads the first time the tab opens — with Curtain now, End and the still
+// when nothing is live), Chats (K-D2: the person's conversations and DMs from OpenVibe.Chat's own API,
+// GET /api/dm/conversations, opened in place, read and answered there) and Activity (notification-ui.js renderDock():
+// the bell's list).
// Desktop: a floating card at the pill's corner. Drag its head to move it (it snaps to the nearest corner), drag the
// 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
// 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.
// bottom over a scrim; drag the head down, ×, Escape or a tap outside closes it. Motion is transform and opacity only.
@@ -38,8 +40,14 @@
@@ -38,8 +40,14 @@
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"/>',
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"/>',
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"/>',
send: '<path d="M4.5 12h11M11 6.5l5.5 5.5-5.5 5.5"/>',
+ vibe: '<path d="M12 4.5a7.5 7.5 0 0 1 6.4 11.4L19.5 19.5l-3.6-1.1A7.5 7.5 0 1 1 12 4.5z"/><path d="M9 11.5h.01M12 11.5h.01M15 11.5h.01"/>',
+ clock: '<circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/>',
};
};
- const TABS = [['agent', 'Agents', G.spark], ['chats', 'Chats', G.chat], ['activity', 'Activity', G.bell]];
+ // Vibe first (L48 V2): the person's AI, on every site.
+ const TABS = [['vibe', 'Vibe', G.vibe], ['agent', 'Agents', G.spark], ['chats', 'Chats', G.chat], ['activity', 'Activity', G.bell]];
+ // The tabs the Network keeps in the person's Dock state (network.dock-state@1; vibe since contracts 0.270.0); a
+ // newer one stays on this site's copy until the Network learns it.
+ const SAVED_TABS = ['vibe', 'agent', 'chats', 'activity'];
const 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 } } };
const DEFAULT_STATE = { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } } };
let el = null, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null;
let el = null, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null;
@@ -55,6 +63,8 @@
@@ -55,6 +63,8 @@
const kind = () => (phone() ? 'phone' : 'desktop');
const kind = () => (phone() ? 'phone' : 'desktop');
const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });
const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });
const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);
const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);
+ // K-D3: the agent stream comes from OpenVibe.Codes' API (it mints the view ticket with the person's own token).
+ const agentBase = () => String(o.agentBase || 'https://openvibe.codes').replace(/\/+$/, '');
async function call(path, init = {}) {
async function call(path, init = {}) {
const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');
const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');
@@ -139,7 +149,9 @@
@@ -139,7 +149,9 @@
@keyframes ovdk-rise { from { transform: translateY(100%); } }
@keyframes ovdk-rise { from { transform: translateY(100%); } }
.ovdk svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.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-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 { display: flex; gap: 2px; padding: 3px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #ececf1) 6%, transparent); min-width: 0; flex: 0 1 auto; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
+ .ovdk-tabs::-webkit-scrollbar { display: none; }
+ .ovdk-tabs button { flex: none; }
.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;
.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; }
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 svg { width: 17px; height: 17px; flex: none; }
@@ -167,8 +179,10 @@
@@ -167,8 +179,10 @@
.ovdk-foot button:hover { color: var(--text-primary, #ececf1); }
.ovdk-foot button:hover { color: var(--text-primary, #ececf1); }
.ovdk-foot svg { width: 15px; height: 15px; }
.ovdk-foot svg { width: 15px; height: 15px; }
.ovdk-wait { display: flex; justify-content: center; padding: 36px; }
.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-wait span { position: relative; display: inline-block; width: 6px; height: 6px; margin: 0 10px; border: 0; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovdk-hop 1s ease-in-out .15s infinite; }
+ .ovdk-wait span::before, .ovdk-wait span::after { content: ''; position: absolute; top: 0; width: 6px; height: 6px; border-radius: 50%; background: inherit; animation: ovdk-hop 1s ease-in-out infinite; }
+ .ovdk-wait span::before { left: -10px; } .ovdk-wait span::after { left: 10px; animation-delay: .3s; }
+ @keyframes ovdk-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-4px); opacity: 1; } }
.ovdk-none { padding: 40px 24px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }
.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 { 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-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; }
@@ -181,7 +195,7 @@
@@ -181,7 +195,7 @@
.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; }
.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; } }
@media (pointer: coarse) { .ovdk-acts button { width: 44px; height: 44px; } .ovdk-tabs button { height: 40px; } }
.ovdk { container-type: inline-size; }
.ovdk { container-type: inline-size; }
- @container (max-width: 440px) { .ovdk-tabs button { padding: 0 11px; gap: 6px; } .ovdk-tabs button svg { display: none; } }
+ @container (max-width: 440px) { .ovdk-tabs button { padding: 0 8px; gap: 6px; font-size: 13px; } .ovdk-tabs button svg { display: none; } .ovdk-acts { gap: 0; } .ovdk-acts button { width: 34px; } }
.ovdk-muted { color: var(--text-muted, #8b8b96); }
.ovdk-muted { color: var(--text-muted, #8b8b96); }
.ovdk-i { display: inline-grid; place-items: center; }
.ovdk-i { display: inline-grid; place-items: center; }
.ovdk-i svg { width: 18px; height: 18px; }
.ovdk-i svg { width: 18px; height: 18px; }
@@ -256,6 +270,7 @@
@@ -256,6 +270,7 @@
<header class="ovdk-head">
<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-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">
<div class="ovdk-acts">
+ <button type="button" data-act="timer" title="A timer" aria-label="Start a timer">${svg(G.clock)}</button>
<button type="button" data-act="pop" hidden title="Open the Dock in a window of its own" aria-label="Open the Dock in a window of its own">${svg(G.open)}</button>
<button type="button" data-act="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="pin" aria-pressed="false" title="Keep the Dock's button on every page" aria-label="Keep the Dock's button on every page">${svg(G.pin)}</button>
<button type="button" data-act="close" title="Close" aria-label="Close the Dock">${svg(G.close)}</button>
<button type="button" data-act="close" title="Close" aria-label="Close the Dock">${svg(G.close)}</button>
@@ -275,6 +290,7 @@
@@ -275,6 +290,7 @@
el.querySelector('[data-act="close"]').addEventListener('click', () => close());
el.querySelector('[data-act="close"]').addEventListener('click', () => close());
el.querySelector('[data-act="pop"]').addEventListener('click', () => { if (popped) backToPage(); else popOut(); });
el.querySelector('[data-act="pop"]').addEventListener('click', () => { if (popped) backToPage(); else popOut(); });
el.querySelector('[data-act="pin"]').addEventListener('click', () => { save({ pinned: !state().pinned }); paintPin(); });
el.querySelector('[data-act="pin"]').addEventListener('click', () => { save({ pinned: !state().pinned }); paintPin(); });
+ el.querySelector('[data-act="timer"]').addEventListener('click', (e) => { const a = e.currentTarget; withTimer((t) => t.menu(a)); });
el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } });
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
// 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.
// chat) leaves its target detached, and must not read as outside.
@@ -407,14 +423,24 @@
@@ -407,14 +423,24 @@
function setTab(id, remember) {
function setTab(id, remember) {
tab = TABS.some(([t]) => t === id) ? id : 'activity';
tab = TABS.some(([t]) => t === id) ? id : 'activity';
if (tab !== 'chats') clearTimeout(chats.timer);
if (tab !== 'chats') clearTimeout(chats.timer);
+ if (tab !== 'vibe' && vibe.api) vibe.api.pause();
el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === tab; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
el.querySelectorAll('[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; });
el.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });
- if (remember && state().tab !== tab) save({ tab });
+ if (remember && state().tab !== tab && SAVED_TABS.includes(tab)) save({ tab });
fill(tab);
fill(tab);
}
}
async function fill(id) {
async function fill(id) {
const panel = el.querySelector(`[data-panel="${id}"]`);
const panel = el.querySelector(`[data-panel="${id}"]`);
+ if (id === 'vibe') {
+ vibe.panel = panel;
+ if (vibe.api) return vibe.api.refresh();
+ panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
+ const ok = await load('dock-vibe.js', () => !!(root.OpenVibeDockVibe && root.OpenVibeDockVibe.mount));
+ if (!ok) { panel.innerHTML = '<div class="ovdk-none">Vibe did not load. Check your connection, then open it again.</div>'; return; }
+ vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => isOpen() && tab === 'vibe', styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });
+ return;
+ }
if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }
if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }
if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); return; }
if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); return; }
mounts[id] = { refresh: null };
mounts[id] = { refresh: null };
@@ -422,8 +448,12 @@
@@ -422,8 +448,12 @@
if (id === 'agent') {
if (id === 'agent') {
const ok = await load('action-center.js', () => !!(root.OpenVibeActionCenter && root.OpenVibeActionCenter.mount));
const ok = await load('action-center.js', () => !!(root.OpenVibeActionCenter && root.OpenVibeActionCenter.mount));
if (!ok) return failed(panel, id);
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, {
+ // K-D3: the person's own agent stream above the list; it hides itself when there is none.
+ panel.innerHTML = `<div class="ovdk-agent"></div><div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;
+ load('dock-agent.js', () => !!(root.OpenVibeDockAgent && root.OpenVibeDockAgent.mount)).then((on) => {
+ if (on && !mounts[id + 'View'] && panel.isConnected) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.firstChild, { token: o.token, base: agentBase(), active: () => isOpen() && tab === 'agent' });
+ });
+ const m = root.OpenVibeActionCenter.mount(panel.querySelector('.ovdk-scroll'), {
token: o.token, apiBase: o.apiBase, scopes: o.scopes, buckets: ['waiting', 'running'],
token: o.token, apiBase: o.apiBase, scopes: o.scopes, buckets: ['waiting', 'running'],
onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },
onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },
});
});
@@ -461,8 +491,9 @@
@@ -461,8 +491,9 @@
const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
async function chatCall(path, init = {}) {
async function chatCall(path, init = {}) {
const t = await tokenOf();
const t = await tokenOf();
- 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 headers = { accept: 'application/json', ...(init.body ? { 'content-type': init.type || 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
+ const { type: _type, ...rest } = init;
+ return fetch(chatBase() + path, { ...rest, headers, credentials: 'omit' }).then(async (r) => {
const b = await r.json().catch(() => ({}));
const b = await r.json().catch(() => ({}));
if (!r.ok) { const e = new Error(b.error || `HTTP ${r.status}`); e.status = r.status; throw e; }
if (!r.ok) { const e = new Error(b.error || `HTTP ${r.status}`); e.status = r.status; throw e; }
return b;
return b;
@@ -605,6 +636,29 @@
@@ -605,6 +636,29 @@
if (isOpen() && tab === 'chats' && chats.open === id) chats.timer = setTimeout(() => pull(list, scroll), 5000);
if (isOpen() && tab === 'chats' && chats.open === id) chats.timer = setTimeout(() => pull(list, scroll), 5000);
}
}
+ // ── Vibe (plan L48 V2): the person's Vibe, in dock-vibe.js, loaded the first time the tab opens (the Dock is on
+ // every page for signed-in people; Vibe's conversation and mic are not needed until then). Alt+V talks to Vibe from
+ // any page: it opens the Dock on Vibe and starts listening (only where the browser can listen).
+ const vibe = { panel: null, api: null };
+ const canListen = () => !!(root.SpeechRecognition || root.webkitSpeechRecognition);
+ document.addEventListener('keydown', (e) => {
+ if (!e.altKey || e.ctrlKey || e.metaKey || String(e.key).toLowerCase() !== 'v' || !canListen() || !o || typeof o.token !== 'function') return;
+ e.preventDefault();
+ const talk = () => { if (vibe.api && vibe.api.talk) vibe.api.talk(); };
+ if (!isOpen() || tab !== 'vibe') { open({ tab: 'vibe' }); setTimeout(talk, 600); } else talk();
+ });
+
+ // ── The timer (plan L48 R2): dock-timer.js, loaded when the state carries one or the person opens its menu ──
+ let timerMod = null, timerLoading = null;
+ function withTimer(fn) {
+ if (timerMod) return fn(timerMod);
+ timerLoading = timerLoading || load('dock-timer.js', () => !!(root.OpenVibeDockTimer && root.OpenVibeDockTimer.attach)).then((ok) => {
+ if (ok && !timerMod) timerMod = root.OpenVibeDockTimer.attach({ state, save, host: () => el, repaint: () => paintPill() });
+ return timerMod;
+ });
+ return timerLoading.then((m) => (m ? fn(m) : null));
+ }
+
// ── The pill ─────────────────────────────────────────────
// ── The pill ─────────────────────────────────────────────
let pill = null, readT = 0, bound = false;
let pill = null, readT = 0, bound = false;
function store(v) {
function store(v) {
@@ -624,13 +678,16 @@
@@ -624,13 +678,16 @@
pill.setAttribute('aria-label', 'The Dock is in a window of its own \u2014 click to bring it back');
pill.setAttribute('aria-label', 'The Dock is in a window of its own \u2014 click to bring it back');
return;
return;
}
}
- const show = !isOpen() && !!(w || r || st.pinned);
+ // A running timer: its minutes, unless an agent needs the person (that comes first).
+ if (st.timer && !timerMod) withTimer((m) => m.sync());
+ const tm = timerMod && !w ? timerMod.pillText() : null;
+ const show = !isOpen() && !!(w || r || st.pinned || tm);
pill.hidden = !show;
pill.hidden = !show;
pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';
pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';
- pill.dataset.tone = w ? 'waiting' : r ? 'running' : 'idle';
+ pill.dataset.tone = w ? 'waiting' : tm ? tm.tone : r ? 'running' : 'idle';
pill.querySelector('.ov-mark').setAttribute('data-state', r && !w ? 'busy' : '');
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');
+ pill.querySelector('.ovdk-t').textContent = w ? 'Needs you' : tm ? tm.text : r ? (r > 1 ? `${r} working` : 'Working') : '';
+ pill.setAttribute('aria-label', tm ? tm.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.
// 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);
if (!isOpen()) clear(show && pill.dataset.corner === 'bottom-right' && !phone() ? 58 : 0);
}
}
@@ -647,7 +704,7 @@
@@ -647,7 +704,7 @@
update(next);
update(next);
if (!isOpen() && next.state.open && !phone()) open();
if (!isOpen() && next.state.open && !phone()) open();
const c = next.counts || {};
const c = next.counts || {};
- if (c.running || c.waiting) readT = setTimeout(read, 30000);
+ if (c.running || c.waiting || next.state.timer) readT = setTimeout(read, 30000);
} catch { /* offline: the copy stands */ }
} 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). */
/** The pill for a signed-in person (navbar.js), or none (null: signed out, or the site turned the Dock off). */
@@ -700,7 +757,7 @@
@@ -700,7 +757,7 @@
if (!d.head) d.documentElement.appendChild(d.createElement('head'));
if (!d.head) d.documentElement.appendChild(d.createElement('head'));
if (!d.body) d.documentElement.appendChild(d.createElement('body'));
if (!d.body) d.documentElement.appendChild(d.createElement('body'));
d.title = 'OpenVibe Dock';
d.title = 'OpenVibe Dock';
- for (const id of ['ovdk-styles', 'ovac-styles', 'openvibe-notif-styles', 'ov-mark-css']) {
+ for (const id of ['ovdk-styles', 'ovac-styles', 'openvibe-notif-styles', 'ovda-styles', 'ov-mark-css']) {
const s = document.getElementById(id);
const s = document.getElementById(id);
if (!s) continue;
if (!s) continue;
const c = d.createElement('style');
const c = d.createElement('style');
@@ -769,7 +826,7 @@
@@ -769,7 +826,7 @@
if (phone()) { el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open'); } else el.removeAttribute('aria-modal');
if (phone()) { el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open'); } else el.removeAttribute('aria-modal');
setTab(want, false);
setTab(want, false);
readUnread();
readUnread();
- if (!state().open || state().tab !== tab) save({ open: true, tab }); else notify();
+ if (!state().open || (state().tab !== tab && SAVED_TABS.includes(tab))) save({ open: true, ...(SAVED_TABS.includes(tab) ? { tab } : {}) }); else notify();
setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]').focus({ preventScroll: true }); } catch { /* */ } }, 0);
setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]').focus({ preventScroll: true }); } catch { /* */ } }, 0);
}
}
function close() {
function close() {
@@ -794,6 +851,7 @@
@@ -794,6 +851,7 @@
if (!next || !next.state) return;
if (!next || !next.state) return;
doc = Object.keys(pending).length ? { ...next, state: state() } : next;
doc = Object.keys(pending).length ? { ...next, state: state() } : next;
store(doc);
store(doc);
+ if (timerMod) timerMod.sync();
paintPill();
paintPill();
if (!el) return;
if (!el) return;
badges();
badges();
@@ -24,15 +24,64 @@
@@ -24,15 +24,64 @@
--ovk-ok: var(--success, #22c55e);
--ovk-ok: var(--success, #22c55e);
--ovk-warn: var(--warning, #f59e0b);
--ovk-warn: var(--warning, #f59e0b);
--ovk-bad: var(--danger, var(--error, #ef4444));
--ovk-bad: var(--danger, var(--error, #ef4444));
- --ovk-live: var(--live-red, #ef4444);
- --ovk-radius: 14px;
- --ovk-radius-sm: 10px;
+ /* Status in words (4.83.0): a theme's -text status colours are its readable ones; the fills above are for shapes. On
+ * a light theme the fill green or amber on white was about 2:1 (openvibe.design/kit's "paid" and "expiring"). */
+ --ovk-ok-text: var(--success-text, var(--ovk-ok));
+ --ovk-warn-text: var(--warning-text, var(--ovk-warn));
+ --ovk-bad-text: var(--danger-text, var(--ovk-bad));
+ --ovk-live: color-mix(in srgb, var(--live-red, #ef4444) 80%, #000); /* a badge fill under white text: 5.5:1 on #ef4444's 80%, 3.8:1 bare */
+ /* Shape and elevation (4.81.0, plan L45 step 2): the kit's corners and shadows as tokens a look can set, so a theme
+ * has a shape as well as colours. The defaults are the kit's own corners and shadows; the personalities follow. */
+ --ov-radius-1: 10px; /* chips, inputs, rows, small buttons */
+ --ov-radius-2: 14px; /* cards, sections, panels */
+ --ov-radius-3: 20px; /* sheets, dialogs, big surfaces */
+ --ov-elev-1: 0 1px 3px rgba(0, 0, 0, .35); /* a knob, a chip that sits on its surface */
+ --ov-elev-2: 0 10px 24px -18px rgba(0, 0, 0, .7); /* a bar that has scrolled content under it */
+ --ov-elev-3: 0 14px 32px rgba(0, 0, 0, .4); /* a menu or popover above the page */
+ /* Motion (4.82.0): the same tokens ov-fx.js has always set, here so every page's var(--ov-dur-*) resolves and the
+ * person's Reduced motion reaches every transition that uses them, not only pages with effects. */
+ --ov-dur-1: .12s;
+ --ov-dur-2: .2s;
+ --ov-dur-3: .32s;
+ --ov-dur-4: .52s;
+ --ov-ease-out: cubic-bezier(.2, .8, .2, 1);
+ --ov-ease-spring: cubic-bezier(.34, 1.56, .64, 1);
+ --ov-ease-in-out: cubic-bezier(.65, 0, .35, 1);
+ --ovk-radius: var(--ov-radius-2);
+ --ovk-radius-sm: var(--ov-radius-1);
--ovk-tap: 44px;
--ovk-tap: 44px;
--ovk-top: var(--ovnav-real-h, 52px); /* the navbar's height: sticky things stop under it */
--ovk-top: var(--ovnav-real-h, 52px); /* the navbar's height: sticky things stop under it */
--ovk-bottom: var(--ovnav-appbar-h, 0px); /* the app bar's height on phones (navbar.js sets it) */
--ovk-bottom: var(--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);
--ovk-font: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
}
}
+/* Motion feels, as ov-fx.js sets them: the calm looks are slower and never overshoot, the loud ones quicker; the
+ * person's Reduced motion, or a device that asks for less, stops every token-timed transition. */
+:is([data-theme=paper], [data-theme=daylight], [data-theme=cloud], [data-theme=snow], [data-theme=sand], [data-theme=rose], [data-theme=meadow], [data-theme=peach], [data-theme=lavender], [data-theme=dawn], [data-theme=solarized-light], [data-theme=catppuccin-latte], [data-theme=nord], [data-theme=nord-light]) {
+ --ov-dur-1: .15s; --ov-dur-2: .25s; --ov-dur-3: .4s; --ov-dur-4: .62s; --ov-ease-spring: var(--ov-ease-out);
+}
+:is([data-theme=neon-tokyo], [data-theme=vapor], [data-theme=matrix], [data-theme=hacker], [data-theme=high-contrast], [data-theme=dracula], [data-theme=monokai], [data-theme=abyss]) {
+ --ov-dur-1: .09s; --ov-dur-2: .15s; --ov-dur-3: .24s; --ov-dur-4: .42s; --ov-ease-spring: cubic-bezier(.3, 1.8, .6, 1);
+}
+html[data-ov-motion=reduced] { --ov-dur-1: 0s; --ov-dur-2: 0s; --ov-dur-3: 0s; --ov-dur-4: 0s; }
+/* :root:root outranks ov-fx.js's own :root and theme feels, which load later. */
+@media (prefers-reduced-motion: reduce) { :root:root { --ov-dur-1: 0s; --ov-dur-2: 0s; --ov-dur-3: 0s; --ov-dur-4: 0s; } }
+/* Shape personalities (plan L45: "Paper is quiet and square, Neon glows and bounces"). A quiet look has square corners
+ * and lies flat, with a hairline where a shadow would be; a loud one is round and its raised things glow in its accent.
+ * The ids are Network's theme catalog (as ov-fx.js's motion feels); any other look keeps the defaults above. */
+:is([data-theme=paper], [data-theme=solarized-light], [data-theme=nord], [data-theme=nord-light], [data-theme=matrix], [data-theme=hacker], [data-theme=high-contrast]) {
+ --ov-radius-1: 4px; --ov-radius-2: 6px; --ov-radius-3: 10px;
+ --ov-elev-1: 0 0 0 1px var(--ovk-line);
+ --ov-elev-2: 0 1px 0 var(--ovk-line);
+ --ov-elev-3: 0 0 0 1px var(--ovk-line), 0 8px 18px -12px rgba(0, 0, 0, .35);
+}
+:is([data-theme=neon-tokyo], [data-theme=vapor]) {
+ --ov-radius-1: 14px; --ov-radius-2: 20px; --ov-radius-3: 28px;
+ --ov-elev-1: 0 0 8px color-mix(in srgb, var(--ovk-accent) 40%, transparent);
+ --ov-elev-2: 0 10px 28px -14px color-mix(in srgb, var(--ovk-accent) 55%, transparent);
+ --ov-elev-3: 0 0 0 1px color-mix(in srgb, var(--ovk-accent) 40%, transparent), 0 18px 44px -14px color-mix(in srgb, var(--ovk-accent) 55%, transparent);
+}
+
/* Heading and paragraph rules double their class (.ovk-section.ovk-section > h2): a site's own ".site h2" has the same
/* 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). */
* specificity as ".ovk-section > h2" and loads later, so it used to restyle the kit's headings (Report, 2026-10-29). */
@@ -45,36 +94,36 @@
@@ -45,36 +94,36 @@
.ovk-page--wide { max-width: 1280px; }
.ovk-page--wide { max-width: 1280px; }
/* ── Buttons ── */
/* ── 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 { 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: calc(var(--ov-radius-1) + 2px); 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 { background: var(--ovk-fill); color: var(--ovk-on-fill); }
.ovk-btn--primary:hover { filter: brightness(1.08); }
.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 { background: var(--ovk-panel); color: var(--ovk-text); border-color: var(--ovk-line); }
.ovk-btn--quiet:hover { border-color: var(--ovk-accent); }
.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--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 { background: transparent; color: var(--ovk-bad-text); 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--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--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: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-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 { display: inline-grid; place-items: center; box-sizing: border-box; width: 40px; height: 40px; flex: none; padding: 0; border-radius: calc(var(--ov-radius-1) + 2px); 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::-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:hover { color: var(--ovk-text); background: var(--ovk-panel-2); border-color: var(--ovk-line); }
.ovk-icon-btn[hidden] { display: none; }
.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.is-done { color: var(--ovk-ok-text); }
+.ovk-icon-btn--danger:hover { color: var(--ovk-bad-text); }
.ovk-icon-btn .ovk-i { width: 20px; height: 20px; }
.ovk-icon-btn .ovk-i { width: 20px; height: 20px; }
@media (pointer: coarse) { .ovk-icon-btn { width: var(--ovk-tap); height: var(--ovk-tap); } }
@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 { display: inline-flex; align-items: center; flex: none; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--ovk-line); font-size: .75rem; font-weight: 800; letter-spacing: .03em; text-transform: uppercase; color: var(--ovk-muted); white-space: nowrap; }
.ovk-badge--accent { color: var(--ovk-accent); border-color: color-mix(in srgb, var(--ovk-accent) 50%, transparent); }
.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--ok { color: var(--ovk-ok-text); border-color: color-mix(in srgb, var(--ovk-ok) 50%, transparent); }
+.ovk-badge--warn { color: var(--ovk-warn-text); border-color: color-mix(in srgb, var(--ovk-warn) 50%, transparent); }
+.ovk-badge--bad { color: var(--ovk-bad-text); border-color: color-mix(in srgb, var(--ovk-bad) 50%, transparent); }
.ovk-badge--live { color: #fff; background: var(--ovk-live); border-color: 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; }
.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 ── */
/* ── 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-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-scrolled .ovk-head { border-bottom-color: var(--ovk-line); box-shadow: var(--ov-elev-2); }
.ovk-head--static { position: static; background: transparent; -webkit-backdrop-filter: none; backdrop-filter: none; }
.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 { display: flex; align-items: center; gap: 6px; min-height: 48px; padding-bottom: 6px; }
.ovk-head-row > .ovk-back { margin-left: -8px; }
.ovk-head-row > .ovk-back { margin-left: -8px; }
@@ -115,8 +164,9 @@
@@ -115,8 +164,9 @@
.ovk-row--unread .ovk-row-sub { color: var(--ovk-text); }
.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 { 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 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-row-trail select { max-width: 9.5em; padding: 5px 6px; border-radius: max(0px, calc(var(--ov-radius-1) - 2px)); border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: .86rem; }
+/* 32% lightness keeps the white letters at 4.5:1 or more for every hue (38% left greens and yellows near 3.7:1). */
+.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% 32%); 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-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); }
.ovk-empty-line { margin: 0 0 14px; padding: 14px; border: 1px dashed var(--ovk-line); border-radius: var(--ovk-radius); color: var(--ovk-muted); }
@@ -137,14 +187,14 @@
@@ -137,14 +187,14 @@
.ovk-fold-body { padding: 12px 14px 14px; }
.ovk-fold-body { padding: 12px 14px 14px; }
.ovk-fold-body > :first-child { margin-top: 0; }
.ovk-fold-body > :first-child { margin-top: 0; }
.ovk-fold-body > :last-child { margin-bottom: 0; }
.ovk-fold-body > :last-child { margin-bottom: 0; }
-.ovk-fold--danger > summary { color: var(--ovk-bad); }
+.ovk-fold--danger > summary { color: var(--ovk-bad-text); }
.ovk-more { position: relative; display: inline-block; }
.ovk-more { position: relative; display: inline-block; }
.ovk-more > summary { list-style: none; }
.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 { position: absolute; right: 0; top: calc(100% + 4px); z-index: 60; min-width: 210px; padding: 6px; box-sizing: border-box; border-radius: calc(var(--ov-radius-1) + 2px); border: 1px solid var(--ovk-line); background: var(--ovk-panel); box-shadow: var(--ov-elev-3); }
.ovk-pop form { margin: 0; }
.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 { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 8px 10px; border: 0; border-radius: max(0px, calc(var(--ov-radius-1) - 2px)); 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:hover, .ovk-pop-item:focus-visible { background: var(--ovk-panel-2); outline: none; }
-.ovk-pop-item--danger { color: var(--ovk-bad); }
+.ovk-pop-item--danger { color: var(--ovk-bad-text); }
/* ── Settings rows: what it does on the left, its control on the right ── */
/* ── 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-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; }
@@ -152,17 +202,17 @@
@@ -152,17 +202,17 @@
div.ovk-set-row { cursor: default; }
div.ovk-set-row { cursor: default; }
.ovk-set-text { flex: 1; min-width: 0; display: grid; gap: 2px; }
.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-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-row select { flex: none; max-width: 52%; margin: 0; padding: 7px 8px; border-radius: var(--ov-radius-1); border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: .9rem; }
.ovk-set-range input[type=range] { flex: 0 1 45%; min-width: 120px; margin: 0; accent-color: var(--ovk-fill); }
.ovk-set-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-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 { 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::after { content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: var(--ov-elev-1); transition: transform .15s; }
.ovk-switch:checked { background: var(--ovk-fill); }
.ovk-switch:checked { background: var(--ovk-fill); }
.ovk-switch:checked::after { transform: translateX(18px); }
.ovk-switch:checked::after { transform: translateX(18px); }
.ovk-switch:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: 2px; }
.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 { display: inline-flex; flex: none; flex-wrap: wrap; max-width: 100%; gap: 2px; padding: 3px; border-radius: var(--ov-radius-1); background: var(--ovk-panel-2); }
.ovk-seg input, .ovk-chip input { position: absolute; opacity: 0; pointer-events: none; }
.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 span { display: block; padding: 6px 12px; border-radius: max(0px, calc(var(--ov-radius-1) - 2px)); color: var(--ovk-muted); font-size: .86rem; font-weight: 650; cursor: pointer; }
.ovk-seg input:checked + span { background: var(--ovk-fill); color: var(--ovk-on-fill); }
.ovk-seg input: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-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-chips { display: flex; flex-wrap: wrap; gap: 6px; }
@@ -173,7 +223,7 @@ div.ovk-set-row { cursor: default; }
@@ -173,7 +223,7 @@ div.ovk-set-row { cursor: default; }
.ovk-form { display: grid; gap: 12px; margin: 0; }
.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 { 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 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 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: var(--ov-radius-1); 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 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 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; }
.ovk-form.ovk-form > p { margin: 0; }
@@ -211,7 +261,7 @@ div.ovk-set-row { cursor: default; }
@@ -211,7 +261,7 @@ div.ovk-set-row { cursor: default; }
/* ── Numbers, empty states, notices, code, sheets ── */
/* ── 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 { 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::-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 > 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: calc(var(--ov-radius-1) + 2px); background: var(--ovk-panel); }
.ovk-stats dt { margin: 0; color: var(--ovk-muted); font-size: .76rem; line-height: 1.25; }
.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; }
.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. */
/* A number that is a link: the whole tile is the target (the label's link stretched over it), never an underline. */
@@ -225,18 +275,18 @@ div.ovk-set-row { cursor: default; }
@@ -225,18 +275,18 @@ div.ovk-set-row { cursor: default; }
.ovk-empty > .ovk-i { width: 28px; height: 28px; }
.ovk-empty > .ovk-i { width: 28px; height: 28px; }
.ovk-empty.ovk-empty p { margin: 0; max-width: 46ch; }
.ovk-empty.ovk-empty p { margin: 0; max-width: 46ch; }
.ovk-empty .ovk-empty-title { color: var(--ovk-text); font-weight: 750; }
.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 { display: flex; align-items: flex-start; gap: 10px; margin: 0 0 12px; padding: 10px 14px; border-radius: calc(var(--ov-radius-1) + 2px); border: 1px solid var(--ovk-line); background: var(--ovk-panel); color: var(--ovk-text); font-size: .92rem; }
.ovk-notice > .ovk-i { margin-top: 2px; }
.ovk-notice > .ovk-i { margin-top: 2px; }
.ovk-notice--ok { border-color: color-mix(in srgb, var(--ovk-ok) 45%, transparent); }
.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--ok > .ovk-i { color: var(--ovk-ok-text); }
.ovk-notice--error { border-color: color-mix(in srgb, var(--ovk-bad) 50%, transparent); }
.ovk-notice--error { 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--error > .ovk-i { color: var(--ovk-bad-text); }
+.ovk-notice--warn > .ovk-i { color: var(--ovk-warn-text); }
.ovk-notice--info > .ovk-i { color: var(--ovk-accent); }
.ovk-notice--info > .ovk-i { color: var(--ovk-accent); }
.ovk-snip { position: relative; }
.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 > .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-snip > pre { max-height: 50vh; margin: 0 0 10px; padding: 12px 48px 12px 12px; overflow: auto; border-radius: var(--ov-radius-1); border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font-size: .84rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
+.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: calc(var(--ov-radius-3) - 2px); background: var(--ovk-panel); color: var(--ovk-text); overflow: hidden; }
.ovk-sheet::backdrop { background: rgba(0, 0, 0, .55); }
.ovk-sheet::backdrop { background: rgba(0, 0, 0, .55); }
.ovk-sheet[open] { display: flex; flex-direction: column; }
.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 { display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 16px; border-bottom: 1px solid var(--ovk-line); }
@@ -246,7 +296,7 @@ div.ovk-set-row { cursor: default; }
@@ -246,7 +296,7 @@ div.ovk-set-row { cursor: default; }
/* ── Phones (< 600 px): bottom sheets, full-width forms ── */
/* ── Phones (< 600 px): bottom sheets, full-width forms ── */
@media (max-width: 599px) {
@media (max-width: 599px) {
- .ovk-sheet { margin: auto 0 0; width: 100vw; border-radius: 18px 18px 0 0; border-bottom: 0; }
+ .ovk-sheet { margin: auto 0 0; width: 100vw; border-radius: calc(var(--ov-radius-3) - 2px) calc(var(--ov-radius-3) - 2px) 0 0; border-bottom: 0; }
.ovk-row > a, .ovk-row--static { padding-left: 12px; }
.ovk-row > a, .ovk-row--static { padding-left: 12px; }
.ovk-head-tools { gap: 0; }
.ovk-head-tools { gap: 0; }
}
}
@@ -273,3 +323,109 @@ div.ovk-set-row { cursor: default; }
@@ -273,3 +323,109 @@ div.ovk-set-row { cursor: default; }
.ovk-go::after { content: ''; position: absolute; inset: -3px; }
.ovk-go::after { content: ''; position: absolute; inset: -3px; }
.ovk-find input, .ovk-find select { min-height: 44px; }
.ovk-find input, .ovk-find select { min-height: 44px; }
}
}
+
+/* ── The left sidebar (kit.sidebar, L35): one for every product. A column beside the page from 761 px; on a phone a
+ drawer over everything (kit.sidebarButton opens it; the scrim, its close button and Escape close it, kit-web.js). The
+ main action and search on top, the navigation as vertical rows, then the sections, which scroll on their own. ── */
+.ovk-with-sidebar { display: grid; grid-template-columns: var(--ovk-sidebar-w, 272px) minmax(0, 1fr); min-height: calc(100dvh - var(--navbar-h, 52px)); }
+.ovk-with-sidebar-main { min-width: 0; }
+.ovk-sidebar { position: sticky; top: var(--navbar-h, 52px); height: calc(100dvh - var(--navbar-h, 52px)); box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: 12px 10px 10px; border-right: 1px solid var(--ovk-line); background: var(--ovk-panel); color: var(--ovk-text); overflow: hidden; }
+.ovk-sidebar-top { display: flex; align-items: center; gap: 6px; }
+.ovk-sidebar-action { flex: 1; display: flex; align-items: center; gap: 10px; min-height: 42px; padding: 0 12px; border-radius: calc(var(--ov-radius-1) + 2px); border: 1px solid var(--ovk-line); background: var(--ovk-bg); color: var(--ovk-text); font-weight: 650; text-decoration: none; transition: border-color .18s, background .18s; }
+.ovk-sidebar-action:hover { border-color: color-mix(in srgb, var(--ovk-accent) 55%, var(--ovk-line)); }
+.ovk-sidebar-action .ovk-i { width: 18px; height: 18px; color: var(--ovk-accent); }
+.ovk-sidebar-close, .ovk-sidebar-open { display: none; }
+.ovk-sidebar-find { position: relative; display: flex; align-items: center; margin: 0; }
+.ovk-sidebar-find .ovk-i { position: absolute; left: 11px; width: 16px; height: 16px; color: var(--ovk-muted); pointer-events: none; }
+.ovk-sidebar-find input { width: 100%; min-height: 38px; box-sizing: border-box; padding: 0 12px 0 34px; border-radius: var(--ov-radius-1); border: 1px solid transparent; background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 14px; }
+.ovk-sidebar-find input:focus { outline: none; border-color: var(--ovk-accent); }
+.ovk-sidebar-nav { display: grid; gap: 1px; padding-bottom: 8px; border-bottom: 1px solid var(--ovk-line); }
+.ovk-sidebar-link { display: flex; align-items: center; gap: 11px; min-height: 38px; padding: 0 10px; border-radius: var(--ov-radius-1); color: var(--ovk-muted); font-size: 14.5px; font-weight: 600; text-decoration: none; transition: background .15s, color .15s; }
+.ovk-sidebar-link .ovk-i { width: 19px; height: 19px; flex: none; }
+.ovk-sidebar-link span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovk-sidebar-link:hover { background: var(--ovk-panel-2); color: var(--ovk-text); }
+.ovk-sidebar-link.is-here { background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); color: var(--ovk-text); }
+.ovk-sidebar-link.is-here .ovk-i { color: var(--ovk-accent); }
+.ovk-sidebar-link:focus-visible, .ovk-sidebar-item:focus-visible, .ovk-sidebar-action:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: -2px; }
+.ovk-sidebar-count { min-width: 20px; box-sizing: border-box; padding: 1px 6px; border-radius: 999px; background: var(--ovk-fill); color: var(--ovk-on-fill); font-size: 11.5px; font-weight: 700; text-align: center; }
+.ovk-sidebar-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--ovk-accent); }
+.ovk-sidebar-dot.is-busy { animation: ovk-sidebar-breathe 1.6s ease-in-out infinite; }
+.ovk-sidebar-dot.is-needs { background: var(--ovk-warn); }
+.ovk-sidebar-dot.is-bad { background: var(--ovk-bad); }
+@keyframes ovk-sidebar-breathe { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.75); } }
+.ovk-sidebar-secs { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; margin: 0 -4px; padding: 0 4px 8px; }
+.ovk-sidebar-h { margin: 12px 10px 4px; font-size: 11.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--ovk-muted); }
+.ovk-sidebar-item { display: flex; align-items: center; gap: 9px; min-height: 36px; padding: 4px 10px; border-radius: var(--ov-radius-1); color: var(--ovk-text); font-size: 14px; text-decoration: none; }
+.ovk-sidebar-item:hover { background: var(--ovk-panel-2); }
+.ovk-sidebar-item.is-here { background: var(--ovk-panel-2); font-weight: 600; }
+.ovk-sidebar-t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovk-sidebar-m { flex: none; color: var(--ovk-muted); font-size: 12px; }
+/* A row with a quiet second line (4.102.0): a conversation's last message under its name, the mark beside both lines. */
+.ovk-sidebar-item.has-sub { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; column-gap: 9px; row-gap: 1px; padding-block: 6px; }
+.ovk-sidebar-item.has-sub.has-mark { grid-template-columns: auto minmax(0, 1fr) auto auto; }
+.ovk-sidebar-item.has-sub.has-mark > :first-child { grid-row: 1 / span 2; }
+.ovk-sidebar-s { grid-column: 1 / -1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ovk-muted); font-size: 12.5px; font-weight: 400; }
+.ovk-sidebar-item.has-sub.has-mark .ovk-sidebar-s { grid-column: 2 / -1; }
+.ovk-sidebar-empty { margin: 8px 10px; color: var(--ovk-muted); font-size: 13.5px; }
+.ovk-sidebar-foot { padding-top: 8px; border-top: 1px solid var(--ovk-line); }
+@media (max-width: 760px) {
+ .ovk-with-sidebar { grid-template-columns: minmax(0, 1fr); }
+ .ovk-sidebar { position: fixed; z-index: 10060; top: 0; left: 0; bottom: 0; height: auto; width: min(320px, 86vw); padding-top: max(12px, env(safe-area-inset-top, 0px)); padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)); border-right: 0; translate: -100% 0; visibility: hidden; transition: translate .25s ease, visibility 0s linear .25s; box-shadow: 12px 0 32px rgba(0, 0, 0, .25); }
+ .ovk-sidebar.is-open { translate: 0 0; visibility: visible; transition: translate .25s ease; }
+ .ovk-sidebar-close, .ovk-sidebar-open { display: inline-grid; }
+ .ovk-sidebar-scrim { position: fixed; inset: 0; z-index: 10050; background: rgba(0, 0, 0, .4); }
+}
+@media (prefers-reduced-motion: reduce) { .ovk-sidebar, .ovk-sidebar.is-open { transition: none; } .ovk-sidebar-dot.is-busy { animation: none; } }
+
+/* ── Scroll areas (4.69.0; the owner, 2026-11-01: "you can't scroll down the page even when you're at the bottom of
+ the chat… this should be consistent across websites"). The network's rule: a scroll area inside a page that scrolls
+ hands the swipe on to the page at its ends; only an area that fills the screen (a sheet, a modal, a full-screen
+ chat) holds it. A sideways rail scrolls sideways only, so a vertical swipe on it moves the page. */
+.ov-scroll { overflow: auto; overscroll-behavior: auto; -webkit-overflow-scrolling: touch; }
+.ov-scroll-fill { overflow: auto; overscroll-behavior: contain; }
+.ov-scroll-x { overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; touch-action: pan-x pan-y; scrollbar-width: none; }
+.ov-scroll-x::-webkit-scrollbar { display: none; }
+@media (max-width: 900px), (hover: none) {
+ .ovk-sidebar-secs { overscroll-behavior-y: auto; }
+}
+
+/* ── Placeholders shaped like the content (4.70.0, kit.skeleton; plan L52) ── A soft light passes over grey shapes the size of
+ what is coming; nothing spins; still under reduced motion and Calm. A list paints these in its first HTML and fills them in
+ place. */
+.ovk-skel { display: block; border-radius: max(0px, calc(var(--ov-radius-1) - 2px)); background: color-mix(in srgb, var(--text-primary, #e8edf3) 7%, transparent); background-image: linear-gradient(100deg, transparent 30%, color-mix(in srgb, var(--text-primary, #e8edf3) 9%, transparent) 50%, transparent 70%); background-size: 220% 100%; background-repeat: no-repeat; animation: ovkShimmer 1.6s ease-in-out infinite; }
+@keyframes ovkShimmer { from { background-position: 120% 0; } to { background-position: -120% 0; } }
+.ovk-skels { display: grid; gap: 12px; }
+.ovk-skels-card { grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); }
+.ovk-skels-tile { grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); }
+.ovk-skels-chip { display: flex; flex-wrap: wrap; gap: 8px; }
+.ovk-skel-card { display: grid; gap: 8px; }
+.ovk-skel-thumb { aspect-ratio: 16 / 9; border-radius: calc(var(--ov-radius-1) + 2px); }
+.ovk-skel-l1 { height: 13px; width: 86%; }
+.ovk-skel-l2 { height: 11px; width: 58%; }
+.ovk-skel-row { display: flex; align-items: center; gap: 12px; }
+.ovk-skel-avatar { flex: none; width: 40px; height: 40px; border-radius: 50%; }
+.ovk-skel-lines { flex: 1; display: grid; gap: 7px; min-width: 0; }
+.ovk-skel-tile { aspect-ratio: 1; border-radius: var(--ov-radius-2); }
+.ovk-skel-chip { width: 84px; height: 30px; border-radius: 999px; }
+.ovk-skel-line { height: 12px; }
+.ovk-skel-chat { display: flex; align-items: center; gap: 8px; }
+.ovk-skel-dot { flex: none; width: 22px; height: 22px; border-radius: 50%; }
+.ovk-skel-stat { display: grid; gap: 6px; padding: 12px; border-radius: var(--ov-radius-2); border: 1px solid var(--border, rgba(255,255,255,.08)); }
+.ovk-skel-num { height: 22px; width: 48%; }
+@media (prefers-reduced-motion: reduce) { .ovk-skel { animation: none; } }
+html[data-ov-motion=reduced] .ovk-skel { animation: none; }
+
+/* ── Moving between pages (4.70.0, plan L52) ── A server-rendered site's pages cross-fade while the navbar and the app bar
+ stay put (cross-document view transitions; a browser without them just loads the page). Off under reduced motion. */
+@view-transition { navigation: auto; }
+::view-transition-old(root), ::view-transition-new(root) { animation-duration: .18s; }
+#navbar-mount, .openvibe-navbar { view-transition-name: ov-navbar; }
+.ovnav-appbar { view-transition-name: ov-appbar; }
+@media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }
+
+/* Content labels (L1): quiet chips beside a title, the adult one in the warning colour. They say what a thing carries;
+ each viewer's own choice (ov-labels.js) decides whether it shows, blurs or hides. */
+.ovk-labels { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 0; padding: 0; list-style: none; }
+.ovk-label { display: inline-flex; align-items: center; min-height: 20px; padding: 0 8px; border-radius: 999px; font-size: .7rem; font-weight: 700; letter-spacing: .02em;
+ color: var(--text-secondary, #a8b3c7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); border: 1px solid var(--border, rgba(255,255,255,.1)); }
+.ovk-label--adult { color: var(--warning-text, #fbbf24); border-color: color-mix(in srgb, var(--warning-text, #fbbf24) 45%, transparent); background: color-mix(in srgb, var(--warning-text, #fbbf24) 9%, transparent); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -0,0 +1,126 @@
+/*
+ * openvibe-shared/notification-live.js — the notification badge's realtime feed (roadmap WS-E task 3, WS-F task 1;
+ * ADR-005 amendment 2). notification-ui.js loads it on demand when a site turns it on (navbar notificationsRealtime).
+ * For every (re)connect it asks Network for a realtime ticket (POST <api>/api/v1/realtime/ticket: Bearer token, or the
+ * cookie on openvibe.network itself) and opens Events' stream with it, without credentials:
+ * <stream_url>?topics=network.notification.*&ticket=<ticket>[&last_event_id=<cursor>]
+ * Events streams only the person's own network.notification.created events (subject visibility); one whose subject is
+ * not the ticket's is ignored all the same. Each calls onNotification(payload); `event: gap` calls onGap.
+ * The cursor is the last SSE id Events sent (opaque, handed back as it came); an event already heard (by event_id) is
+ * skipped. A ticket opens one stream: errors close it and reconnect with a fresh ticket from the cursor, backing off 2 s to 15 min
+ * with jitter. Stops (the caller's polling is all there is) after 10 failures in a row, on a Content-Security-Policy
+ * refusal, when signed out (401/403) or when Network has realtime off (404/503). Hidden 5 min: closed; shown: resumed.
+ * OVNotificationLive.create({ ticketUrl, token, credentials, onNotification, onGap, onState }) → { start, stop, restart, state }
+ */
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OVNotificationLive) return;
+ const TYPE_RE = /^network\.notification\./;
+ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+ const MAX_FAILURES = 10; // 2 s, 4 s, … 512 s: about 9 to 17 minutes of retries before polling only
+ const SEEN_MAX = 256; // event ids remembered for dedupe
+
+ function create(o) {
+ const rt = { state: 'off', failures: 0, lastId: null, subject: null, origin: null, events: 0, ignored: 0, gaps: 0, tickets: 0 };
+ let es = null; let stopped = true; let gen = 0;
+ const seen = new Set();
+ const t = {}; // timers: retry, hide
+ const later = (k, f, ms) => { root.clearTimeout(t[k]); t[k] = root.setTimeout(() => { t[k] = null; f(); }, ms); };
+ const call = (f, ...a) => { try { if (typeof f === 'function') f(...a); } catch { /* the page's handler must not break the feed */ } };
+ function set(s) { if (rt.state !== s) { rt.state = s; call(o.onState, s); } }
+ function close() { const s = es; es = null; if (s) try { s.close(); } catch { /* */ } }
+ function halt(s) { close(); root.clearTimeout(t.retry); t.retry = null; set(s); }
+
+ async function open() {
+ if (stopped || es || rt.state === 'ticket') return;
+ if (document.hidden) { set('hidden'); return; }
+ const g = gen;
+ set('ticket');
+ let tk;
+ try {
+ const tok = typeof o.token === 'function' ? await o.token() : o.token;
+ const r = await root.fetch(o.ticketUrl, { method: 'POST', credentials: o.credentials || 'omit', cache: 'no-store', headers: tok ? { Authorization: `Bearer ${tok}` } : {} });
+ if (g !== gen) return;
+ if (r.status === 401 || r.status === 403) return halt('signed-out');
+ if (r.status === 404 || r.status === 501 || r.status === 503) return halt('unavailable');
+ if (!r.ok) throw new Error('ticket');
+ tk = await r.json();
+ if (!tk || typeof tk.ticket !== 'string' || !SUBJECT_RE.test(String(tk.subject)) || !Array.isArray(tk.topics)) throw new Error('ticket');
+ } catch {
+ if (g === gen) fail();
+ return;
+ }
+ if (g !== gen) return;
+ let u;
+ try {
+ u = new URL(tk.stream_url);
+ if (!/^https?:$/.test(u.protocol)) throw new Error('url');
+ u.searchParams.set('topics', tk.topics.join(','));
+ u.searchParams.set('ticket', tk.ticket);
+ if (rt.lastId != null) u.searchParams.set('last_event_id', rt.lastId);
+ } catch { fail(); return; }
+ rt.tickets++; rt.origin = u.origin;
+ rt.subject = tk.subject;
+ set('connecting');
+ let s;
+ try { s = es = new root.EventSource(u.href); } catch { es = null; fail(); return; }
+ s.onopen = () => { if (es === s) { rt.failures = 0; set('open'); } };
+ s.onmessage = (e) => { if (es === s) heard(e); };
+ s.onerror = () => { if (es === s) fail(); };
+ s.addEventListener('gap', (e) => {
+ if (es !== s) return;
+ let gap = null; try { gap = JSON.parse(e.data); } catch { /* */ }
+ rt.gaps++;
+ call(o.onGap, gap);
+ });
+ }
+ function heard(e) {
+ let m; try { m = JSON.parse(e.data); } catch { return; }
+ const ev = m && m.event;
+ const id = ev && typeof ev.event_id === 'string' ? ev.event_id : null;
+ if (id) {
+ if (seen.has(id)) return; // already heard (a replay after a reconnect)
+ seen.add(id);
+ if (seen.size > SEEN_MAX) seen.delete(seen.values().next().value);
+ }
+ if (e.lastEventId) rt.lastId = String(e.lastEventId);
+ // Events already sends only this person's own; anything else is never shown.
+ if (!ev || !TYPE_RE.test(String(ev.event_type)) || !ev.subject || ev.subject.type !== 'user' || ev.subject.id !== rt.subject) { rt.ignored++; return; }
+ rt.events++;
+ call(o.onNotification, ev.payload && typeof ev.payload === 'object' ? ev.payload : {}, ev);
+ }
+ // A ticket opens one stream, so the browser's own reconnect (same URL) is never used: close, back off, new ticket.
+ function fail() {
+ close();
+ if (stopped || rt.state === 'blocked') return;
+ if (++rt.failures >= MAX_FAILURES) { set('failed'); return; }
+ set('backoff');
+ later('retry', () => { if (!stopped) { if (document.hidden) set('hidden'); else open(); } }, Math.min(9e5, 2e3 * 2 ** (rt.failures - 1)) * (0.5 + Math.random() / 2));
+ }
+
+ // A Content-Security-Policy whose connect-src leaves Events out: one refusal, then polling only.
+ document.addEventListener('securitypolicyviolation', (e) => {
+ if (stopped || !rt.origin || String(e.blockedURI || '').indexOf(rt.origin) !== 0) return;
+ halt('blocked');
+ });
+ document.addEventListener('visibilitychange', () => {
+ if (stopped) return;
+ if (document.hidden) { if (es) later('hide', () => { if (document.hidden && es) { close(); set('hidden'); } }, 5 * 60 * 1000); return; }
+ root.clearTimeout(t.hide); t.hide = null;
+ if (rt.state === 'hidden') open();
+ });
+ root.addEventListener('online', () => { if (!stopped && (rt.state === 'failed' || rt.state === 'backoff')) { rt.failures = 0; root.clearTimeout(t.retry); set('off'); open(); } });
+
+ const api = {
+ start() { if (!stopped) return api; stopped = false; set('off'); open(); return api; },
+ stop() { stopped = true; gen++; close(); Object.keys(t).forEach((k) => { root.clearTimeout(t[k]); t[k] = null; }); set('off'); return api; },
+ /** Another account (or a new token): forget the cursor and the subject, start again. */
+ restart() { api.stop(); rt.lastId = null; seen.clear(); rt.subject = null; rt.failures = 0; return api.start(); },
+ state: () => ({ ...rt }),
+ };
+ return api;
+ }
+
+ root.OVNotificationLive = { create };
+ if (typeof module !== 'undefined' && module.exports) module.exports = root.OVNotificationLive;
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -65,7 +65,9 @@
@@ -65,7 +65,9 @@
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"/>',
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"/>',
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"/>',
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"/>',
+ bell: '<path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15ZM10 20a2 2 0 0 0 4 0"/>',
+ // The set's solid bell (openvibe-shared/glyphs): shown where the person prefers solid icons (4.71.0).
+ bellSolid: '<path d="M5.8 16.2V11a6.2 6.2 0 0 1 12.4 0v5.2l1.7 2.3H4.1ZM9.9 19.6h4.2a2.1 2.1 0 0 1-4.2 0Z"/>',
key: '<circle cx="8.2" cy="14.8" r="4"/><path d="M11.1 12l8.2-8.2M16.4 6.7l2.3 2.3M14.2 8.9l1.8 1.8"/>',
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"/>',
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"/>',
};
};
@@ -116,7 +118,7 @@
@@ -116,7 +118,7 @@
@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-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-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-sheet-up { from { transform: translateY(48px); opacity: .5; } to { transform: none; opacity: 1; } }
- @keyframes ovn-spin { to { transform: rotate(360deg); } }
+ @keyframes ovn-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-4px); opacity: 1; } }
/* Toasts. Opaque cards on the theme's raised surface; held (hidden, timers paused) while a menu is open. */
/* 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;
.openvibe-toast-container { position: fixed; z-index: 2147482600; display: flex; flex-direction: column; gap: 10px; pointer-events: none;
@@ -164,7 +166,8 @@
@@ -164,7 +166,8 @@
.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; }
.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 { 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; }
+ html[data-ov-icons=solid] .openvibe-bell svg.has-s { stroke-width: 0; } html[data-ov-icons=solid] .openvibe-bell svg > .ovg-s { display: inline; fill: currentColor; stroke: none; } .openvibe-bell svg > .ovg-s { display: none; }
+ .openvibe-bell svg { color: var(--text-secondary, #b0b0b8); width: 22px; height: 22px; fill: none; stroke: var(--text-secondary, #b0b0b8); stroke-width: 1.9; 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: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: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;
.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;
@@ -280,7 +283,9 @@
@@ -280,7 +283,9 @@
.openvibe-nv-empty b { display: block; font-size: 15px; color: var(--text-primary, #ececf1); margin-bottom: 4px; }
.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 { 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 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-more .spinner { position: relative; display: inline-block; width: 6px; height: 6px; margin: 0 10px; border: 0; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovn-hop 1s ease-in-out .15s infinite; }
+ .openvibe-nv-more .spinner::before, .openvibe-nv-more .spinner::after { content: ''; position: absolute; top: 0; width: 6px; height: 6px; border-radius: 50%; background: inherit; animation: ovn-hop 1s ease-in-out infinite; }
+ .openvibe-nv-more .spinner::before { left: -10px; } .openvibe-nv-more .spinner::after { left: 10px; animation-delay: .3s; }
.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 { 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-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); }
.openvibe-nv-note { font-size: 12px; color: var(--text-secondary, #b6b6c0); padding: 8px 16px; background: color-mix(in srgb, var(--warning, #f5a524) 12%, transparent); }
@@ -783,7 +788,7 @@
@@ -783,7 +788,7 @@
const bell = document.createElement('div');
const bell = document.createElement('div');
bell.className = 'openvibe-bell';
bell.className = 'openvibe-bell';
bell.setAttribute('role', 'button'); bell.setAttribute('aria-label', 'Notifications'); bell.tabIndex = 0;
bell.setAttribute('role', 'button'); bell.setAttribute('aria-label', 'Notifications'); bell.tabIndex = 0;
- bell.innerHTML = `${svg(GLYPH.bell)}<span class="badge hidden">0</span>`;
+ bell.innerHTML = `<svg class="ovnav-ovg has-s" viewBox="0 0 24 24" aria-hidden="true">${GLYPH.bell}<g class="ovg-s">${GLYPH.bellSolid}</g></svg><span class="badge hidden">0</span>`;
bell.addEventListener('click', () => togglePanel());
bell.addEventListener('click', () => togglePanel());
bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } });
bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } });
_bellEl = bell;
_bellEl = bell;
@@ -1,92 +1,184 @@
@@ -1,92 +1,184 @@
/**
/**
- * 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.
+ * ov-mark.js — the OpenVibe mark as a drop-in.
+ * <span class="ov-mark"></span> → a 32px mark (the box is square, the mark centred in it)
+ * <span class="ov-mark" data-size="56"> → any size (the box's width); add data-static="1" for no motion
+ * <span class="ov-mark" data-wide> → the box hugs the mark (92 × 56) instead of a square
+ * <span class="ov-mark" data-variant="live"> → the site's own touch (Live's record light in the O, see below)
+ * <span class="ov-mark" data-tone="ink|white"> → one colour: the text's (stamps, UI) or white (on a colour tile)
+ * <span class="ov-mark" data-form="heavy"> → force a form (default: heavy up to 22px wide, the pair above)
+ * data-state="busy|ok|error|no|notify|live|sleep" → what it shows (island.js sets busy/ok/error on the navbar's)
+ * data-progress="0.4" with style="--ovm-p:.4" → a known progress: the ring draws round to that much
+ * OVMark.busy(button) → { done, fail, reset }: a button at work shows the mark busy, then the tick or the "!". A form
+ * posted the old way (POST) does this by itself until the next page; data-ov-busy="off" on a form opts out.
+ * Self-contained: injects its own CSS once, works on any page (Live, Network, static pages). The same mark as static
+ * SVG, for servers and icons: openvibe-shared/mark (mark.js), whose numbers this file draws.
*
*
- * 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 mark is the Pair (the owner, 2026-10-31: "Pair, halo … this should be our new logo"): a ring O and a round V,
+ * with a gap of the ground around the V where it crosses the ring. The gap is a mask, so the mark sits on any ground.
+ * The V is as tall as the O, so it sits level beside a word in the navbar and centred in a tile. Two forms: the pair,
+ * and the heavy (thicker strokes) for 22px and under, where the pair's gap would close.
+ *
+ * Everything it does means something, and nothing spins:
+ * - intro: the O rises and draws itself round, the V drops in on a spring, then a glint of light crosses the mark;
+ * - hover: the V leans back, the O hops, a glint crosses and a soft glow of its two colours comes up behind it;
+ * - press: it gives a little and springs back;
+ * Each stroke runs light to deep in its own colour (data-glow keeps the glow on, for heroes and brand pages).
+ * - a page on its way (navbar): the ring empties and draws itself round again as the next page loads, paced by how
+ * long this site's pages usually take in this browser (learned per site, boost moves and full loads apart); the
+ * V nods while it waits and hops when the page is here (window.OVMark.navStart/navEnd for single-page apps);
+ * - busy: the V bobs and the O breathes, in turn; ok: the V becomes a tick, green, and hops (the tick only ever
+ * means "done"); error: the V becomes "!", red, and the mark shakes; no: the V becomes "×", grey, and the O
+ * shakes its head; notify: a dot pops up by the V and the O bounces; live: the O is the record light, red, and
+ * ripples go out from it; sleep: the O closes like an eye and the V sinks.
+ *
+ * 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 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 a state, a page load or
+ * progress. Otherwise its CSS animations are paused where they are. It never moves while the page is hidden, while it
+ * is off-screen (IntersectionObserver), with data-static, under prefers-reduced-motion or under the network's own
+ * reduced-motion setting (html[data-ov-motion=reduced], theme-loader.js); its colours and shapes still change.
*
*
* The season (theme-loader.js sets <html data-ov-season-mark="halloween|winter"> for anyone who has not turned seasonal
* 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.
+ * themes off, on any theme): the mark wears it, the O a pumpkin with its stem and leaf in October, caps of snow in
+ * December. Now and then (every 4 to 8 minutes) a mark on screen flickers for a moment, and on the first visit of the
+ * day a few leaves (snowflakes in December) drift down from the first mark seen. Never under either reduced-motion setting.
*/
*/
(function () {
(function () {
'use strict';
'use strict';
if (window.__ovMark) return; window.__ovMark = true;
if (window.__ovMark) return; window.__ovMark = true;
let n = 0;
let n = 0;
+ const f = (v) => +v.toFixed(2);
+ // The Pair in a 100-unit box, centred on (50, 50) (openvibe-shared/mark: the same numbers; test/mark.test.js).
+ function pair(r, sw, vsw, a = 0.86, t = 1.5, b = 2.16) {
+ const R = r + sw / 2, right = b * R + vsw / 2, cx = 50 - (right - R) / 2, cy = 50;
+ const A = [f(cx + a * R), f(cy - R + vsw / 2)], T = [f(cx + t * R), f(cy + R - vsw / 2)], B = [f(cx + b * R), f(cy - R + vsw / 2)];
+ const ring = `M${f(cx)} ${f(cy - r)}A${r} ${r} 0 1 1 ${f(cx)} ${f(cy + r)}A${r} ${r} 0 1 1 ${f(cx)} ${f(cy - r)}`;
+ const x0 = f(cx - R), x1 = f(B[0] + vsw / 2), pad = 2, w = f(x1 - x0 + 2 * pad);
+ const box = `${f(50 - w / 2)} ${f(50 - w / 2)} ${w} ${w}`, wideBox = `${f(50 - w / 2)} ${f(50 - R - pad)} ${w} ${f(2 * R + 2 * pad)}`;
+ // The V's two strokes, each from its first point, for every shape it takes.
+ const vw = B[0] - A[0], vh = T[1] - A[1], mx = A[0] + vw / 2, my = A[1] + vh * 0.55, s = Math.min(vw, vh) * 0.42, tick = [A[0] + vw * 0.46, T[1]];
+ const shapes = {
+ v: [T[0], T[1], A[0], A[1], T[0], T[1], B[0], B[1]],
+ tip: [T[0], T[1], T[0], T[1], T[0], T[1], T[0], T[1]],
+ ok: [tick[0], tick[1], A[0] + vw * 0.16, A[1] + vh * 0.52, tick[0], tick[1], B[0] + vw * 0.06, A[1]],
+ no: [mx - s + vw * 0.1, my - s, mx + s + vw * 0.1, my + s, mx - s + vw * 0.1, my + s, mx + s + vw * 0.1, my - s],
+ err: [mx, A[1], mx, T[1] - vsw * 1.28, mx, T[1], mx, T[1] + 0.01],
+ };
+ for (const k in shapes) shapes[k] = shapes[k].map(f);
+ return { cx: f(cx), cy, r, sw, R: f(R), vsw, halo: vsw + 7, A, T, B, ring, box, wideBox, shapes };
+ }
+ const FORMS = { pair: pair(20, 13, 13), heavy: pair(18, 18, 17) };
+ const formOf = (v) => (v === 'heavy' || v === 'solid' ? 'heavy' : 'pair');
const CSS = `
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)}}
+@property --ovm-fill{syntax:'<number>';inherits:true;initial-value:1}
+.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;flex:none;vertical-align:middle;line-height:0;--ovm-o:#4f7cff;--ovm-v:#8b5cf6;--ovm-fill:1;transition:--ovm-fill .55s cubic-bezier(.3,.8,.2,1)}
+.ov-mark[data-wide]{height:auto;aspect-ratio:94/57}
+.ov-mark svg{width:100%;height:100%;overflow:visible}
+.ov-mark .m,.ov-mark .og,.ov-mark .vg{transform-box:view-box}
+.ov-mark .m{transform-origin:50px 50px}
+.ov-mark .o,.ov-mark .ot{fill:none;stroke:var(--ovm-o);transition:stroke .35s}
+.ov-mark .ot{opacity:0;transition:opacity .3s,stroke .35s}
+.ov-mark .va,.ov-mark .vb{fill:none;stroke:var(--ovm-v);stroke-linecap:round;stroke-linejoin:round;transition:stroke .35s,opacity .5s}
+.ov-mark .ha,.ov-mark .hb{fill:none;stroke:#000;stroke-linecap:round}
+/* Depth (4.65.0): each stroke runs from a lighter to a deeper tone of its own colour, so states, seasons, tones and a
+ page's own colour still decide it. Without color-mix the strokes are flat. */
+.ov-mark .g1,.ov-mark .g2{stop-color:var(--ovm-o);transition:stop-color .35s}.ov-mark .g3,.ov-mark .g4{stop-color:var(--ovm-v);transition:stop-color .35s}
+@supports (color:color-mix(in srgb,red 50%,blue)){.ov-mark .g1{stop-color:color-mix(in srgb,var(--ovm-o) 68%,#fff)}.ov-mark .g2{stop-color:color-mix(in srgb,var(--ovm-o) 84%,#000)}.ov-mark .g3{stop-color:color-mix(in srgb,var(--ovm-v) 70%,#fff)}.ov-mark .g4{stop-color:color-mix(in srgb,var(--ovm-v) 86%,#000)}
+.ov-mark[data-tone] .g1,.ov-mark[data-tone] .g2{stop-color:var(--ovm-o)}.ov-mark[data-tone] .g3,.ov-mark[data-tone] .g4{stop-color:var(--ovm-v)}}
+/* The glint: a band of light crosses the mark (its own shape is the mask) on the intro and on hover. */
+.ov-mark .gr{transform-box:view-box;transform-origin:50px 50px;transform:translateX(-80px) skewX(-20deg)}
+/* The glow: a soft light of the mark's two colours behind it, on hover; always on with data-glow (heroes, brand pages). */
+.ov-mark{position:relative;isolation:isolate}
+.ov-mark::before{content:"";position:absolute;inset:6%;z-index:-1;border-radius:50%;pointer-events:none;background:radial-gradient(closest-side at 36% 50%,var(--ovm-o),transparent),radial-gradient(closest-side at 70% 50%,var(--ovm-v),transparent);filter:blur(8px);opacity:0;transform:scale(.7);transition:opacity .45s,transform .6s cubic-bezier(.3,.8,.2,1)}
+.ov-mark[data-glow]::before{opacity:.42;transform:none}
+a:hover>.ov-mark::before,button:hover>.ov-mark::before,.ov-mark:hover::before,a:focus-visible>.ov-mark::before{opacity:.55;transform:scale(1.14)}
+.ov-mark[data-tone]::before,.ov-mark[data-state=sleep]::before{display:none}
+/* A press: the mark gives a little and springs back. */
+.ov-mark .m{transition:transform .42s cubic-bezier(.34,1.56,.64,1)}
+a:active>.ov-mark .m,button:active>.ov-mark .m,.ov-mark:active .m{transform:scale(.9);transition-duration:.1s}
+.ov-mark .cd{display:none;fill:var(--ovm-dot,#ff3b5c);transform-box:fill-box;transform-origin:center}
+.ov-mark .nd{fill:var(--danger,#ef4444);opacity:0;transform:scale(0);transform-box:fill-box;transform-origin:center;transition:opacity .2s,transform .42s cubic-bezier(.34,1.56,.64,1)}
+.ov-mark .rp{fill:none;stroke:#ff3b5c;stroke-width:3;opacity:0;transform-box:fill-box;transform-origin:center}
+.ov-mark[data-tone=ink]{--ovm-o:currentColor;--ovm-v:currentColor}
+.ov-mark[data-tone=white]{--ovm-o:#fff;--ovm-v:#fff}
+/* The season: the O is a pumpkin in October (states below still colour it). */
.ov-mark .sh,.ov-mark .sw2{display:none}
.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 .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}
+/* As weak as the base rule, so a state, a tone or a page's own colour (a brand page) wins over the season. */
+:where(html[data-ov-season-mark=halloween]) .ov-mark{--ovm-o:#f97316}
+.ov-mark .ss{fill:none;stroke:#5fae3c;stroke-width:4.4;stroke-linecap:round}
+.ov-mark .sl{fill:#7fd04e;transform-box:fill-box;transform-origin:0 80%}
+.ov-mark .sc{fill:none;stroke:#f4fbff;stroke-width:5.6;stroke-linecap:round}
.ov-mark .sd{fill:#f4fbff}
.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}
+/* Hover and focus: the V leans back and the O hops. */
+a:hover>.ov-mark:not([data-state]) .vg,button:hover>.ov-mark:not([data-state]) .vg,.ov-mark:not([data-state]):hover .vg,a:focus-visible>.ov-mark:not([data-state]) .vg{animation:ovmLean .62s ease-out 1}
+a:hover>.ov-mark:not([data-state]) .og,button:hover>.ov-mark:not([data-state]) .og,.ov-mark:not([data-state]):hover .og,a:focus-visible>.ov-mark:not([data-state]) .og{animation:ovmHopO .62s ease-out 1}
+a:hover>.ov-mark .gr,button:hover>.ov-mark .gr,.ov-mark:hover .gr,a:focus-visible>.ov-mark .gr{animation:ovmGlint .8s cubic-bezier(.45,0,.25,1) .05s 1}
+/* The intro: the O rises and draws itself round, the V drops in on a spring, and a glint crosses the two. */
+.ov-mark[data-ovm-intro] .og{animation:ovmRise .6s cubic-bezier(.34,1.4,.64,1) backwards}
+.ov-mark[data-ovm-intro] .o{animation:ovmDraw .8s cubic-bezier(.65,0,.25,1) backwards}
+.ov-mark[data-ovm-intro] .vg{animation:ovmDrop .62s cubic-bezier(.34,1.56,.64,1) .32s backwards}
+.ov-mark[data-ovm-intro] .gr{animation:ovmGlint .9s cubic-bezier(.45,0,.25,1) .8s backwards}
+html[data-ov-season-mark] .ov-mark[data-ovm-intro] .sl{animation:ovmLeaf .9s cubic-bezier(.3,1.6,.5,1) .5s both}
+/* A page on its way, or a known progress: the ring draws itself round to the level over a faint track. */
+.ov-mark[data-nav],.ov-mark[data-progress]{transition:--ovm-fill .3s linear}
+.ov-mark[data-progress]{--ovm-fill:var(--ovm-p,0)}
+.ov-mark[data-nav] .o,.ov-mark[data-progress] .o{stroke-dasharray:100 100;stroke-dashoffset:calc((1 - var(--ovm-fill)) * 100px)}
+.ov-mark[data-nav] .ot,.ov-mark[data-progress] .ot{opacity:.24}
+.ov-mark[data-nav]:not([data-ovm-arrive]) .vg{animation:ovmNod .9s ease-in-out infinite}
+.ov-mark[data-ovm-arrive] .vg{animation:ovmHop .5s ease-out 1}
+.ov-mark[data-ovm-arrive] .og{animation:ovmPulse .5s ease-out 1}
+/* The states. */
+.ov-mark[data-state=busy] .vg{animation:ovmBob .9s ease-in-out infinite}
+.ov-mark[data-state=busy] .og{animation:ovmBreathe .9s ease-in-out .45s infinite}
+.ov-mark[data-state=ok]{--ovm-o:var(--success,#22c55e);--ovm-v:var(--success,#22c55e)}
+.ov-mark[data-state=ok] .vg{animation:ovmHop .56s ease-out .14s 1 both}
+.ov-mark[data-state=error]{--ovm-o:var(--danger,#ef4444);--ovm-v:var(--danger,#ef4444)}
+.ov-mark[data-state=error] .m{animation:ovmShake .56s ease-in-out 1}
+.ov-mark[data-state=no]{--ovm-o:var(--text-muted,#94a3b8);--ovm-v:var(--text-muted,#94a3b8)}
+.ov-mark[data-state=no] .og{animation:ovmNo .52s ease-in-out .1s 1}
+.ov-mark[data-state=notify] .nd{opacity:1;transform:scale(1)}
+.ov-mark[data-state=notify] .og{animation:ovmBounce .76s ease-out .14s 1}
+.ov-mark[data-state=live]{--ovm-o:#ff3b5c}
+.ov-mark[data-state=live] .og{animation:ovmPulse 1.6s ease-in-out infinite}
+.ov-mark[data-state=live] .rp{animation:ovmRipple 1.6s ease-out infinite}
+.ov-mark[data-state=live] .rp2{animation-delay:.8s}
+.ov-mark[data-state=sleep] svg{filter:grayscale(1);opacity:.7}
+.ov-mark[data-state=sleep] .og{transform:scaleY(.14);transition:transform .52s ease-in-out}
+.ov-mark[data-state=sleep] .vg{transform:translateY(4px);transition:transform .52s ease-in-out}
+.ov-mark[data-state=sleep] .va,.ov-mark[data-state=sleep] .vb{opacity:.5}
+/* Per-site touches: one family, each site with its own light in the O and its own pace. */
+.ov-mark[data-variant=live] .cd,.ov-mark[data-variant=vip] .cd,.ov-mark[data-variant=tips] .cd,.ov-mark[data-variant=deals] .cd,.ov-mark[data-variant=coupons] .cd{display:inline}
+.ov-mark[data-variant=live][data-ovm-run]:not([data-state]) .cd{animation:ovmOnAir 1.4s ease-in-out infinite}
+.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=games][data-state=busy] .vg,.ov-mark[data-variant=games][data-state=busy] .og{animation-duration:.6s}
+.ov-mark[data-variant=chat][data-state=busy] .vg,.ov-mark[data-variant=chat][data-state=busy] .og{animation-duration:.55s}
+.ov-mark[data-variant=codes][data-state=busy] .vg{animation-timing-function:steps(4,end)}
+.ov-mark[data-variant=blog][data-state=busy] .vg,.ov-mark[data-variant=wiki][data-state=busy] .vg,.ov-mark[data-variant=news][data-state=busy] .vg,.ov-mark[data-variant=blog][data-state=busy] .og,.ov-mark[data-variant=wiki][data-state=busy] .og,.ov-mark[data-variant=news][data-state=busy] .og{animation-duration:1.2s}
+@keyframes ovmRise{from{transform:scale(.55);opacity:0}}
+@keyframes ovmDraw{from{stroke-dasharray:100 100;stroke-dashoffset:100}to{stroke-dasharray:100 100;stroke-dashoffset:0}}
+@keyframes ovmDrop{from{transform:translateY(-18px) scale(.5);opacity:0}}
+@keyframes ovmGlint{from{transform:translateX(-80px) skewX(-20deg)}to{transform:translateX(80px) skewX(-20deg)}}
+@keyframes ovmLean{0%,100%{transform:rotate(0)}45%{transform:rotate(-11deg)}}
+@keyframes ovmHopO{0%,100%{transform:translateY(0)}45%{transform:translateY(-3px)}}
+@keyframes ovmNod{0%,100%{transform:rotate(0)}50%{transform:rotate(-7deg)}}
+@keyframes ovmHop{0%,100%{transform:translateY(0) scaleY(1)}35%{transform:translateY(-9px) scaleY(1.04)}70%{transform:translateY(0) scaleY(.96)}}
+@keyframes ovmBob{0%,100%{transform:translateY(0)}50%{transform:translateY(6px)}}
+@keyframes ovmBreathe{0%,100%{transform:scale(1)}50%{transform:scale(.88)}}
+@keyframes ovmPulse{0%,100%{transform:scale(1)}50%{transform:scale(1.06)}}
+@keyframes ovmShake{0%,100%{transform:translateX(0)}15%{transform:translateX(-7px)}30%{transform:translateX(7px)}45%{transform:translateX(-6px)}60%{transform:translateX(6px)}80%{transform:translateX(-3px)}}
+@keyframes ovmNo{0%,100%{transform:translateX(0)}20%{transform:translateX(-4px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)}}
+@keyframes ovmBounce{0%,80%,100%{transform:translateY(0)}20%{transform:translateY(-6px)}40%{transform:translateY(0)}60%{transform:translateY(-4px)}}
+@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.8}50%{transform:scale(1.3);opacity:1}}
+@keyframes ovmRipple{0%{transform:scale(1);opacity:.7}100%{transform:scale(1.9);opacity:0}}
+.ov-mark[data-static] *{animation:none!important}
+.ov-mark[data-ov]:not([data-ovm-run]) *{animation-play-state:paused!important}
.ov-mark[data-ovm-flourish] .sl{animation:ovmWiggle 1.2s ease-in-out 1}
.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] .sd{animation:ovmTwinkle 1.4s ease-in-out 1}
-.ov-mark[data-ovm-flourish] .sw{animation:ovmSweep .9s linear 1;opacity:.9}
+.ov-mark[data-ovm-flourish] .vg{animation:ovmLean .7s ease-out 1}
.ovm-fall{position:fixed;width:0;height:0;pointer-events:none;z-index:10010}
.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}
.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%}
html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:50%}
@@ -94,22 +186,59 @@ html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:
@@ -94,22 +186,59 @@ html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:
@keyframes ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}}
@keyframes ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}}
@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}
@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))}}
@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>`;
+@media (prefers-reduced-motion:reduce){.ov-mark *{animation:none!important}.ov-mark,.ov-mark *,.ov-mark::before{transition:none!important}.ovm-fall{display:none}}
+html[data-ov-motion=reduced] .ov-mark *{animation:none!important}
+.ov-busy{cursor:progress;pointer-events:none}
+.ov-busy-mark{display:inline-block;vertical-align:-.2em;margin-right:.45em;flex:none}
+`;
+ const seg = (p, i) => `M${f(p[i])} ${f(p[i + 1])}L${f(p[i + 2])} ${f(p[i + 3])}`;
+ function svg(id, G, wide) {
+ const p = G.shapes.v, oc = `transform-origin:${G.cx}px ${G.cy}px`, vc = `transform-origin:${G.T[0]}px ${G.T[1]}px`;
+ const ri = G.r - G.sw / 2, top = G.cy - G.R;
+ // The season: a pumpkin's stem and leaf on the O; snow on the O and on the V's caps.
+ const stem = `M${f(G.cx + 0.6)} ${f(top + 2.5)}c.3-4.6 1.6-8 5-10.6`, leaf = `M${f(G.cx + 5.2)} ${f(top - 7.4)}c4.4-4.8 11-5.2 15.8-2.4-3.6 5.2-10.4 6.8-15.8 2.4z`;
+ const capR = f(G.r + G.sw / 2 - 1.6), at = (deg) => [f(G.cx + capR * Math.cos(deg * Math.PI / 180)), f(G.cy + capR * Math.sin(deg * Math.PI / 180))];
+ const c0 = at(-150), c1 = at(-40);
+ return `<svg viewBox="${wide ? G.wideBox : G.box}" aria-hidden="true"><defs>
+<mask id="${id}h" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/><g class="vg" style="${vc}"><path class="ha" d="${seg(p, 0)}" stroke-width="${G.halo}"/><path class="hb" d="${seg(p, 4)}" stroke-width="${G.halo}"/></g></mask>
+<linearGradient id="${id}go" gradientUnits="userSpaceOnUse" x1="${f(G.cx - G.R)}" y1="${f(G.cy - G.R)}" x2="${f(G.cx + G.R)}" y2="${f(G.cy + G.R)}"><stop class="g1" offset="0"/><stop class="g2" offset="1"/></linearGradient>
+<linearGradient id="${id}gv" gradientUnits="userSpaceOnUse" x1="0" y1="${G.A[1]}" x2="0" y2="${G.T[1]}"><stop class="g3" offset="0"/><stop class="g4" offset="1"/></linearGradient>
+<linearGradient id="${id}gg"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".8"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>
+<mask id="${id}s" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><g mask="url(#${id}h)"><g class="og" style="${oc}"><path d="${G.ring}" fill="none" stroke="#fff" stroke-width="${G.sw}"/></g></g><g class="vg" style="${vc}"><path class="ma" d="${seg(p, 0)}" fill="none" stroke="#fff" stroke-linecap="round" stroke-width="${G.vsw}"/><path class="mb" d="${seg(p, 4)}" fill="none" stroke="#fff" stroke-linecap="round" stroke-width="${G.vsw}"/></g></mask></defs>
+<g class="m"><circle class="rp" cx="${G.cx}" cy="${G.cy}" r="${G.R}"/><circle class="rp rp2" cx="${G.cx}" cy="${G.cy}" r="${G.R}"/>
+<g mask="url(#${id}h)"><g class="og" style="${oc}"><path class="ot" d="${G.ring}" stroke-width="${G.sw}"/><path class="o" d="${G.ring}" pathLength="100" stroke-width="${G.sw}" style="stroke:url(#${id}go)"/><circle class="cd" cx="${G.cx}" cy="${G.cy}" r="${f(ri * 0.62)}"/>
+<g class="sw2"><path class="sc" d="M${c0[0]} ${c0[1]}A${capR} ${capR} 0 0 1 ${c1[0]} ${c1[1]}"/></g></g></g>
+<g class="vg" style="${vc}"><path class="va" d="${seg(p, 0)}" stroke-width="${G.vsw}" style="stroke:url(#${id}gv)"/><path class="vb" d="${seg(p, 4)}" stroke-width="${G.vsw}" style="stroke:url(#${id}gv)"/>
+<g class="sw2"><circle class="sd" cx="${G.A[0]}" cy="${f(G.A[1] - G.vsw * 0.18)}" r="${f(G.vsw * 0.36)}"/><circle class="sd" cx="${G.B[0]}" cy="${f(G.B[1] - G.vsw * 0.18)}" r="${f(G.vsw * 0.36)}"/></g></g>
+<g mask="url(#${id}s)"><rect class="gr" x="38" y="-10" width="24" height="120" fill="url(#${id}gg)"/></g>
+<circle class="nd" cx="${f(G.B[0] + 5.5)}" cy="${f(G.B[1] - 9)}" r="${f(G.vsw * 0.55)}"/>
+<g class="sh"><path class="ss" d="${stem}"/><path class="sl" d="${leaf}"/></g>
+<g class="sw2"><circle class="sd" cx="${f(G.cx - 12)}" cy="${f(top - 6)}" r="1.8"/><circle class="sd" cx="${f(G.B[0] - 6)}" cy="${f(G.B[1] - 12)}" r="1.5"/></g></g></svg>`;
}
}
+
+ // ── The V's shapes: a state turns it into a tick, a cross or "!" and back, the gap in the ring following it ──
+ const SHAPE_OF = { ok: 'ok', error: 'err', no: 'no' };
+ const backOut = (p) => { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(p - 1, 3) + c1 * Math.pow(p - 1, 2); };
+ function drawV(q, p) {
+ q.pts = p;
+ const a = seg(p, 0), b = seg(p, 4);
+ q.va.setAttribute('d', a); q.ha.setAttribute('d', a); q.vb.setAttribute('d', b); q.hb.setAttribute('d', b);
+ if (q.ma) { q.ma.setAttribute('d', a); q.mb.setAttribute('d', b); }
+ }
+ function shape(el, animate) {
+ const q = el.__ovm; if (!q) return;
+ const name = SHAPE_OF[el.getAttribute('data-state')] || 'v';
+ if (q.name === name) return;
+ q.name = name;
+ const to = FORMS[q.form].shapes[name], from = q.pts.slice();
+ const raf = typeof window.requestAnimationFrame === 'function' ? window.requestAnimationFrame.bind(window) : null;
+ if (q.raf && window.cancelAnimationFrame) window.cancelAnimationFrame(q.raf);
+ if (!animate || !raf) { drawV(q, to); return; }
+ const t0 = Date.now(), ms = 420;
+ const step = () => { const t = Math.min(1, (Date.now() - t0) / ms), e = backOut(t); drawV(q, from.map((v, i) => v + (to[i] - v) * e)); q.raf = t < 1 ? raf(step) : 0; };
+ q.raf = raf(step);
+ }
+
const INTRO_MS = 8000, LINGER_MS = 1200;
const INTRO_MS = 8000, LINGER_MS = 1200;
const state = new WeakMap(); // mark → { introEnd, lingerEnd, hover, focus, onScreen, timer }
const state = new WeakMap(); // mark → { introEnd, lingerEnd, hover, focus, onScreen, timer }
let io = null, rm = null;
let io = null, rm = null;
@@ -133,16 +262,15 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
@@ -133,16 +262,15 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
const may = !reduced() && !el.hasAttribute('data-static') && !document.hidden && st.onScreen === true;
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); }
if (may && !st.introEnd) { st.introEnd = introDeadline(now); if (season()) setTimeout(() => fallOnce(el), 900); }
const s = el.getAttribute('data-state');
const s = el.getAttribute('data-state');
- const run = may && (now < st.introEnd || st.hover || st.focus || now < st.lingerEnd || s === 'busy' || s === 'ok');
+ // Anything the mark is showing keeps it moving: a state, a page on its way, a known progress.
+ const showing = (s && s !== 'idle') || el.hasAttribute('data-nav') || el.hasAttribute('data-progress');
+ const intro = now < st.introEnd;
+ const run = may && (intro || st.hover || st.focus || now < st.lingerEnd || showing);
if (run !== el.hasAttribute('data-ovm-run')) { if (run) el.setAttribute('data-ovm-run', ''); else el.removeAttribute('data-ovm-run'); }
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 */ }
+ // The intro's pop and growth are on only inside the intro (paused with the rest while the mark cannot move).
+ if ((may && intro) !== el.hasAttribute('data-ovm-intro') && (may || !intro)) { if (may && intro) el.setAttribute('data-ovm-intro', ''); else el.removeAttribute('data-ovm-intro'); }
+ shape(el, run);
+ try { if (run) svgEl.unpauseAnimations(); else svgEl.pauseAnimations(); } catch { /* no SMIL */ }
clearTimeout(st.timer); st.timer = 0;
clearTimeout(st.timer); st.timer = 0;
const next = Math.min(...[st.introEnd, st.lingerEnd].filter((t) => t > now));
const next = Math.min(...[st.introEnd, st.lingerEnd].filter((t) => t > now));
if (run && next !== Infinity) st.timer = setTimeout(() => update(el), next - now + 20);
if (run && next !== Infinity) st.timer = setTimeout(() => update(el), next - now + 20);
@@ -220,8 +348,17 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
@@ -220,8 +348,17 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
if (!el.hasAttribute('data-ov')) {
if (!el.hasAttribute('data-ov')) {
el.setAttribute('data-ov', '1');
el.setAttribute('data-ov', '1');
const size = parseInt(el.getAttribute('data-size'), 10);
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 (size) { el.style.width = size + 'px'; if (!el.hasAttribute('data-wide')) el.style.height = size + 'px'; }
+ // The form follows the size unless the page names one: under 22px the pair's gap closes, the heavy holds.
+ if (!el.hasAttribute('data-form')) {
+ let px = size;
+ if (!px) { try { px = Math.round(el.getBoundingClientRect().width); } catch { px = 0; } }
+ el.setAttribute('data-form', px && px <= 22 ? 'heavy' : 'pair');
+ }
+ const form = formOf(el.getAttribute('data-form'));
+ el.innerHTML = svg('ovm' + (++n) + '_', FORMS[form], el.hasAttribute('data-wide'));
+ const pick = (c) => el.querySelector(c);
+ el.__ovm = { form, name: 'v', pts: FORMS[form].shapes.v.slice(), raf: 0, va: pick('.va'), vb: pick('.vb'), ha: pick('.ha'), hb: pick('.hb'), ma: pick('.ma'), mb: pick('.mb') };
}
}
if (!state.has(el) && el.isConnected) track(el);
if (!state.has(el) && el.isConnected) track(el);
});
});
@@ -237,6 +374,7 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
@@ -237,6 +374,7 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
try { rm = window.matchMedia('(prefers-reduced-motion: reduce)'); rm.addEventListener('change', updateAll); } catch { rm = null; }
try { rm = window.matchMedia('(prefers-reduced-motion: reduce)'); rm.addEventListener('change', updateAll); } catch { rm = null; }
document.addEventListener('visibilitychange', updateAll);
document.addEventListener('visibilitychange', updateAll);
mount();
mount();
+ hookNav();
scheduleFlourish();
scheduleFlourish();
try {
try {
new MutationObserver((records) => {
new MutationObserver((records) => {
@@ -251,9 +389,154 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
@@ -251,9 +389,154 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
});
});
}
}
if (added) mount();
if (added) mount();
- }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-ov-motion', 'data-ov-season-mark'] });
+ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-nav', 'data-progress', 'data-ov-motion', 'data-ov-season-mark'] });
} catch { /* */ }
} catch { /* */ }
}
}
+ // ── A page on its way: the navbar's mark draws its ring round as the next page loads ──
+ // The pace is learned: the usual time of this site's boost moves and full page loads in this browser (a running
+ // average in localStorage). The ring reaches about four fifths at the usual time and never closes before the page
+ // is really here; then it closes, the V hops, and the track fades on the new page.
+ const loc = () => window.location || { host: '', href: 'http://localhost/', origin: 'http://localhost', pathname: '/', search: '' };
+ const NAV_KEY = 'ov-nav-ms', NAV_FLAG = 'ov-nav-click', NAV_DEFAULT = { boost: 450, page: 1600 };
+ const navMarks = () => [...document.querySelectorAll('.openvibe-navbar-brand .ov-mark, .navbar .nav-brand .ov-mark, .ov-mark[data-ov-nav]')].filter((m) => !m.closest('.openvibe-network-badge'));
+ const navStore = () => { try { return JSON.parse(window.localStorage.getItem(NAV_KEY) || '{}') || {}; } catch { return {}; } };
+ function usual(kind) { const v = Number(navStore()[`${loc().host}:${kind}`]); return v > 0 ? v : NAV_DEFAULT[kind] || 1000; }
+ function learn(kind, ms) {
+ if (!(ms > 30 && ms < 30000)) return;
+ try {
+ const all = navStore(), k = `${loc().host}:${kind}`;
+ all[k] = Math.round(all[k] > 0 ? all[k] * 0.7 + ms * 0.3 : ms);
+ window.localStorage.setItem(NAV_KEY, JSON.stringify(all));
+ } catch { /* no storage: the default pace */ }
+ }
+ let nav = null;
+ function setFill(marks, p) { marks.forEach((m) => { m.setAttribute('data-nav', ''); m.style.setProperty('--ovm-fill', String(p)); }); }
+ function navStart(kind = 'page') {
+ navStop();
+ const marks = navMarks(); if (!marks.length) return;
+ marks.forEach((m) => m.removeAttribute('data-ovm-arrive'));
+ const t0 = Date.now(), avg = usual(kind);
+ const me = { kind, t0, timer: 0 };
+ const tick = () => {
+ if (nav !== me) return;
+ const t = Date.now() - t0;
+ setFill(navMarks(), Math.min(0.94, 0.08 + 0.86 * (1 - Math.exp(-1.6 * t / avg))).toFixed(3));
+ me.timer = setTimeout(tick, 120);
+ };
+ nav = me;
+ tick();
+ }
+ function navStop() { if (nav) { clearTimeout(nav.timer); nav = null; } }
+ /** The page is here: the ring closes and the V hops. A navbar re-rendered by the move gets the same ending. */
+ // The ending runs after every other listener of the arrival (the navbar re-renders on it), and no timer keeps a mark:
+ // each step looks the marks up again, so a replaced navbar is never held in memory.
+ const clearNav = () => { if (!nav) navMarks().forEach((m) => { m.removeAttribute('data-nav'); m.removeAttribute('data-ovm-arrive'); m.style.removeProperty('--ovm-fill'); }); };
+ function navEnd(kind) {
+ const was = nav; navStop();
+ if (was && kind) learn(kind, Date.now() - was.t0);
+ setTimeout(() => {
+ if (nav) return; mount();
+ const marks = navMarks(); setFill(marks, 1); marks.forEach((m) => m.setAttribute('data-ovm-arrive', ''));
+ setTimeout(clearNav, 520);
+ }, 0);
+ }
+ function hookNav() {
+ document.addEventListener('ov:boost:before', () => navStart('boost'));
+ document.addEventListener('ov:boost:load', () => navEnd('boost'));
+ // A full page load: a plain click on a link that leaves this page, in this tab, not taken by boost or the app.
+ document.addEventListener('click', (e) => {
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+ const a = e.target && e.target.closest && e.target.closest('a[href]');
+ if (!a || a.hasAttribute('download') || (a.target && a.target !== '_self')) return;
+ const here = loc(); let u; try { u = new URL(a.href, here.href); } catch { return; }
+ if (!/^https?:$/.test(u.protocol) || (u.origin === here.origin && u.pathname === here.pathname && u.search === here.search)) return;
+ navStart('page');
+ try { if (u.origin === here.origin) sessionStorage.setItem(NAV_FLAG, String(Date.now())); } catch { /* */ }
+ });
+ // Back to a page from the back/forward cache: nothing is loading any more.
+ window.addEventListener('pageshow', (e) => { if (e.persisted) { navStop(); clearNav(); } });
+ // This page came from a click on the last one: learn how long it took, and finish the ring here.
+ let t = 0; try { t = Number(sessionStorage.getItem(NAV_FLAG)) || 0; sessionStorage.removeItem(NAV_FLAG); } catch { /* */ }
+ if (t && Date.now() - t < 20000) {
+ learn('page', Date.now() - t);
+ const finish = () => { const marks = navMarks(); if (!marks.length) return false; setFill(marks, 0.9); setTimeout(() => navEnd(null), 60); return true; };
+ let tries = 0; const again = () => { if (!finish() && ++tries <= 20) setTimeout(again, 100); }; again();
+ }
+ }
+ /** Replay the intro on one mark (a brand page, a splash): the O draws itself round, the V drops in, a glint crosses. */
+ function intro(el) {
+ const st = el && state.get(el); if (!st) return;
+ el.removeAttribute('data-ovm-intro');
+ try { void el.getBoundingClientRect(); } catch { /* */ }
+ st.introEnd = Date.now() + 2000; update(el);
+ }
+ // ── Busy buttons (the plan: "buttons' busy, then done or failed, from the mark's states", 2026-11-01) ──
+ // A button that starts work shows the mark at work beside its label, keeps its width and takes no second press.
+ // Script work ends it with done (the tick, and "Done" or a word) or fail (the "!", and a word), then the label
+ // comes back; a form posted the old way stays busy until the next page arrives. The button is never disabled:
+ // a disabled submitter's name and value would drop out of the form it posts.
+ // OVMark.busy(button) → { done(text?), fail(text?), reset() }
+ const BUSY = new Map();
+ function busy(btn) {
+ if (!btn || !btn.isConnected) return null;
+ if (BUSY.has(btn)) return BUSY.get(btn);
+ const isInput = btn.tagName === 'INPUT';
+ const before = isInput ? btn.value : btn.innerHTML;
+ const width = btn.offsetWidth;
+ if (width) btn.style.minWidth = width + 'px';
+ btn.setAttribute('aria-busy', 'true');
+ btn.classList.add('ov-busy');
+ let mark = null;
+ if (!isInput) {
+ mark = document.createElement('span');
+ mark.className = 'ov-mark ov-busy-mark';
+ mark.setAttribute('data-size', '18'); mark.setAttribute('data-tone', 'ink'); mark.setAttribute('data-state', 'busy'); mark.setAttribute('aria-hidden', 'true');
+ btn.insertBefore(mark, btn.firstChild);
+ mount(btn);
+ }
+ let timer = 0;
+ const reset = () => {
+ clearTimeout(timer);
+ if (!BUSY.has(btn)) return;
+ BUSY.delete(btn);
+ if (isInput) btn.value = before; else btn.innerHTML = before;
+ btn.style.minWidth = '';
+ btn.removeAttribute('aria-busy');
+ btn.classList.remove('ov-busy');
+ const form = btn.form || (btn.closest && btn.closest('form'));
+ if (form) delete form.__ovBusy;
+ };
+ const end = (st, text, ms) => {
+ if (!BUSY.has(btn)) return;
+ if (mark) {
+ mark.setAttribute('data-state', st);
+ // The label after the mark says what happened, in the button's own place.
+ while (mark.nextSibling) btn.removeChild(mark.nextSibling);
+ btn.appendChild(document.createTextNode(text));
+ } else btn.value = text;
+ btn.setAttribute('aria-busy', 'false');
+ clearTimeout(timer); timer = setTimeout(reset, ms);
+ };
+ const api = { done: (text) => end('ok', text || 'Done', 1400), fail: (text) => end('error', text || 'Failed', 2600), reset };
+ BUSY.set(btn, api);
+ return api;
+ }
+ // A form posted the old way (POST, no script of the page's own took the submit): its button shows the mark at work
+ // until the next page arrives, and a second submit does nothing. data-ov-busy="off" on a form opts out.
+ document.addEventListener('submit', (e) => {
+ const form = e.target;
+ if (!form || form.tagName !== 'FORM' || form.getAttribute('data-ov-busy') === 'off') return;
+ if (String(form.getAttribute('method') || 'get').toLowerCase() !== 'post') return;
+ if (form.__ovBusy) { e.preventDefault(); return; }
+ if (e.defaultPrevented) return;
+ const btn = e.submitter || form.querySelector('button[type=submit],button:not([type]),input[type=submit]');
+ if (!btn) return;
+ form.__ovBusy = true;
+ busy(btn);
+ });
+ // Back to a page from the back/forward cache: nothing is busy any more.
+ window.addEventListener('pageshow', (e) => { if (e.persisted) [...BUSY.values()].forEach((b) => b.reset()); });
+ window.OVMark = { mount, navStart, navEnd, usual, intro, busy, FORMS };
window.ovMarkMount = mount;
window.ovMarkMount = mount;
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
})();
})();
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-extensions",
"name": "openvibe-extensions",
- "version": "0.8.1",
+ "version": "0.9.0",
"private": true,
"private": true,
"description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.",
"description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.",
"scripts": {
"scripts": {
@@ -9,16 +9,20 @@
@@ -9,16 +9,20 @@
* Reviews": activeTab is the only host access; Firefox's sidebar_action, the
* Reviews": activeTab is the only host access; Firefox's sidebar_action, the
* side panel there, needs no permission of its own; identity starts the Dock's one-click
* side panel there, needs no permission of its own; identity starts the Dock's one-click
* OAuth sign-in)
* OAuth sign-in)
- * 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)
+ * host_permissions exactly the API origin, the Network origin, the Chat origin and the Codes
+ * origin, all https://openvibe.<tld> sites (the coupon API, the Dock's state,
+ * the Dock's Chats tab, and the Dock's agent view)
* allowed key key (Chrome's public key that fixes the extension id; dropped from the Firefox zip)
* allowed key key (Chrome's public key that fixes the extension id; dropped from the Firefox zip)
* 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,
* omnibox, … (anything not on the allow-list of keys; the side panel's own keys,
* omnibox, … (anything not on the allow-list of keys; the side panel's own keys,
* side_panel and sidebar_action, are on it)
* side_panel and sidebar_action, are on it)
- * CSP script-src 'self' only, connect-src only the OpenVibe origins, no unsafe-*
+ * CSP script-src 'self' only; style-src 'self' 'unsafe-inline' (the Dock draws its
+ * own styles in a <style> element); img-src 'self' blob: (the agent view's
+ * still is a local object URL); connect-src only the OpenVibe origins;
+ * frame-src only Live's agent embed; no other unsafe-*, wildcard, data: or
+ * remote http
* pages popup/options/panel HTML load only local scripts, no inline script
* 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
@@ -67,7 +71,16 @@ function auditManifest(m, { apiOrigin, origins } = {}) {
@@ -67,7 +71,16 @@ function auditManifest(m, { apiOrigin, origins } = {}) {
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['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 (JSON.stringify(dirs['style-src']) !== JSON.stringify(["'self'", "'unsafe-inline'"])) problems.push("CSP style-src must be exactly 'self' 'unsafe-inline' (the Dock's own <style>)");
+ if (JSON.stringify(dirs['img-src']) !== JSON.stringify(["'self'", 'blob:'])) problems.push("CSP img-src must be exactly 'self' blob: (the agent view's still)");
+ if (JSON.stringify(dirs['frame-src']) !== JSON.stringify(['https://openvibe.live'])) problems.push('CSP frame-src must be exactly https://openvibe.live (the agent embed)');
+ for (const [name, values] of Object.entries(dirs)) {
+ if (name === 'style-src') continue;
+ for (const v of values) {
+ const devLocal = /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(v);
+ if (/unsafe-|\*|data:/.test(v) || (v === 'blob:' && name !== 'img-src') || (/^http:/.test(v) && !devLocal)) problems.push(`CSP ${name} must not allow ${v}`);
+ }
+ }
}
}
const gecko = m.browser_specific_settings && m.browser_specific_settings.gecko;
const gecko = m.browser_specific_settings && m.browser_specific_settings.gecko;
if (!gecko || !gecko.id) problems.push('browser_specific_settings.gecko.id is required (Firefox)');
if (!gecko || !gecko.id) problems.push('browser_specific_settings.gecko.id is required (Firefox)');
@@ -94,13 +107,14 @@ function auditDir(dir) {
@@ -94,13 +107,14 @@ function auditDir(dir) {
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 network = (configSrc.match(/NETWORK_ORIGIN:\s*'([^']+)'/) || [])[1];
const chat = (configSrc.match(/CHAT_ORIGIN:\s*'([^']+)'/) || [])[1];
const chat = (configSrc.match(/CHAT_ORIGIN:\s*'([^']+)'/) || [])[1];
+ const codes = (configSrc.match(/CODES_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`);
- 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`);
+ const origins = [origin, network, chat, codes].filter(Boolean);
+ for (const o of [network, chat, codes].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 }));
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));
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 };
return { manifest, origin, origins, problems };
new file mode 100644
new file mode 100644
@@ -0,0 +1,222 @@
@@ -0,0 +1,222 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * The side panel in a real browser: headless Chrome (CDP over Node's own WebSocket, no dependencies) loads the
+ * unpacked extension, opens panel.html with a stubbed Network/Chat/Codes and a live agent view, and checks what a
+ * person would see: the Dock on its Agents tab, the agent's stream frame, the line and the meta, the four controls
+ * (Pause/Resume, Curtain now, Mute/Unmute, End), and the Dock's own styles and nothing a CSP refused.
+ *
+ * node scripts/check-panel.js (CHROME=/path/to/chrome to choose the browser; OV_EXT_DIR=an unpacked build, default browser/)
+ *
+ * test/panel.test.js runs it when a Chrome is installed and says so when it is not.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawn } = require('child_process');
+
+const CHROME = process.env.CHROME || ['/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser'].find((p) => fs.existsSync(p));
+const ROOT = path.join(__dirname, '..');
+const EXT_DIR = process.env.OV_EXT_DIR || path.join(ROOT, 'browser');
+const MANIFEST = JSON.parse(fs.readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf8'));
+
+/** Chrome's extension id from the public key in the manifest (the same rule test/manifest.test.js checks). */
+function extensionId(key) {
+ const hash = crypto.createHash('sha256').update(Buffer.from(key, 'base64')).digest();
+ let id = '';
+ for (let i = 0; i < 16; i++) id += String.fromCharCode(97 + (hash[i] >> 4)) + String.fromCharCode(97 + (hash[i] & 15));
+ return id;
+}
+const EXT_ID = extensionId(MANIFEST.key);
+
+const TOKEN = 'test.' + Buffer.from(JSON.stringify({ sub: 'usr_test', username: 'ada' })).toString('base64url') + '.signature';
+const AGENT_ID = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const AGENT = {
+ paused: true, paused_by: 'ada', paused_by_role: 'owner', mode: 'watch',
+ id: AGENT_ID, state: 'live', title: 'Ship the Dock',
+ task: { title: 'Ship the Dock', url: 'https://openvibe.codes/tasks/1' },
+ curtain: { up: false }, viewers: 2, worked_seconds: 754, visibility: 'private',
+ started_at: new Date(Date.now() - 754000).toISOString(), ended_at: null,
+ page_url: `https://openvibe.live/@ada/agents/${AGENT_ID}`,
+ embed_url: `https://openvibe.live/embed/agent/${AGENT_ID}?vt=wvt_test_ticket`,
+ last_frame_url: null, voice_muted: true,
+};
+const DOCK = {
+ state: {
+ open: true, tab: 'agent', pinned: true, agent: AGENT_ID, chat: null,
+ place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } },
+ updated_at: new Date().toISOString(),
+ },
+ counts: { running: 0, waiting: 0, unread: 0 },
+};
+
+/** A CDP client over Node's WebSocket: requests by id, and events to listeners. */
+function cdp(url) {
+ const ws = new WebSocket(url);
+ let id = 0;
+ const waiting = new Map();
+ const events = new Map();
+ ws.addEventListener('message', (ev) => {
+ const m = JSON.parse(ev.data);
+ if (m.id && waiting.has(m.id)) {
+ const w = waiting.get(m.id); waiting.delete(m.id);
+ if (m.error) w.reject(new Error(m.error.message)); else w.resolve(m.result);
+ } else if (m.method) {
+ for (const fn of events.get(m.method) || []) fn(m.params);
+ }
+ });
+ const ready = new Promise((resolve, reject) => { ws.addEventListener('open', resolve); ws.addEventListener('error', reject); });
+ return {
+ ready,
+ send(method, params = {}) { return new Promise((resolve, reject) => { const n = ++id; waiting.set(n, { resolve, reject }); ws.send(JSON.stringify({ id: n, method, params })); }); },
+ on(method, fn) { if (!events.has(method)) events.set(method, []); events.get(method).push(fn); },
+ close() { ws.close(); },
+ };
+}
+
+async function evaluate(c, expression) {
+ const r = await c.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
+ if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception ? r.exceptionDetails.exception.description : r.exceptionDetails.text);
+ return r.result.value;
+}
+
+const pause = (ms) => new Promise((r) => setTimeout(r, ms));
+const json = (v) => ({ status: 200, type: 'application/json', body: JSON.stringify(v) });
+
+/** What the stubbed first parties answer. A route this file does not know gets an empty object, never a refusal. */
+function answer(url, method) {
+ const u = new URL(url);
+ const p = u.pathname;
+ if (method === 'OPTIONS') return { status: 204, type: 'text/plain', body: '' };
+ if (u.origin === 'https://openvibe.live') {
+ return { status: 200, type: 'text/html', body: '<!doctype html><html><body><p id="stub-embed">embed</p></body></html>' };
+ }
+ if (u.origin === 'https://openvibe.network' && p === '/api/v1/me/dock') return json(DOCK);
+ if (u.origin === 'https://openvibe.network' && p === '/api/v1/me/actions') return json({ items: [], counts: {} });
+ if (u.origin === 'https://openvibe.codes' && p === '/api/v1/me/agent-view') return json({ agent: AGENT });
+ return json({});
+}
+
+async function main() {
+ if (!CHROME) return { skipped: 'no Chrome or Chromium installed (set CHROME)' };
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-panel-'));
+ const proc = spawn(CHROME, ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--no-sandbox',
+ '--enable-unsafe-extension-debugging', `--user-data-dir=${dir}`, '--remote-debugging-port=0', 'about:blank'], { stdio: 'ignore' });
+ let page = null;
+ try {
+ let port = null;
+ for (let i = 0; i < 100 && !port; i++) {
+ await pause(100);
+ try { port = fs.readFileSync(path.join(dir, 'DevToolsActivePort'), 'utf8').split('\n')[0]; } catch { /* not yet */ }
+ }
+ if (!port) throw new Error('Chrome did not start');
+ const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
+ const target = targets.find((t) => t.type === 'page');
+ if (!target) throw new Error('Chrome has no page target');
+ const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
+ const browser = cdp(version.webSocketDebuggerUrl);
+ await browser.ready;
+ let loaded;
+ try {
+ loaded = await browser.send('Extensions.loadUnpacked', { path: EXT_DIR });
+ } catch (err) {
+ // An older Chrome has no Extensions domain; that is this browser's limit, not the panel's.
+ if (/method|not available|not found|unsupported/i.test(String(err && err.message))) {
+ browser.close();
+ return { skipped: `this Chrome cannot load an unpacked extension over CDP (${err.message})` };
+ }
+ throw err;
+ }
+ if (loaded.id !== EXT_ID) throw new Error(`Chrome loaded ${loaded.id}, the manifest key says ${EXT_ID}`);
+ browser.close();
+
+ page = cdp(target.webSocketDebuggerUrl);
+ await page.ready;
+ await page.send('Page.enable');
+ await page.send('Runtime.enable');
+ await page.send('Log.enable');
+ const security = [];
+ page.on('Log.entryAdded', ({ entry }) => { if (entry && entry.source === 'security') security.push(entry.text); });
+ const paused = [];
+ page.on('Fetch.requestPaused', (p) => { paused.push(p.request.url); void (async () => {
+ const a = answer(p.request.url, p.request.method);
+ const headers = [
+ { name: 'Access-Control-Allow-Origin', value: '*' },
+ { name: 'Access-Control-Allow-Headers', value: 'authorization, content-type' },
+ { name: 'Access-Control-Allow-Methods', value: 'GET, POST, OPTIONS' },
+ { name: 'Content-Type', value: a.type },
+ ];
+ const params = { requestId: p.requestId, responseCode: a.status, responseHeaders: headers };
+ if (a.status !== 204) params.body = Buffer.from(a.body).toString('base64');
+ await page.send('Fetch.fulfillRequest', params);
+ })().catch(() => { /* the page went away */ }); });
+ await page.send('Fetch.enable', { patterns: [
+ { urlPattern: 'https://openvibe.network/*' }, { urlPattern: 'https://openvibe.chat/*' },
+ { urlPattern: 'https://openvibe.codes/*' }, { urlPattern: 'https://openvibe.live/*' },
+ ] });
+
+ await page.send('Page.navigate', { url: `chrome-extension://${EXT_ID}/panel.html` });
+ for (let i = 0; i < 100 && (await evaluate(page, 'document.readyState')) !== 'complete'; i++) await pause(50);
+ await evaluate(page, `chrome.storage.session.set(${JSON.stringify({ dockAccess: { token: TOKEN, expires_at: Date.now() + 3600000, scope: 'dock activity chat.read', username: 'ada' } })}).then(() => true)`);
+ await evaluate(page, `chrome.storage.local.set(${JSON.stringify({ dockAccount: { username: 'ada', sub: 'usr_test' } })}).then(() => true)`);
+ await page.send('Page.reload');
+
+ let seen = null;
+ for (let i = 0; i < 100; i++) {
+ await pause(100);
+ seen = await evaluate(page, `(() => {
+ const q = (s) => document.querySelector(s);
+ const text = (n) => n ? n.textContent.trim() : null;
+ return {
+ gateHidden: q('#gate') ? q('#gate').hidden : null,
+ dock: !!q('.ovdk'),
+ agent: !!q('.ovda.on'),
+ line: text(q('.ovda-line')),
+ meta: text(q('.ovda-meta')),
+ acts: [...document.querySelectorAll('.ovda-acts [data-act]')].map((b) => b.dataset.act + ':' + b.textContent.trim()),
+ frame: (q('iframe.ovda-frame') || {}).src || null,
+ tab: text(q('[role="tab"][aria-selected="true"]')),
+ styles: !!document.getElementById('ovdk-styles') && !!document.getElementById('ovda-styles') && !!document.getElementById('ovac-styles'),
+ lineDisplay: q('.ovda-line') ? getComputedStyle(q('.ovda-line')).display : null,
+ };
+ })()`);
+ if (seen && seen.agent) break;
+ }
+
+ for (let i = 0; i < 20 && !(paused.some((u) => u.startsWith('https://openvibe.live/embed/agent/')) && paused.some((u) => u.startsWith('https://openvibe.codes/api/v1/me/agent-view'))); i++) await pause(100);
+
+ const failures = [];
+ if (!seen || !seen.dock) failures.push('the panel did not mount the Dock');
+ if (!seen || !seen.gateHidden) failures.push('the sign-in gate stayed up with a signed-in Dock');
+ if (!seen || !seen.agent) failures.push('the Agents tab did not draw the agent view');
+ if (seen && seen.tab !== 'Agents') failures.push(`the Dock opened on "${seen.tab}", not Agents`);
+ if (seen && seen.line !== 'Ship the Dock') failures.push(`the agent's line is "${seen.line}"`);
+ if (seen && !/Paused by @ada/.test(seen.meta || '')) failures.push(`the meta does not say who paused it: "${seen.meta}"`);
+ if (seen && !/2 watching/.test(seen.meta || '')) failures.push(`the meta does not say how many watch: "${seen.meta}"`);
+ const wantActs = ['pause:Resume', 'curtain:Curtain now', 'mute:Unmute', 'end:End'];
+ if (seen && JSON.stringify(seen.acts) !== JSON.stringify(wantActs)) failures.push(`the four controls are ${JSON.stringify(seen.acts)}`);
+ if (seen && seen.frame !== AGENT.embed_url) failures.push(`the stream frame is "${seen.frame}"`);
+ if (seen && !seen.styles) failures.push("the Dock's own styles are missing");
+ if (seen && seen.lineDisplay !== 'flex') failures.push(`the agent's styles did not apply (line display ${seen.lineDisplay})`);
+ if (security.length) failures.push(`CSP refused: ${security.join(' | ')}`);
+ if (!paused.some((u) => u.startsWith('https://openvibe.live/embed/agent/'))) failures.push('the panel never asked for the agent embed');
+ if (!paused.some((u) => u.startsWith('https://openvibe.codes/api/v1/me/agent-view'))) failures.push('the panel never asked Codes for the agent view');
+ return { failures, staged: paused.length, seen };
+ } finally {
+ if (page) page.close();
+ proc.kill();
+ await pause(200);
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+module.exports = { main };
+
+if (require.main === module) {
+ main().then((r) => {
+ if (r.skipped) { console.log(`side panel in Chrome: skipped (${r.skipped})`); return; }
+ if (r.failures.length) { console.error(r.failures.join('\n')); process.exit(1); }
+ console.log(`side panel in Chrome: the Dock opened on Agents with the agent view and its four controls (${r.staged} stubbed requests)`);
+ }).catch((err) => { console.error(err); process.exit(1); });
+}
@@ -16,8 +16,11 @@ reads, sends or stores, change this file, the README's "What it can see" table a
@@ -16,8 +16,11 @@ reads, sends or stores, change this file, the README's "What it can see" table a
| `scripting` | Four buttons inject a script packaged in the extension into the active tab's top frame, only after the press: "Try these codes on this page" runs `lib/autoapply.js` (finds the coupon box, types each code, reads whether the order total changed), "Save its rating to OpenVibe Reviews" runs `lib/rating.js` (reads the rating the page states), and "Keep on my Rent board" and "Check the rent" run `lib/listing.js` (reads the price, city and bedrooms a listing states). None makes a request or reads cookies or storage. |
| `scripting` | Four buttons inject a script packaged in the extension into the active tab's top frame, only after the press: "Try these codes on this page" runs `lib/autoapply.js` (finds the coupon box, types each code, reads whether the order total changed), "Save its rating to OpenVibe Reviews" runs `lib/rating.js` (reads the rating the page states), and "Keep on my Rent board" and "Check the rent" run `lib/listing.js` (reads the price, city and bedrooms a listing states). None makes a request or reads cookies or storage. |
| `storage` | Keeps the optional `cpx_` install token, and the Dock's sign-in: the OAuth access token in `storage.session` (memory) and the rotating refresh token in `storage.local` (never synced). A pasted 0.7.0 `networkToken` is kept only as that fallback. |
| `storage` | Keeps the optional `cpx_` install token, and the Dock's sign-in: the OAuth access token in `storage.session` (memory) and the rotating refresh token in `storage.local` (never synced). A pasted 0.7.0 `networkToken` is kept only as that fallback. |
| `identity` | Starts the Dock's one-click sign-in (`launchWebAuthFlow`) at openvibe.network; the code returns to the browser's own extension redirect URL. No password is read by the extension. |
| `identity` | Starts the Dock's one-click sign-in (`launchWebAuthFlow`) at openvibe.network; the code returns to the browser's own extension redirect URL. No password is read by the extension. |
-| Host permission `https://openvibe.coupons/*` | The one API the extension calls: look up a merchant by hostname, list its codes, and report whether a code worked. |
-| Remote code | **No.** All JavaScript is in the package; the CSP is `script-src 'self'` with no `eval`. |
+| Host permission `https://openvibe.coupons/*` | The Coupons API: look up a merchant by hostname, list its codes, and report whether a code worked. |
+| Host permission `https://openvibe.network/*` | The Dock's state (`GET`/`PUT /api/v1/me/dock`) and its notifications, with the person's own token. |
+| Host permission `https://openvibe.chat/*` | The Dock's Chats tab: the person's conversations and DMs (`GET /api/dm/conversations`), with the same token. |
+| Host permission `https://openvibe.codes/*` | The Dock's agent view: the person's own desk stream, its still, and the Pause/Resume, Curtain now, Mute/Unmute and End controls (`/api/v1/me/agent-view`), with the same token. |
+| Remote code | **No.** All JavaScript is in the package; the CSP is `script-src 'self'` with no `eval`. The agent view's `https://openvibe.live/embed/agent/…` frame is the only remote frame (`frame-src`), and it is Live's own page, not script the extension runs. |
## Data usage (Chrome Web Store "Privacy practices" tab)
## Data usage (Chrome Web Store "Privacy practices" tab)
@@ -26,8 +29,8 @@ Tick these data types, because each leaves the device in some case:
@@ -26,8 +29,8 @@ Tick these data types, because each leaves the device in some case:
| Type | Ticked | What, when, where |
| Type | Ticked | What, when, where |
|---|---|---|
|---|---|---|
| Web history | **Yes** | The active tab's **hostname** goes to openvibe.coupons when the popup opens on a site. On a hand-off press, the page's address (credentials, fragment and tracking parameters removed) and title go to openvibe.rent, openvibe.watch, openvibe.food or openvibe.reviews in the address of a tab the person sees. |
| Web history | **Yes** | The active tab's **hostname** goes to openvibe.coupons when the popup opens on a site. On a hand-off press, the page's address (credentials, fragment and tracking parameters removed) and title go to openvibe.rent, openvibe.watch, openvibe.food or openvibe.reviews in the address of a tab the person sees. |
-| Website content | **Yes** | On "Save its rating to OpenVibe Reviews": the business name, rating, scale, count of ratings and city the page states, in the address of the openvibe.reviews tab. On "Compare delivery apps": the restaurant or store name taken from the page title, to openvibe.food. On "Keep on my Rent board" and "Check the rent": the price, its currency, the city and region (never the street) and the bedrooms the listing states, in the address of the openvibe.rent tab. |
-| Authentication information | **Yes** | Only if the person connects the extension: the `cpx_` install token they pasted is sent to openvibe.coupons as `Authorization: Bearer` on its API calls. The Dock's one-click sign-in sends an OAuth access token to openvibe.network and openvibe.chat as `Authorization: Bearer` (scopes `dock`, `activity`, `chat.read`) and keeps a refresh token on this device. |
+| Website content | **Yes** | The Dock reads the person's own agent view (the task line, the worked time, the stream's state) from openvibe.codes and frames the stream from openvibe.live; it is their own content, not the page's. On "Save its rating to OpenVibe Reviews": the business name, rating, scale, count of ratings and city the page states, in the address of the openvibe.reviews tab. On "Compare delivery apps": the restaurant or store name taken from the page title, to openvibe.food. On "Keep on my Rent board" and "Check the rent": the price, its currency, the city and region (never the street) and the bedrooms the listing states, in the address of the openvibe.rent tab. |
+| Authentication information | **Yes** | Only if the person connects the extension: the `cpx_` install token they pasted is sent to openvibe.coupons as `Authorization: Bearer` on its API calls. The Dock's one-click sign-in sends an OAuth access token to openvibe.network, openvibe.chat and openvibe.codes as `Authorization: Bearer` (scopes `dock`, `activity`, `chat.read`) and keeps a refresh token on this device. |
| User activity | No | No clicks, keystrokes or scrolls are recorded. A report sends a code id, `worked`/`failed` and an optional reason the person chose, which is the person's own feedback. |
| User activity | No | No clicks, keystrokes or scrolls are recorded. A report sends a code id, `worked`/`failed` and an optional reason the person chose, which is the person's own feedback. |
| Financial and payment information | No | `lib/autoapply.js` reads the order total in the page to tell which codes lowered it; the total stays in the popup and is never sent. |
| Financial and payment information | No | `lib/autoapply.js` reads the order total in the page to tell which codes lowered it; the total stays in the popup and is never sent. |
| Personally identifiable information, health, personal communications, location | No | Not read. (The city sent with a rating is the business's, and with a listing the home's, from the page; never the person's.) |
| Personally identifiable information, health, personal communications, location | No | Not read. (The city sent with a rating is the business's, and with a listing the home's, from the page; never the person's.) |
@@ -47,4 +50,5 @@ these to the person at install. Nothing is optional, because nothing is collecte
@@ -47,4 +50,5 @@ these to the person at install. Nothing is optional, because nothing is collecte
didn't press for, and the token is the person's own choice on the settings page.
didn't press for, and the token is the person's own choice on the settings page.
In Firefox, MV3 host permissions can be withheld at install; the settings page then shows "Allow
In Firefox, MV3 host permissions can be withheld at install; the settings page then shows "Allow
-access to openvibe.coupons".
+access to openvibe.coupons". The Dock needs its three OpenVibe origins at install; Firefox asks for
+them together with the extension.
@@ -11,16 +11,16 @@ const { check, done } = require('./helpers/harness');
@@ -11,16 +11,16 @@ 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'];
+const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes'];
(async () => {
(async () => {
const { manifest, origin, problems } = auditDir(DIR);
const { manifest, origin, problems } = auditDir(DIR);
- await check('the shipped manifest passes the audit: activeTab + identity + scripting + storage + sidePanel, three OpenVibe host permissions, MV3', async () => {
+ await check('the shipped manifest passes the audit: activeTab + identity + scripting + storage + sidePanel, four 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', 'identity', 'scripting', 'storage']);
assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'identity', 'scripting', 'storage']);
- assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*', 'https://openvibe.network/*', 'https://openvibe.chat/*']);
+ assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*']);
assert.strictEqual(manifest.sidebar_action.default_panel, 'panel.html', 'the Dock lives in the side panel');
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`);
@@ -90,7 +90,10 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
@@ -90,7 +90,10 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
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 https:\/\/openvibe\.network https:\/\/openvibe\.chat;/);
+ assert.match(csp, /connect-src https:\/\/openvibe\.coupons https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes;/);
+ assert.match(csp, /style-src 'self' 'unsafe-inline';/);
+ assert.match(csp, /img-src 'self' blob:;/);
+ assert.match(csp, /frame-src https:\/\/openvibe\.live;/);
assert.strictEqual(auditManifest(manifest, { apiOrigin: 'https://evil.example.net' }).length > 0, true, 'another origin is caught');
assert.strictEqual(auditManifest(manifest, { apiOrigin: 'https://evil.example.net' }).length > 0, true, 'another origin is caught');
});
});
@@ -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', origins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat'] }), []);
+ assert.deepStrictEqual(auditManifest(JSON.parse(files.get('manifest.json')), { apiOrigin: 'https://openvibe.coupons', origins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes'] }), []);
});
});
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/*', '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.deepStrictEqual(m.host_permissions, ['http://localhost:4850/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*']);
+ assert.match(m.content_security_policy.extension_pages, /connect-src http:\/\/localhost:4850 https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes;/);
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\//);
@@ -69,7 +69,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
@@ -69,7 +69,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
const doc = fakeDoc();
const doc = fakeDoc();
const dock = fakeDock();
const dock = fakeDock();
const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => TOKEN });
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 () => null } }, auth: fakeAuth(), net, dock, sibling: (n) => 'chrome-extension://abc/shared/' + n });
+ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat', CODES_ORIGIN: 'https://openvibe.codes' }, platform: { storage: { getNetworkToken: async () => null } }, auth: fakeAuth(), net, dock, sibling: (n) => 'chrome-extension://abc/shared/' + n });
const out = await panel.start();
const out = await panel.start();
assert.strictEqual(out.mounted, true, 'the Dock is mounted');
assert.strictEqual(out.mounted, true, 'the Dock is mounted');
assert.strictEqual(dock.opened, 1, 'and opened');
assert.strictEqual(dock.opened, 1, 'and opened');
@@ -78,6 +78,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
@@ -78,6 +78,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
assert.strictEqual(dock.mounted.me, 'ada', 'the account name');
assert.strictEqual(dock.mounted.me, 'ada', 'the account name');
assert.strictEqual(dock.mounted.apiBase, 'https://openvibe.network', "the Network, where the Dock's state is");
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.chatBase, 'https://openvibe.chat', 'and Chat, where the Chats tab reads');
+ assert.strictEqual(dock.mounted.agentBase, 'https://openvibe.codes', "and Codes, where the agent view's stream and four controls are");
assert.strictEqual(dock.mounted.sibling('dock.js'), 'chrome-extension://abc/shared/dock.js', 'the bundled Dock, from this extension');
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.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');
assert.ok(net1.requests.some((r) => r.url === '/api/v1/me/dock' && r.auth === 'Bearer ' + TOKEN), 'the Network is asked with that sign-in');
@@ -95,7 +96,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
@@ -95,7 +96,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
const doc = fakeDoc();
const doc = fakeDoc();
const dock = fakeDock();
const dock = fakeDock();
const net = createNetworkClient({ origin: net2.origin, fetch: (...a) => fetch(...a), getToken: async () => TOKEN });
const net = createNetworkClient({ origin: net2.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 () => null } }, auth: fakeAuth(), net, dock });
+ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat', CODES_ORIGIN: 'https://openvibe.codes' }, platform: { storage: { getNetworkToken: async () => null } }, auth: fakeAuth(), net, dock });
await panel.start();
await panel.start();
assert.strictEqual(dock.openedWith.doc.state.tab, 'chats', 'the side panel opens on the page\'s tab');
assert.strictEqual(dock.openedWith.doc.state.tab, 'chats', 'the side panel opens on the page\'s tab');
assert.deepStrictEqual(dock.openedWith.doc.counts, first.counts, 'and the unread counts the page sees');
assert.deepStrictEqual(dock.openedWith.doc.counts, first.counts, 'and the unread counts the page sees');
@@ -113,7 +114,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
@@ -113,7 +114,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
const doc = fakeDoc();
const doc = fakeDoc();
const dock = fakeDock();
const dock = fakeDock();
const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => null });
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 } }, auth: fakeAuth({ mode: 'none' }), net, dock });
+ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat', CODES_ORIGIN: 'https://openvibe.codes' }, platform: { storage: { getNetworkToken: async () => null } }, auth: fakeAuth({ mode: 'none' }), net, dock });
const out = await panel.start();
const out = await panel.start();
assert.strictEqual(out.mounted, false, 'no Dock without a sign-in');
assert.strictEqual(out.mounted, false, 'no Dock without a sign-in');
assert.strictEqual(doc.nodes.gate.hidden, false, 'the gate is shown');
assert.strictEqual(doc.nodes.gate.hidden, false, 'the gate is shown');
@@ -127,7 +128,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
@@ -127,7 +128,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
const dock = fakeDock();
const dock = fakeDock();
const bad = 'wrong_token_' + 'y'.repeat(40);
const bad = 'wrong_token_' + 'y'.repeat(40);
const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => bad });
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 () => null } }, auth: fakeAuth({ token: bad }), net, dock });
+ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat', CODES_ORIGIN: 'https://openvibe.codes' }, platform: { storage: { getNetworkToken: async () => null } }, auth: fakeAuth({ token: bad }), net, dock });
const out = await panel.start();
const out = await panel.start();
assert.strictEqual(out.mounted, false, 'no Dock with a sign-in the Network refused');
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.strictEqual(doc.nodes.gate.hidden, false, 'the gate says so');
@@ -139,7 +140,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
@@ -139,7 +140,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
const dock = fakeDock();
const dock = fakeDock();
const auth = fakeAuth({ mode: 'none' });
const auth = fakeAuth({ mode: 'none' });
const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => TOKEN });
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: {} }, auth, net, dock });
+ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat', CODES_ORIGIN: 'https://openvibe.codes' }, platform: { storage: {} }, auth, net, dock });
await panel.start();
await panel.start();
doc.nodes['gate-button'].onclick();
doc.nodes['gate-button'].onclick();
// The click handler is async; wait until the grant is "connected" and the Dock mounted.
// The click handler is async; wait until the grant is "connected" and the Dock mounted.
@@ -154,7 +155,8 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
@@ -154,7 +155,8 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
}
}
await check('the panel bundles the released Dock and loads nothing remote', async () => {
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']) {
+ for (const f of ['shared/dock.js', 'shared/dock-agent.js', 'shared/dock-timer.js', 'shared/dock-vibe.js',
+ 'shared/action-center.js', 'shared/notification-ui.js', 'shared/notification-live.js', 'shared/ov-mark.js', 'shared/kit.css']) {
assert.ok(fs.existsSync(path.join(DIR, f)), 'the panel ships ' + f);
assert.ok(fs.existsSync(path.join(DIR, f)), 'the panel ships ' + f);
}
}
const html = fs.readFileSync(path.join(DIR, 'panel.html'), 'utf8');
const html = fs.readFileSync(path.join(DIR, 'panel.html'), 'utf8');
@@ -163,5 +165,11 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
@@ -163,5 +165,11 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
assert.match(html, /shared\/dock\.js/);
assert.match(html, /shared\/dock\.js/);
});
});
+ await check('in a real browser: the panel opens on Agents with the agent view and its four controls (skipped without Chrome)', async () => {
+ const r = await require('../scripts/check-panel').main();
+ if (r.skipped) { console.log(` skipped: ${r.skipped}`); return; }
+ assert.deepStrictEqual(r.failures, []);
+ });
+
done();
done();
})();
})();