openvibers/openvibe.shared Commits

4.204.0: buttons that show their state, resizable windows, the composer everywhere, players, csp (#460)

OP OpenVibers committed 2 days ago · 1bbce3bae8 · 55 files, +1239 −468

* Buttons that show what they do and what is happening

The owner, 2026-11-11: "improve our buttons across the site and add better animations and effects
for them, and different things we can use them for, and little indicators or statuses ... idle
animation ... more important buttons ... more intuitive and more modular design, shared across
everything through openvibe.design".

- Kinds: hero (the one thing a page is for: 52 px, the accent gradient), soft (a tinted second
  choice) and live (on air, a breathing dot), beside primary, quiet, ghost and danger.
- Status on any kind: a count badge, a status dot (ok, warn, bad, live), a toggle (aria-pressed),
  attention (the one next step: a soft ring now and then until pressed), a progress fill.
- Motion: a 1 px lift and a press; the icon moves by meaning (forward, back, add, open, down,
  refresh). Every move is scaled by --ov-move, so Calm makes it smaller and Off removes it.
- Work: OpenVibeKitWeb.busy(button, work) keeps the words, adds the wait and aria-busy, then a
  check for a moment or a shake and red. Hold-to-confirm for destructive buttons (pointer, Enter,
  Space; letting go early cancels).
- kit.button() takes kind, nudge, iconEnd, attention, badge, dot, hold, pressed. docs/kit.md#buttons.

* A panel window can be resized: a corner grip for mouse, finger and keys, its size kept with its place

The owner, 2026-11-11: a robot's controls "can be popped out ... and resized". OVWindow.open({ kind: 'panel', resizable: true, minWidth, minHeight, onResize }) adds a grip at the corner (pointer drag works on touch; arrow keys, Shift for bigger steps, Enter for the first size); the size is kept with the place per id, and a move keeps the size.

* Sibling revisions after the window change

* 4.204.0: buttons that show their state, resizable windows, the one composer everywhere, players that start sooner, csp

Carries the composer of #458 (its CI failed on the macOS menu flake, never released), #454 (player starts) and #455 (csp) with this branch's buttons and window resize. 4.203.0 went to #459 (editable DMs in the Dock).

* Browser tests check the page with eventually(), never after a guessed sleep; a ratchet keeps it at zero

The owner: "Can we fix the Mac OS bug once and for all?" menu-places-chrome slept 180 ms against a 140 ms debounce and failed on the slow macOS runners (4.202.0, 4.203.0). browser-harness gains eventually(check, { timeout }); 382 page checks in 26 test files use it; test/fixed-waits.test.js fails on any new sleep-then-check (baseline zero).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e4e34fd..6f0c8e9 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,29 @@
# Changelog
++## 4.204.0 — 2026-11-11
++
++**Buttons that show their state, windows that resize, the one composer everywhere, players that start sooner, and one
++place for the network's CSP.**
++
++- **Buttons that show what they do and what is happening (`kit.css`, `kit.js`, `kit-web.js`).** Kinds by importance
++ (hero, soft, live beside primary, quiet, ghost, danger); a count badge, a status dot, a toggle's fill, a progress
++ fill; attention (the one next step glows now and then); busy → a check, or a shake and red
++ (`OpenVibeKitWeb.busy()`); hold-to-confirm for destructive buttons; icons that move by meaning. Every motion follows
++ the motion setting. docs/kit.md#buttons.
++- **A panel window can be resized (`ov-window.js`).** `resizable: true` adds a corner grip that a mouse, a finger or
++ a pen drags and the arrow keys move; the size is kept with the place per id.
++- **The composer carries its own look, and Vibe's Dock box is the same composer (`ov-composer.js`, `dock-vibe.js`).**
++ Its styles moved out of `thread.css` into the script; Vibe's box gets drafts, "/" commands, the marks and settings,
++ with `data-ovc-no-files` keeping its picture upload its own. (Was #458, never released.)
++- **Players start sooner (`player.js`, #454).** The poster stays until a frame is presented; preload follows the
++ connection; battery-aware HLS attachment with a low first rung. docs/player-startup.md.
++- **No more macOS flakes from fixed sleeps (tests).** A browser test now checks the page with `eventually()` from
++ browser-harness (it retries until the page is there, 5 s at most) instead of after a guessed sleep: 382 checks in 26
++ files, including menu-places-chrome, whose 180 ms sleep against a 140 ms debounce failed on the slow macOS runners
++ and held back 4.202.0 and 4.203.0. `test/fixed-waits.test.js` keeps the count of sleep-then-check places at zero.
++- **`openvibe-shared/csp` (#455).** One place for the network's Content-Security-Policy pieces; `connectSources()`
++ names Media, the composer's upload origin.
++
## 4.203.0 — 2026-11-11
- Dock conversations update edited messages and link cards live; authors can edit their own messages.
ddiff --git a/README.md b/README.md
iindex 4ebbb74..586ef29 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.203.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.204.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 9a2ff38..8da812f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,13 +4,14 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.203.0",
++ "version": "4.204.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"contracts": "openvibe-contracts v0.399.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.147.0",
"features": [
++ "buttons that show their state (kinds, badge, dot, attention, busy/done/error, hold), resizable panel windows, the one composer everywhere, players that start sooner, and openvibe-shared/csp (4.204.0)",
"the Dock updates edited DM text and link cards live and lets authors edit their own messages (4.203.0)",
"the shared field dropdown, one dialog/sheet/panel window, user cards, eight site groups, measured page ends and remade reveal (4.202.0)",
"the page-view beacon sends ?in=1 only for a signed-in browser and counts a guest at once, so a signed-in person is counted from presence and never also as a guest (4.191.0)",
ddiff --git a/browser-harness.js b/browser-harness.js
iindex 06e2867..f4eddd8 100644
---- a/browser-harness.js
++++ b/browser-harness.js
@@@ -908,7 +908,23 @@ async function run(opts = {}) {
return report;
}
--module.exports = { textSmells,
++/**
++ * Retry an assertion until it passes (the browser tests' answer to a fixed wait before a check, 2026-11-11: the macOS
++ * runners are slow enough that a 140 ms debounce sometimes outlasted a 180 ms sleep, and Shared's releases kept failing
++ * on menu-places-chrome). check is an async function that throws while the page is not there yet; the last error is
++ * thrown after timeout ms. Waits are a ceiling, never a guess: a check that is already true returns at once.
++ */
++async function eventually(check, { timeout = 5000, every = 50 } = {}) {
++ const end = Date.now() + timeout;
++ let last;
++ for (;;) {
++ try { return await check(); } catch (e) { last = e; }
++ if (Date.now() >= end) throw last;
++ await new Promise((r) => setTimeout(r, every));
++ }
++}
++
++module.exports = { textSmells, eventually,
run, launch, openPage, format, summarize, findChrome, axeSource, checkUnreachable,
// pure helpers, exported for tests and for the Host CLI
normalizeRoutes, jsonLdEntities, findText, visibleIn, growth, sitemapPaths, checkRoute, splitErrors,
ddiff --git a/csp.js b/csp.js
nnew file mode 100644
iindex 0000000..f165910
---- /dev/null
++++ b/csp.js
@@@ -0,0 +1,69 @@
++'use strict';
++/**
++ * openvibe-shared/csp — the network's Content-Security-Policy pieces, in one place.
++ *
++ * Every OpenVibe site builds a CSP header. Before this module each built its own string, so the composer's upload to
++ * OpenVibe.Media was blocked on any site that had not named `https://openvibe.media` in connect-src (the browser sends
++ * the multipart POST there, and a CSP refusal is a network error with no status). A site that uses these helpers gets
++ * Network, Events and Media in connect-src by default; a site that needs more (its own websocket, Cloudflare's beacon)
++ * adds it with `sites` or `extra`.
++ *
++ * const csp = require('openvibe-shared/csp');
++ * res.setHeader('Content-Security-Policy', csp.policy({ nonce, sites: ['wss://openvibe.chat'], frames: ['https://openvibe.bot'] }));
++ *
++ * `connectSources()` is the list on its own, for a site that keeps its own policy but wants the network's origins.
++ */
++const NETWORK = 'https://openvibe.network';
++const EVENTS = 'https://openvibe.events';
++const MEDIA = 'https://openvibe.media';
++
++const uniq = (list) => [...new Set(list.filter(Boolean).map(String))];
++
++/**
++ * The connect-src value: this site, the Network (sign-in, the Frame, settings, mentions), Events (release and
++ * notification streams) and Media (composer attachments). `sites` and `extra` add more.
++ */
++function connectSources({ self = true, sites = [], extra = [] } = {}) {
++ return uniq([self ? "'self'" : null, NETWORK, `wss://${NETWORK.slice(8)}`, EVENTS, `wss://${EVENTS.slice(8)}`, MEDIA, ...sites, ...extra]).join(' ');
++}
++
++/**
++ * A full policy. Defaults suit a server-rendered site that runs only its own scripts, with inline styles and pictures
++ * and media from anywhere over https.
++ *
++ * nonce the response's script nonce (inline scripts need it)
++ * script extra script origins
++ * style extra style origins
++ * images the img-src value (default https:)
++ * media the media-src value (default https:)
++ * frames frame-src entries (default 'self')
++ * formAction the form-action value (default 'self' https://openvibe.network)
++ * sites extra connect-src origins
++ * connect a full connect-src value, instead of connectSources()
++ * connectExtra extra connect-src entries
++ * objectSrc object-src (default 'none')
++ * baseUri base-uri (default 'self')
++ * frameAncestors frame-ancestors (default 'self')
++ * extra { directive: value } appended (e.g. { 'worker-src': "'self' blob:" })
++ */
++function policy({ nonce, script = [], style = [], images = 'https:', media = 'https:', frames = ["'self'"], formAction = `'self' ${NETWORK}`, connect, sites = [], connectExtra = [], objectSrc = "'none'", baseUri = "'self'", frameAncestors = "'self'", extra = {} } = {}) {
++ const directives = [
++ ["default-src", "'self'"],
++ ["script-src", ["'self'", nonce ? `'nonce-${nonce}'` : null, ...script].filter(Boolean).join(' ')],
++ ["style-src", ["'self'", "'unsafe-inline'", ...style].filter(Boolean).join(' ')],
++ ["img-src", images],
++ ["media-src", media],
++ ["font-src", "'self' data:"],
++ ["connect-src", connect || connectSources({ sites, extra: connectExtra })],
++ ["frame-src", frames.join(' ')],
++ ["frame-ancestors", frameAncestors],
++ ["base-uri", baseUri],
++ ["object-src", objectSrc],
++ ["form-action", formAction],
++ ];
++ const seen = new Set(directives.map(([d]) => d));
++ for (const [d, v] of Object.entries(extra)) if (!seen.has(d) && v != null) directives.push([d, String(v)]);
++ return directives.map(([d, v]) => `${d} ${v}`).join('; ');
++}
++
++module.exports = { NETWORK, EVENTS, MEDIA, MEDIA_UPLOAD_ORIGIN: MEDIA, connectSources, policy };
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex caabc43..4ed0e43 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -55,6 +55,21 @@
.ovdk-res-c.is-stopped .ovdk-i, .ovdk-res-c.is-stopped .ovdk-res-t { color: var(--text-muted, #8b8b96); }
`;
let ctx = null, G = {};
++ // 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
++ // drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
++ const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
++ const VIBE_REV = { 'ov-composer.js': 'f52e2323e6e9' };
++ function joinComposer(form) {
++ if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
++ form.setAttribute('data-ovc-no-files', '');
++ const go = () => { try { if (root.OpenVibeComposer && form.isConnected) root.OpenVibeComposer.enhance(form); } catch (e) { console.error(e); } };
++ if (root.OpenVibeComposer) { go(); return; }
++ const m = /^(https?:\/\/[^?#]*\/)dock-vibe(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
++ if (!m) return;
++ let sc = document.getElementById('ov-composer-script');
++ if (!sc) { sc = document.createElement('script'); sc.id = 'ov-composer-script'; sc.async = true; sc.src = m[1] + 'ov-composer.js' + (VIBE_REV['ov-composer.js'] ? '?v=' + VIBE_REV['ov-composer.js'] : ''); document.head.appendChild(sc); }
++ sc.addEventListener('load', go, { once: true });
++ }
const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
/** A round icon button with its name as label and tooltip. */
const iconButton = (tag, cls, path, label) => { const b = h(tag, cls); if (tag === 'button') b.type = 'button'; b.setAttribute('aria-label', label); b.setAttribute('data-ov-tip', label); b.title = label; b.innerHTML = svg(path); return b; };
@@@ -249,6 +264,7 @@
} finally { vibe.busy = false; go.disabled = false; box.focus(); }
}
form.addEventListener('submit', (e) => { e.preventDefault(); send(box.value.trim()); });
++ joinComposer(form);
async function pull(first) {
let got;
ddiff --git a/dock.js b/dock.js
iindex 15dba6f..d19e4ef 100644
---- a/dock.js
++++ b/dock.js
@@@ -89,7 +89,7 @@
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
-- const DOCK_REV = { 'dock-vibe.js': '1bfa19b01e8e', 'dock-code.js': '3dcbaca10bfb', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
++ const DOCK_REV = { 'dock-vibe.js': '81365d0289bb', 'dock-code.js': '3dcbaca10bfb', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
ddiff --git a/docs/kit.md b/docs/kit.md
iindex 61aed10..df850b1 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -362,6 +362,37 @@ kit.tool({
The frame is part of Kit 2: its look is in `kit-data.css` and its behaviour (the clear button and the examples) in
`kit-data-web.js`. A page that draws no tool loads neither, so `kit.css` and `kit-web.js` weigh what they did.
++## Buttons (4.204.0)
++
++A button says what it does and shows what is happening. One look on every site; every motion follows the motion
++setting (Calm makes it smaller and slower, Off removes it).
++
++| Kind | When | Look |
++|---|---|---|
++| `hero` | the one thing the page is for (Go live, Start) | 52 px, the accent gradient, a glow |
++| `primary` | the main action of a section | filled accent |
++| `soft` | a second choice that still matters (Invite, Finish setup) | tinted accent |
++| `quiet` | everything else | panel with a line |
++| `ghost` | a link-like action inside text or a row | accent words only |
++| `danger` | destructive; with `hold` it asks for a press and hold | red line |
++| `live` | on air now | red, a breathing dot |
++
++Status, on any kind: `badge` (a count at the corner, 99+ at most), `dot` (`ok`, `warn`, `bad`, `live`; live
++breathes), `pressed` (a toggle: aria-pressed, tinted), `attention` (the one next step: a soft ring goes out now and
++then until it is pressed; one per screen), `nudge` (the icon moves by meaning on hover: `forward`, `back`, `add`,
++`open`, `down`, `refresh`), `iconEnd` (an icon after the words), and `style="--ovk-progress: .4"` (a fill inside it).
++
++```js
++kit.button({ label: 'Go live', icon: 'live', kind: 'hero', nudge: 'forward', iconEnd: 'chevron' })
++kit.button({ label: 'Messages', icon: 'chat', kind: 'quiet', badge: 3, type: 'button' })
++kit.button({ label: 'Delete', kind: 'danger', hold: 1200, type: 'button' })
++await OpenVibeKitWeb.busy(button, fetch('/api/save', { method: 'POST' })) // busy → a check, or a shake and red
++```
++
++`busy(button, work)` keeps the words, adds the mark's wait beside them and aria-busy, and disables the button; when the
++work resolves the button turns green with a check for a moment, and when it fails it shakes once and turns red. A
++`hold` button fires its click only after the hold has run (pointer, Enter or Space); letting go early cancels.
++
## The navbar's links
The 2026-10-28 sweep of all 38 sites' bars found three habits that made them feel crowded and repetitive:
ddiff --git a/docs/player-starts-measurement.md b/docs/player-starts-measurement.md
nnew file mode 100644
iindex 0000000..740740e
---- /dev/null
++++ b/docs/player-starts-measurement.md
@@@ -0,0 +1,45 @@
++# Player starts — ask 288
++
++Measured 2026-11-10 UTC with system Chrome and fresh Playwright contexts, browser cache cold. No Live stream was online (`/api/streams`: `{"streams":[],"agents":[]}`). Live `/vod/6298` redirects to Video `/watch?v=sU4LkcV7PpM`; these are two entry paths to the same 341-second ROBOT recording, not independent encodes. Its joined HLS object is `med_01M729VRTW6E3NPTTM008H0GAA`.
++
++Phone: 1.6 Mbit/s down, 150 ms latency, 4x CPU slowdown, 390px. Computer: 10 Mbit/s down, 40 ms latency, native CPU, 1280px. Upload 0.75 Mbit/s. TTFF starts at the explicit muted play request (after the video element appears), and ends at requestVideoFrameCallback. Rebuffer ratio is waiting time divided by the following 20 seconds of wall time. Seek tests start from 220 seconds in a fresh context. One sample per entry/profile/position; these are diagnostic samples, not statistical estimates. The shared test host was busy (load 22–26 on 8 CPUs); reruns need the same caveat. Page loading and automatic preloading may precede the play request.
++
++## Before
++
++| Entry | Profile | Position | TTFF, s | Rebuffer | Observed tier |
++|---|---|---:|---:|---:|---|
++| live-recording | phone | 0s | 12.451 | 16.01% | B2 via Media |
++| live-recording | phone | 220s | 15.126 | 4.14% | B2 via Media |
++| live-recording | computer | 0s | 2.877 | 0.00% | B2 via Media |
++| live-recording | computer | 220s | 3.848 | 0.00% | B2 via Media |
++| video-recording | phone | 0s | 12.123 | 16.44% | B2 via Media |
++| video-recording | phone | 220s | 14.914 | 5.97% | B2 via Media |
++| video-recording | computer | 0s | 2.597 | 0.00% | B2 via Media |
++| video-recording | computer | 220s | 3.923 | 0.00% | B2 via Media |
++
++Both opening and later segments redirect to `s3.us-west-004.backblazeb2.com`; Media may proxy packed chunks. No R2 response was observed. The part-source master offers only 720p (BANDWIDTH 3403963, average 2383603): greater than the phone throughput, so buffer changes cannot make this source sustainable on that connection. A low rung must exist before it can be selected.
++
++Evidence: `player-starts-evidence/measure.cjs`, `before.json`. The script saves hostnames only, never presigned URL queries. Production source URLs and the public page are used directly; no database writes or credentials.
++
++## After
++
++Pending Shared review and Media deployment. Do not treat a local Shared override as a production rollout.
++
++## Next for the design
++
++No layout or control changes. Cards adopting Shared warming need a public HLS source attribute; the main session owns their markup and appearance.
++
++## Shared candidate, local script override against production media
++
++The browser replaced only Shared player.js and the consumer HLS constructor call with this branch. This is not deployed. Chrome used native HLS in these samples, so these numbers do not isolate hls.js buffer tuning or card warming. No consistent rebuffer improvement can be inferred from these single samples, especially under the changing host load (22–47).
++
++| Entry | Profile | Position | TTFF, s | Rebuffer | Tier |
++|---|---|---:|---:|---:|---|
++| live-recording | phone | 0s | 8.976 | 22.48% | B2 |
++| live-recording | phone | 220s | 17.388 | 3.43% | B2 |
++| live-recording | computer | 0s | 2.771 | 0.00% | B2 |
++| live-recording | computer | 220s | 5.357 | 0.00% | B2 |
++| video-recording | phone | 0s | 10.983 | 4.88% | B2 |
++| video-recording | phone | 220s | 19.946 | 0.97% | B2 |
++| video-recording | computer | 0s | 3.791 | 0.00% | B2 |
++| video-recording | computer | 220s | 5.541 | 0.00% | B2 |
ddiff --git a/docs/player-startup.md b/docs/player-startup.md
nnew file mode 100644
iindex 0000000..9529f56
---- /dev/null
++++ b/docs/player-startup.md
@@@ -0,0 +1,22 @@
++# Connection-aware player startup
++
++`player.js` keeps the poster until a decoded frame is presented, and applies preload on enhancement: `none` for Save-Data/2g/slow-2g, `metadata` for 3g, `auto` otherwise. Set the same preload before assigning a source when creating a video dynamically: enhancement cannot undo bytes the HTML parser already requested. The Battery API is optional; its absence uses the connection policy.
++
++Pages that attach HLS use the same policy with their existing Hls constructor and error handling:
++
++```js
++const hls = OpenVibePlayer.attachHls(video, Hls, playlist, { enableWorker: true });
++// Keep the page's Hls.Events.ERROR fallback and destroy hls on navigation.
++```
++
++Native HLS uses the video's preload; hls.js loads the manifest but defers segments until play on constrained connections. The first level is 0 (hls.js orders the ladder by bitrate); automatic ABR then chooses subsequent levels. No rendition is invented if the source only has one. Forward/back buffers are 60/30 seconds normally, 30/15 on 3g, 30/0 on 2g or Save-Data, and 15/0 on an unplugged battery at 20% or lower. The hard forward limits are 120/60/30 seconds. Battery and connection changes update the attachment; destroy removes listeners.
++
++Cards opt in using a **public**, unsigned VOD HLS source:
++
++```html
++<a href="/watch?v=…" data-ov-player-hls="https://openvibe.media/o/…/master.m3u8">Watch</a>
++```
++
++Call `OpenVibePlayer.warmCards(container)` for cards added later. Hover or keyboard focus for 120 ms warms the lowest playlist, its init and first media segment into HTTP cache. It stops on leave/blur, avoids live, encrypted, signed and byte-range playlists, and makes no requests on Save-Data/2g/3g or low battery. Each card is capped at 2 MiB and each document at 20 starts per minute. Oversized first segments are cancelled; warming is best effort, never a prerequisite for playback. HTTP cache reuse depends on the source's cache/CORS headers and browser partitioning.
++
++These helpers do not replace a consumer's existing HLS constructor automatically. Consumer adoption and release pins are necessary to get the buffer policy. Next for the design: use the source attribute on existing cards; keep their appearance and controls with the main session.
ddiff --git a/kit-web.js b/kit-web.js
iindex ed05bd9..e413890 100644
---- a/kit-web.js
++++ b/kit-web.js
@@@ -18,6 +18,10 @@
* and a page move close it (document-level, so boost-swapped pages need no wiring)
* card previews .ovk-card video[data-src]: a muted loop while the card is hovered or focused (pointers only, never
* under reduced motion; the file loads only then)
++ * hold .ovk-btn[data-hold="1200"]: press and hold to confirm (the fill runs; letting go early cancels; Enter or
++ * Space held works the same); the click goes through only after the hold
++ * busy(button, work) shows the work on the button: busy (the words stay, the wait beside them, aria-busy), then a
++ * check for a moment, or a shake and red when it failed; work is a promise or a function returning one
*
* Idempotent and safe on a page that uses none of it. docs/kit.md describes the pieces.
*/
@@@ -291,7 +295,68 @@
}
-- const api = { enhance };
++ /** The work a button started, shown on the button. Resolves with the work's value; rethrows its error. */
++ async function busy(btn, work) {
++ if (!btn) return typeof work === 'function' ? work() : work;
++ const wait = document.createElement('span');
++ wait.className = 'ovk-btn-wait'; wait.setAttribute('aria-hidden', 'true'); wait.innerHTML = '<i></i><i></i><i></i>';
++ const wasDisabled = btn.disabled;
++ btn.dataset.state = 'busy'; btn.setAttribute('aria-busy', 'true'); btn.appendChild(wait);
++ if ('disabled' in btn) btn.disabled = true;
++ const settle = (state) => {
++ wait.remove(); btn.removeAttribute('aria-busy');
++ if ('disabled' in btn) btn.disabled = wasDisabled;
++ btn.dataset.state = state;
++ setTimeout(() => { if (btn.dataset.state === state) delete btn.dataset.state; }, state === 'done' ? 1200 : 1600);
++ };
++ try {
++ const out = await (typeof work === 'function' ? work() : work);
++ settle('done');
++ return out;
++ } catch (e) { settle('error'); throw e; }
++ }
++
++ // Hold to confirm: document-level, so buttons added later need no wiring.
++ function bindHold() {
++ if (typeof document === 'undefined' || root.__ovkHoldBound) return;
++ root.__ovkHoldBound = true;
++ let held = null;
++ const stop = (fire) => {
++ if (!held) return;
++ const { btn, raf, ok } = held; held = null;
++ cancelAnimationFrame(raf); btn.classList.remove('is-holding'); btn.style.removeProperty('--ovk-hold');
++ if (fire && ok) { btn.dataset.ovkHeld = '1'; btn.click(); }
++ };
++ const start = (btn) => {
++ const ms = Math.max(400, Math.min(4000, Number(btn.getAttribute('data-hold')) || 1200));
++ const t0 = performance.now();
++ held = { btn, raf: 0, ok: false };
++ btn.classList.add('is-holding');
++ const tick = (t) => {
++ if (!held || held.btn !== btn) return;
++ const f = Math.min(1, (t - t0) / ms);
++ btn.style.setProperty('--ovk-hold', `${Math.round(f * 100)}%`);
++ if (f >= 1) { held.ok = true; stop(true); return; }
++ held.raf = requestAnimationFrame(tick);
++ };
++ held.raf = requestAnimationFrame(tick);
++ };
++ document.addEventListener('pointerdown', (e) => { const b = e.target.closest && e.target.closest('.ovk-btn[data-hold]'); if (b && !b.disabled && e.button === 0) start(b); });
++ document.addEventListener('pointerup', () => stop(false));
++ document.addEventListener('pointercancel', () => stop(false));
++ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.target.closest('.ovk-btn[data-hold]'); if (b && !e.repeat && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); start(b); } });
++ document.addEventListener('keyup', (e) => { if (e.key === 'Enter' || e.key === ' ') stop(false); });
++ // A plain click on a hold button does nothing until the hold has run.
++ document.addEventListener('click', (e) => {
++ const b = e.target.closest && e.target.closest('.ovk-btn[data-hold]');
++ if (!b) return;
++ if (b.dataset.ovkHeld === '1') { delete b.dataset.ovkHeld; return; }
++ e.preventDefault(); e.stopImmediatePropagation();
++ }, true);
++ }
++ bindHold();
++
++ const api = { enhance, busy };
root.OpenVibeKitWeb = api;
if (typeof module === 'object' && module.exports) { module.exports = api; return; }
if (typeof document !== 'undefined') {
ddiff --git a/kit.css b/kit.css
iindex 0004d55..d91c9ba 100644
---- a/kit.css
++++ b/kit.css
@@@ -118,6 +118,83 @@ html[data-ov-motion=reduced] { --ov-move: .35; --ov-dur-1: .15s; --ov-dur-2: .25
.ovk-icon-btn[hidden] { display: none; }
.ovk-icon-btn.is-done { color: var(--ovk-ok-text); }
.ovk-icon-btn--danger:hover { color: var(--ovk-bad-text); }
++
++/* ── Buttons that show what they do and what is happening (4.204.0) ───────────────────────────────────────────────
++ The owner, 2026-11-11: "improve our buttons across the site and add better animations and effects for them, and
++ different things we can use them for, and little indicators or statuses … idle animation … more important buttons …
++ more intuitive and more modular … shared across everything through openvibe.design". docs/kit.md#buttons.
++ Every motion is scaled by --ov-move (Calm makes it smaller and slower, Off removes it; the durations follow). */
++.ovk-btn, .ovk-icon-btn { position: relative; -webkit-tap-highlight-color: transparent;
++ transition: transform var(--ov-dur-1, .12s) var(--ov-ease-out, ease), box-shadow var(--ov-dur-2, .2s) var(--ov-ease-out, ease),
++ background-color var(--ov-dur-1, .12s), border-color var(--ov-dur-1, .12s), color var(--ov-dur-1, .12s), filter var(--ov-dur-1, .12s); }
++.ovk-btn:hover:not([disabled]):not([aria-disabled="true"]) { transform: translateY(calc(-1px * var(--ov-move, 1))); box-shadow: 0 8px 18px -12px rgba(0, 0, 0, .6); }
++.ovk-btn:active:not([disabled]), .ovk-icon-btn:active:not([disabled]) { transform: scale(calc(1 - .03 * var(--ov-move, 1))); box-shadow: none; transition-duration: .06s; }
++.ovk-btn .ovk-i, .ovk-icon-btn .ovk-i { transition: transform var(--ov-dur-2, .2s) var(--ov-ease-spring, var(--ov-ease-out, ease)); }
++/* The icon moves the way the button does (data-nudge): forward slides on, add turns, open lifts, down drops, refresh turns. */
++.ovk-btn[data-nudge="forward"]:hover .ovk-i:last-child, .ovk-icon-btn[data-nudge="forward"]:hover .ovk-i { transform: translateX(calc(3px * var(--ov-move, 1))); }
++.ovk-btn[data-nudge="back"]:hover .ovk-i:first-child, .ovk-icon-btn[data-nudge="back"]:hover .ovk-i { transform: translateX(calc(-3px * var(--ov-move, 1))); }
++.ovk-btn[data-nudge="add"]:hover .ovk-i:first-child, .ovk-icon-btn[data-nudge="add"]:hover .ovk-i { transform: rotate(calc(90deg * var(--ov-move, 1))); }
++.ovk-btn[data-nudge="open"]:hover .ovk-i:last-child, .ovk-icon-btn[data-nudge="open"]:hover .ovk-i { transform: translate(calc(2px * var(--ov-move, 1)), calc(-2px * var(--ov-move, 1))); }
++.ovk-btn[data-nudge="down"]:hover .ovk-i:last-child, .ovk-icon-btn[data-nudge="down"]:hover .ovk-i { transform: translateY(calc(2px * var(--ov-move, 1))); }
++.ovk-btn[data-nudge="refresh"]:hover .ovk-i:first-child, .ovk-icon-btn[data-nudge="refresh"]:hover .ovk-i { transform: rotate(calc(180deg * var(--ov-move, 1))); }
++
++/* Importance: hero (the one thing a page is for), soft (a tinted second choice), live (on air now). */
++.ovk-btn--hero { min-height: 52px; padding: 12px 24px; font-size: 1.02rem; color: var(--ovk-on-fill); border: 0;
++ background: linear-gradient(135deg, var(--ovk-accent), var(--ovk-fill)); box-shadow: 0 10px 26px -14px var(--ovk-fill); }
++.ovk-btn--hero:hover:not([disabled]) { box-shadow: 0 14px 30px -12px var(--ovk-fill); filter: brightness(1.06); }
++.ovk-btn--soft { color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); border-color: color-mix(in srgb, var(--ovk-accent) 22%, transparent); }
++.ovk-btn--soft:hover { background: color-mix(in srgb, var(--ovk-accent) 20%, transparent); }
++.ovk-btn--live { color: #fff; background: var(--ovk-live); }
++.ovk-btn--live::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #fff; flex: none; animation: ovkBreathe 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }
++@keyframes ovkBreathe { 50% { opacity: calc(1 - .65 * var(--ov-move, 1)); transform: scale(calc(1 - .2 * var(--ov-move, 1))); } }
++
++/* A toggle says it is on (aria-pressed): filled tint and a check-weight border. */
++.ovk-btn[aria-pressed="true"], .ovk-icon-btn[aria-pressed="true"] { color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 16%, transparent); border-color: color-mix(in srgb, var(--ovk-accent) 45%, transparent); }
++
++/* The one next step (data-attention): a soft ring goes out from it now and then, until it is pressed. */
++.ovk-btn[data-attention]:not([disabled])::after, .ovk-icon-btn[data-attention]:not([disabled])::after {
++ content: ""; position: absolute; inset: -1px; border-radius: inherit; pointer-events: none;
++ box-shadow: 0 0 0 0 color-mix(in srgb, var(--ovk-accent) 55%, transparent); animation: ovkAttention 3.2s var(--ov-ease-out, ease-out) infinite; }
++@keyframes ovkAttention { 0%, 55% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ovk-accent) 55%, transparent); } 85%, 100% { box-shadow: 0 0 0 calc(10px * var(--ov-move, 1)) transparent; } }
++
++/* A count (data-badge="3") and a status dot (data-dot="ok|warn|bad|live") at the top right. */
++.ovk-btn[data-badge]:not([data-badge=""])::before, .ovk-icon-btn[data-badge]:not([data-badge=""])::before {
++ content: attr(data-badge); position: absolute; top: -6px; right: -6px; min-width: 18px; height: 18px; padding: 0 5px; box-sizing: border-box;
++ border-radius: 999px; background: var(--ovk-bad); color: #fff; font-size: 11px; font-weight: 800; line-height: 18px; text-align: center;
++ box-shadow: 0 0 0 2px var(--ovk-bg, var(--bg-primary, #0b0f19)); animation: ovkPop var(--ov-dur-2, .2s) var(--ov-ease-spring, ease-out); }
++.ovk-btn--live[data-badge]::before { animation: ovkBreathe 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }
++.ovk-btn[data-dot], .ovk-icon-btn[data-dot] { --ovk-dot: var(--ovk-muted); }
++.ovk-btn[data-dot="ok"], .ovk-icon-btn[data-dot="ok"] { --ovk-dot: var(--ovk-ok); }
++.ovk-btn[data-dot="warn"], .ovk-icon-btn[data-dot="warn"] { --ovk-dot: var(--warning, #f59e0b); }
++.ovk-btn[data-dot="bad"], .ovk-icon-btn[data-dot="bad"], .ovk-btn[data-dot="live"], .ovk-icon-btn[data-dot="live"] { --ovk-dot: var(--ovk-bad); }
++.ovk-btn[data-dot]:not([data-badge])::before, .ovk-icon-btn[data-dot]:not([data-badge])::before {
++ content: ""; position: absolute; top: 4px; right: 4px; width: 8px; height: 8px; border-radius: 50%; background: var(--ovk-dot);
++ box-shadow: 0 0 0 2px var(--ovk-bg, var(--bg-primary, #0b0f19)); }
++.ovk-btn[data-dot="live"]:not([data-badge])::before, .ovk-icon-btn[data-dot="live"]:not([data-badge])::before { animation: ovkBreathe 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }
++@keyframes ovkPop { from { transform: scale(calc(1 - .6 * var(--ov-move, 1))); opacity: 0; } }
++
++/* Progress inside the button (style="--ovk-progress: .4"): an upload, a countdown, a hold. */
++.ovk-btn[style*="--ovk-progress"] { background-image: linear-gradient(90deg, color-mix(in srgb, currentColor 18%, transparent) calc(var(--ovk-progress, 0) * 100%), transparent 0); }
++
++/* What is happening (data-state, set by OpenVibeKitWeb.busy): busy keeps the words and adds the mark's wait; done shows
++ a check for a moment; error shakes once and turns red. */
++.ovk-btn[data-state="busy"] { cursor: progress; pointer-events: none; }
++.ovk-btn[data-state="busy"] > :not(.ovk-btn-wait) { opacity: .62; }
++.ovk-btn-wait { display: inline-flex; gap: 3px; align-items: center; }
++.ovk-btn-wait i { width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: ovkWait 1.1s var(--ov-ease-in-out, ease-in-out) infinite; }
++.ovk-btn-wait i:nth-child(2) { animation-delay: .15s; } .ovk-btn-wait i:nth-child(3) { animation-delay: .3s; }
++@keyframes ovkWait { 0%, 80%, 100% { opacity: .25; transform: translateY(0); } 40% { opacity: 1; transform: translateY(calc(-3px * var(--ov-move, 1))); } }
++.ovk-btn[data-state="done"] { color: #fff; background: var(--ovk-ok); border-color: transparent; }
++.ovk-btn[data-state="done"]::after { content: ""; flex: none; width: 16px; height: 16px; background: currentColor; animation: ovkPop var(--ov-dur-2, .2s) var(--ov-ease-spring, ease-out);
++ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12.5 4.5 4.5L19 7.5'/%3E%3C/svg%3E") center / contain no-repeat;
++ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12.5 4.5 4.5L19 7.5'/%3E%3C/svg%3E") center / contain no-repeat; }
++.ovk-btn[data-state="error"] { color: var(--ovk-bad-text); border-color: var(--ovk-bad); background: color-mix(in srgb, var(--ovk-bad) 10%, transparent); animation: ovkShake .36s var(--ov-ease-out, ease-out); }
++@keyframes ovkShake { 25% { transform: translateX(calc(-4px * var(--ov-move, 1))); } 50% { transform: translateX(calc(4px * var(--ov-move, 1))); } 75% { transform: translateX(calc(-2px * var(--ov-move, 1))); } }
++
++/* Hold to confirm (data-hold="1200" on a destructive button): the fill runs while it is held; letting go early cancels. */
++.ovk-btn[data-hold] { background-image: linear-gradient(90deg, color-mix(in srgb, var(--ovk-bad) 26%, transparent) var(--ovk-hold, 0%), transparent 0); }
++.ovk-btn[data-hold].is-holding { transition: none; }
++@media (hover: none) { .ovk-btn:hover:not([disabled]):not([aria-disabled="true"]) { transform: none; box-shadow: none; } }
.ovk-icon-btn .ovk-i { width: 20px; height: 20px; }
@media (pointer: coarse) { .ovk-icon-btn { width: var(--ovk-tap); height: var(--ovk-tap); } }
.ovk-badge { display: inline-flex; align-items: center; flex: none; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--ovk-line); font-size: .75rem; font-weight: 800; letter-spacing: .03em; text-transform: uppercase; color: var(--ovk-muted); white-space: nowrap; }
ddiff --git a/kit.js b/kit.js
iindex dcd57cb..a1fd868 100644
---- a/kit.js
++++ b/kit.js
@@@ -158,9 +158,22 @@
// ── Controls ──
/** A button or a link that looks like one. kind: 'primary' (default), 'quiet', 'danger', 'ghost'; small: a pill. */
-- function button({ href = null, type = 'submit', label = '', icon: ic = '', kind = 'primary', small = false, name = null, value = null, attrs: extra = null } = {}) {
++ // 4.204.0, buttons that show what they do and what is happening (docs/kit.md#buttons): kind 'hero' | 'soft' | 'live'
++ // beside primary/quiet/ghost/danger; nudge (forward, back, add, open, down, refresh) moves the icon by meaning;
++ // iconEnd puts it after the words; attention marks the one next step; badge a count; dot ok|warn|bad|live; hold
++ // (ms) makes a destructive button hold-to-confirm; pressed makes it a toggle.
++ function button({ href = null, type = 'submit', label = '', icon: ic = '', iconEnd = '', kind = 'primary', small = false, name = null, value = null, attrs: extra = null,
++ nudge = null, attention = false, badge = null, dot = null, hold = null, pressed = null } = {}) {
const cls = `ovk-btn ovk-btn--${esc(kind)}${small ? ' ovk-btn--small' : ''}`;
-- const inner = `${ic ? icon(ic) : ''}<span>${esc(label)}</span>`;
++ const more = { ...(extra || {}) };
++ if (nudge) more['data-nudge'] = nudge;
++ if (attention) more['data-attention'] = '';
++ if (badge != null && badge !== '' && badge !== 0) more['data-badge'] = Number(badge) > 99 ? '99+' : String(badge);
++ if (dot) more['data-dot'] = dot;
++ if (hold) more['data-hold'] = String(Math.max(400, Math.min(4000, Number(hold) || 1200)));
++ if (pressed != null) more['aria-pressed'] = pressed ? 'true' : 'false';
++ extra = Object.keys(more).length ? more : null;
++ const inner = `${ic ? icon(ic) : ''}<span>${esc(label)}</span>${iconEnd ? icon(iconEnd) : ''}`;
if (href != null) return `<a class="${cls}" href="${esc(url(href))}"${attrs(extra)}>${inner}</a>`;
return `<button class="${cls}" type="${type === 'button' ? 'button' : type === 'reset' ? 'reset' : 'submit'}"${name ? ` name="${esc(name)}"` : ''}${value != null ? ` value="${esc(value)}"` : ''}${attrs(extra)}>${inner}</button>`;
}
ddiff --git a/navbar.js b/navbar.js
iindex 90dfc9c..739c547 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -607,7 +607,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '8eec18660ff4', 'dock.js': '1e63c27fc63c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'ea69b8a08868', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'cca1c50e1cce', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '8eec18660ff4', 'dock.js': '31d1cac08de5', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'ea69b8a08868', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'e84894600223', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
ddiff --git a/ov-composer.js b/ov-composer.js
iindex 053ebac..61f4f0e 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -26,10 +26,72 @@
'use strict';
if (root.OpenVibeComposer || typeof document === 'undefined') return;
++ // 4.203.0: the composer carries its own look (it was in thread.css), so any box on any page gets it: the Dock's
++ // Vibe and Code boxes included. Injected once, before the first box is joined.
++ const CSS = `.ovc-files { display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 8px; margin: 0 0 2px -4px; scrollbar-width: thin; }
++.ovc-files[hidden] { display: none; }
++.ovc-file { position: relative; flex: none; display: flex; align-items: center; gap: 9px; max-width: 230px; height: 52px; padding: 5px 30px 5px 5px; box-sizing: border-box; border-radius: 14px;
++ border: 1px solid var(--ovt-line, var(--border, rgba(148, 163, 184, .18))); background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); overflow: hidden; animation: ovc-in .2s cubic-bezier(.2, .9, .3, 1) both; }
++.ovc-file.is-bad { border-color: color-mix(in srgb, var(--live-red, #ef4444) 70%, transparent); }
++.ovc-file.is-up::after { content: ""; position: absolute; left: 0; bottom: 0; height: 3px; width: calc(var(--p, 0) * 100%); background: var(--ovt-accent, var(--accent, #3b82f6)); transition: width .2s; }
++.ovc-file-th { flex: none; width: 40px; height: 40px; border-radius: 10px; object-fit: cover; background: #0b1220; }
++.ovc-file-ic { display: grid; place-items: center; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 16%, transparent); color: var(--ovt-accent, var(--accent, #3b82f6)); font-size: 11px; font-weight: 750; letter-spacing: .02em; }
++.ovc-file-t { min-width: 0; display: grid; line-height: 1.25; }
++.ovc-file-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; font-weight: 600; color: var(--text-primary, #e6edf7); }
++.ovc-file-s { font-size: 12px; color: var(--text-muted, #7386a3); white-space: nowrap; }
++.ovc-file.is-bad .ovc-file-s { color: var(--live-red, #ef4444); }
++.ovc-file-x, .ovc-file-b { position: absolute; top: 4px; right: 4px; display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; }
++.ovc-file-b { top: auto; bottom: 4px; }
++.ovc-file-x:hover, .ovc-file-b:hover, .ovc-file-x:focus-visible, .ovc-file-b:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 10%, transparent)); color: var(--text-primary, #e6edf7); }
++.ovc-file-x svg, .ovc-file-b svg { width: 15px; height: 15px; }
++.ovc-dropping { border-color: var(--ovt-accent, var(--accent, #3b82f6)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 30%, transparent), 0 8px 30px rgba(0, 0, 0, .25); }
++.ovc-dropping::before { content: "Drop to attach"; position: absolute; inset: 0; z-index: 5; display: grid; place-items: center; border-radius: inherit; background: color-mix(in srgb, var(--bg-card, #131c2e) 86%, var(--ovt-accent, var(--accent, #3b82f6))); color: var(--text-primary, #e6edf7); font-weight: 650; pointer-events: none; }
++.ovc-pop { position: absolute; z-index: 40; left: 6px; right: 6px; bottom: calc(100% + 8px); max-height: min(320px, 50vh); overflow: auto; padding: 6px; border-radius: 16px; border: 1px solid var(--ovt-line, var(--border, rgba(148, 163, 184, .18)));
++ background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); animation: ovc-in .16s cubic-bezier(.2, .9, .3, 1) both; }
++.ovt.is-start .ovc-pop { bottom: auto; top: calc(100% + 8px); }
++.ovc-pop[hidden] { display: none; }
++.ovc-pop-i { display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 6px 10px; box-sizing: border-box; border: 0; border-radius: 11px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
++.ovc-pop-i[aria-selected="true"], .ovc-pop-i:hover { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); }
++.ovc-pop-ic, .ovc-pop-av { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; color: var(--ovt-accent, var(--accent, #3b82f6)); background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 12%, transparent); object-fit: cover; }
++.ovc-pop-av { border-radius: 50%; }
++.ovc-pop-ic svg { width: 17px; height: 17px; }
++.ovc-pop-t { min-width: 0; display: grid; line-height: 1.3; }
++.ovc-pop-t b { font-weight: 650; font-size: 14px; color: var(--text-primary, #e6edf7); }
++.ovc-pop-t span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text-muted, #7386a3); }
++.ovc-pop-tag { font-size: 11px; font-weight: 650; padding: 2px 7px; border-radius: 999px; color: var(--ovt-accent, var(--accent, #3b82f6)); background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 12%, transparent); }
++.ovc-mic[aria-pressed="true"] { color: var(--live-red, #ef4444); background: color-mix(in srgb, var(--live-red, #ef4444) 14%, transparent); animation: ovc-listen 1.4s ease-in-out infinite; }
++.ovc-count { flex: none; font-size: 12px; font-variant-numeric: tabular-nums; color: var(--text-muted, #7386a3); }
++.ovc-count.is-low { color: var(--live-red, #ef4444); }
++.ovc-count[hidden] { display: none; }
++.ovt-composer.ovc-compact { padding: 6px 8px 6px 12px; border-radius: 18px; }
++.ovt-composer.ovc-compact .ovt-c-row { margin-top: 0; }
++.ovt-composer.ovc-compact .ovt-btn, .ovt-composer.ovc-compact .ovt-send { width: 36px; min-width: 36px; height: 36px; }
++@keyframes ovc-in { from { opacity: 0; transform: translateY(4px) scale(.98); } }
++@keyframes ovc-listen { 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--live-red, #ef4444) 18%, transparent); } }
++html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data-ov-motion=off] .ovc-mic { animation: none; }
++.ovc-set { width: min(460px, calc(100vw - 24px)); max-height: min(640px, calc(100vh - 32px)); padding: 0; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .2)); border-radius: 20px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); box-shadow: 0 30px 80px rgba(0, 0, 0, .5); }
++.ovc-set::backdrop { background: rgba(2, 6, 14, .55); }
++.ovc-set.ovc-set-in { width: auto; max-height: none; border: 0; border-radius: 0; background: none; box-shadow: none; }
++.ovc-set-in .ovc-set-f { padding: 0; }
++.ovc-set-f { display: grid; gap: 14px; padding: 6px 18px 18px; }
++.ovc-set-h { display: flex; align-items: center; justify-content: space-between; margin: 0 -8px 0 0; font-size: 17px; }
++.ovc-set-g { margin: 0; padding: 0; border: 0; display: grid; gap: 6px; }
++.ovc-set-g legend { padding: 0 0 6px; font-weight: 650; font-size: 14px; }
++.ovc-set label { display: flex; align-items: center; gap: 10px; min-height: 40px; font-size: 14.5px; cursor: pointer; }
++.ovc-set small, .ovc-set-p { color: var(--text-muted, #7386a3); font-size: 12.5px; margin: 0; }
++.ovc-set input[type=radio], .ovc-set input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--accent, #3b82f6); }
++.ovc-set-l { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
++.ovc-set-l li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; padding: 4px 4px 4px 10px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); }
++.ovc-set-l span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary, #b4c0d4); font-size: 13px; }
++.ovc-set-add { display: grid; grid-template-columns: 110px 1fr auto; gap: 8px; align-items: start; }
++.ovc-set-add input, .ovc-set-add textarea { box-sizing: border-box; width: 100%; padding: 9px 10px; border-radius: 10px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .25)); background: var(--bg-input, rgba(2, 6, 14, .35)); color: inherit; font: inherit; font-size: 14px; resize: vertical; }
++.ovc-set-btn { height: 40px; padding: 0 14px; border: 0; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: inherit; font-weight: 650; cursor: pointer; }
++@media (max-width: 480px) { .ovc-set-add { grid-template-columns: 1fr; } .ovc-file { max-width: 200px; } }`;
++ function styles() { if (document.getElementById('ovc-styles')) return; const st = document.createElement('style'); st.id = 'ovc-styles'; st.textContent = CSS; document.head.appendChild(st); }
const mods = [];
// 4.202.0: your settings open in the one window (ov-window.js: a sheet on a phone, swiped down to close), loaded
// from beside this file; without it, a plain <dialog>.
-- const COMPOSER_REV = { 'ov-window.js': 'c882d11d85ff' };
++ const COMPOSER_REV = { 'ov-window.js': '625783527b4e' };
const selfSrc = (document.currentScript && document.currentScript.src) || '';
function needWindow() {
return new Promise((ok) => {
@@@ -102,6 +164,7 @@
// ── One box.
function enhance(form) {
if (!form || form.__ovc) return form && form.__ovc;
++ styles();
const text = form.querySelector('[data-ovt-text]') || form.querySelector('textarea');
if (!text) return null;
const row = form.querySelector('.ovt-c-row');
@@@ -111,11 +174,14 @@
const inputs = () => [...form.querySelectorAll('input[type="file"]')];
const mine = () => mods.filter((m) => !m.match || m.match(form));
const uploader = () => { const m = mine().find((x) => typeof x.upload === 'function'); return m ? m.upload : null; };
-- const canFile = () => !!(inputs().length || uploader());
++ // data-ovc-no-files: the box handles its own files (the Dock's Vibe box uploads a picture as it is picked).
++ const noFiles = form.hasAttribute('data-ovc-no-files');
++ const canFile = () => !noFiles && !!(inputs().length || uploader());
const maxFiles = Number(form.getAttribute('data-ovc-max-files')) || 10;
const maxBytes = (Number(form.getAttribute('data-ovc-max-mb')) || 25) * 1048576;
const files = [];
let quiet = '';
++ const wasRequired = !!text.required; // a box that was not required stays so (the Dock's Vibe box)
const say = (s) => { if (!note) return; if (!quiet) quiet = note.textContent; note.textContent = s; clearTimeout(say.t); say.t = setTimeout(() => { note.textContent = quiet; quiet = ''; }, 5000); };
const fire = () => text.dispatchEvent(new Event('input', { bubbles: true }));
@@@ -143,6 +209,7 @@
return acc.split(',').map((x) => x.trim().toLowerCase()).some((a) => !a || (a.startsWith('.') ? name.endsWith(a) : a.endsWith('/*') ? type.startsWith(a.slice(0, -1)) : type === a));
};
function syncInputs() {
++ if (noFiles) return;
if (uploader()) { inputs().forEach((i) => { i.value = ''; }); return; }
const [first, ...rest] = inputs();
if (!first || typeof DataTransfer === 'undefined') return;
@@@ -210,7 +277,7 @@
else if (e.target.closest('[data-ovc-back]')) { const t = f.text; remove(f); ctx.insert(t); }
});
form.addEventListener('change', (e) => {
-- const i = e.target; if (!i || i.type !== 'file' || i.__ovcSync) return;
++ const i = e.target; if (noFiles || !i || i.type !== 'file' || i.__ovcSync) return;
const picked = [...(i.files || [])];
i.__ovcSync = true; i.value = ''; i.__ovcSync = false;
add(picked);
@@@ -243,8 +310,8 @@
function syncSend() {
if (!send || form.classList.contains('is-busy')) return;
send.disabled = busy() || (!text.value.trim() && !files.length);
-- if (text.required && files.length) text.required = false;
-- else if (!files.length && !text.required && !form.classList.contains('is-busy')) text.required = true;
++ if (wasRequired && text.required && files.length) text.required = false;
++ else if (wasRequired && !files.length && !text.required && !form.classList.contains('is-busy')) text.required = true;
}
text.addEventListener('input', syncSend);
ddiff --git a/ov-thread.js b/ov-thread.js
iindex a490d5d..56661fb 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
-- const THREAD_REV = { 'ov-composer.js': 'bab301d99cb6', 'ov-combo.js': '3e426e56fc75' };
++ const THREAD_REV = { 'ov-composer.js': 'f52e2323e6e9', 'ov-combo.js': '3e426e56fc75' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
ddiff --git a/ov-user.js b/ov-user.js
iindex 8ccb713..259eba6 100644
---- a/ov-user.js
++++ b/ov-user.js
@@@ -14,7 +14,7 @@
})(typeof window !== 'undefined' ? window : globalThis, function (root) {
'use strict';
const NETWORK = 'https://openvibe.network';
-- const USER_REV = { 'ov-window.js': 'c882d11d85ff' };
++ const USER_REV = { 'ov-window.js': '625783527b4e' };
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const CSS = `.ovu { position: relative; display: grid; gap: 14px; padding: 0 0 18px; color: var(--text-primary, #e6edf7); font: 500 14.5px/1.4 var(--font, Inter, system-ui, sans-serif); }
.ovu-banner { position: relative; height: 92px; overflow: hidden; background: linear-gradient(135deg, hsl(var(--ovu-h, 220) 70% 42%), hsl(calc(var(--ovu-h, 220) + 40) 70% 28%)); }
ddiff --git a/ov-wallet.js b/ov-wallet.js
iindex f14ba19..b319725 100644
---- a/ov-wallet.js
++++ b/ov-wallet.js
@@@ -33,7 +33,7 @@
// 4.202.0 (asks 304 and 318): the wallet sits in the one window (ov-window.js, loaded from beside this file): the
// same motion, keys, phone sheet and window style as every other window; its own <dialog> where that is missing.
-- const WALLET_REV = { 'ov-window.js': 'c882d11d85ff' };
++ const WALLET_REV = { 'ov-window.js': '625783527b4e' };
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
function needWindow() {
return new Promise((ok) => {
ddiff --git a/ov-window.js b/ov-window.js
iindex 865d2e6..7e2bef1 100644
---- a/ov-window.js
++++ b/ov-window.js
@@@ -15,6 +15,9 @@
* label, onClose, bare, width }); bare: the content draws its own head (the title is still
* read); width: px, for content that wants its own
* w.el, w.body, w.foot, w.setTitle(text), w.close()
++ * A panel can be resizable (4.204.0, the owner: a robot's controls "can be popped out … and resized"): { resizable: true,
++ * minWidth, minHeight, onResize({ width, height }) } adds a grip at its corner that a mouse, a finger or a pen drags and
++ * the arrow keys move (Shift for bigger steps, Enter for the first size); the size is kept with its place per id.
* A site's content goes in as nodes (or text, set as text): the window never parses markup it is given.
*/
(function (root) {
@@@ -52,6 +55,12 @@ html[data-ov-window="outline"] { --ovw-bg: var(--bg-primary, #0b1220); --ovw-blu
.ovw.is-sheet .ovw-h { position: relative; padding-top: 16px; touch-action: none; }
.ovw.is-sheet .ovw-h::before { content: ""; position: absolute; left: 50%; top: 6px; width: 38px; height: 4px; margin-left: -19px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 28%, transparent); }
.ovw.is-sheet.is-dragging { animation: none; }
++.ovw.is-resizable { max-height: none; }
++.ovw-grip { position: absolute; right: 2px; bottom: 2px; z-index: 2; width: 26px; height: 26px; padding: 0; border: 0; border-radius: 8px; background: none; cursor: nwse-resize; touch-action: none; color: var(--text-secondary, #a8b3c4); }
++.ovw-grip::before { content: ""; position: absolute; right: 6px; bottom: 6px; width: 10px; height: 10px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; border-radius: 0 0 3px 0; opacity: .55; }
++.ovw-grip:hover::before, .ovw-grip:focus-visible::before, .ovw.is-resizing .ovw-grip::before { opacity: 1; color: var(--accent, #3b82f6); }
++.ovw-grip:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
++.ovw.is-resizing { animation: none; transition: none; user-select: none; }
.ovw { --ovw-w: 520px; }
.ovw.is-sm { --ovw-w: 380px; }
.ovw.is-lg { --ovw-w: 760px; }
@@@ -177,7 +186,7 @@ html.ovw-locked { overflow: hidden; }
const up = (ev) => {
head.removeEventListener('pointermove', move); head.removeEventListener('pointerup', up); head.removeEventListener('pointercancel', up);
el.classList.remove('is-dragging');
-- if (kind === 'panel') { if (moved && o.id) { const b = el.getBoundingClientRect(); place.set(o.id, { x: Math.round(b.left), y: Math.round(b.top) }); } return; }
++ if (kind === 'panel') { if (moved && o.id) { const b = el.getBoundingClientRect(); place.set(o.id, Object.assign(place.get(o.id) || {}, { x: Math.round(b.left), y: Math.round(b.top) })); } return; }
const dy = ev.clientY - sy;
if (moved && dy > Math.min(140, r.height * 0.28)) w.close('swipe');
else { el.style.transition = 'transform .2s cubic-bezier(.2, 1, .3, 1)'; el.style.transform = ''; setTimeout(() => { el.style.transition = ''; }, 220); }
@@@ -186,11 +195,56 @@ html.ovw-locked { overflow: hidden; }
});
}
++ if (kind === 'panel' && o.resizable) resizable(el, o);
++
const first = o.focus ? body.querySelector(o.focus) : focusables(body)[0];
(first || x).focus({ preventScroll: true });
return w;
}
++ /** A panel's corner grip: pointer drag (mouse, finger, pen) and the arrow keys; the size is kept with its place. */
++ function resizable(el, o) {
++ el.classList.add('is-resizable');
++ const minW = Math.max(200, Number(o.minWidth) || 260), minH = Math.max(120, Number(o.minHeight) || 160);
++ const grip = document.createElement('button');
++ grip.type = 'button'; grip.className = 'ovw-grip';
++ grip.setAttribute('aria-label', 'Resize: drag, or use the arrow keys');
++ el.append(grip);
++ const first = { w: el.offsetWidth, h: el.offsetHeight };
++ const set = (w, h, save) => {
++ const r = el.getBoundingClientRect();
++ const W = Math.round(Math.max(minW, Math.min(w, root.innerWidth - r.left - 8)));
++ const H = Math.round(Math.max(minH, Math.min(h, root.innerHeight - r.top - 8)));
++ el.style.width = `${W}px`; el.style.height = `${H}px`;
++ if (save && o.id) place.set(o.id, Object.assign(place.get(o.id) || {}, { x: Math.round(r.left), y: Math.round(r.top), w: W, h: H }));
++ if (typeof o.onResize === 'function') { try { o.onResize({ width: W, height: H }); } catch { /* the page's own */ } }
++ };
++ const saved = o.id ? place.get(o.id) : null;
++ if (saved && saved.w && saved.h) set(saved.w, saved.h, false);
++ grip.addEventListener('pointerdown', (e) => {
++ if (e.button !== undefined && e.button !== 0) return;
++ e.preventDefault();
++ const sx = e.clientX, sy = e.clientY, w0 = el.offsetWidth, h0 = el.offsetHeight;
++ el.classList.add('is-resizing');
++ try { grip.setPointerCapture(e.pointerId); } catch { /* */ }
++ const move = (ev) => set(w0 + ev.clientX - sx, h0 + ev.clientY - sy, false);
++ const up = () => {
++ grip.removeEventListener('pointermove', move); grip.removeEventListener('pointerup', up); grip.removeEventListener('pointercancel', up);
++ el.classList.remove('is-resizing');
++ set(el.offsetWidth, el.offsetHeight, true);
++ };
++ grip.addEventListener('pointermove', move); grip.addEventListener('pointerup', up); grip.addEventListener('pointercancel', up);
++ });
++ grip.addEventListener('keydown', (e) => {
++ const step = e.shiftKey ? 80 : 24;
++ const d = { ArrowRight: [step, 0], ArrowLeft: [-step, 0], ArrowDown: [0, step], ArrowUp: [0, -step] }[e.key];
++ if (e.key === 'Enter' || e.key === 'Home') { e.preventDefault(); set(first.w, first.h, true); return; }
++ if (!d) return;
++ e.preventDefault();
++ set(el.offsetWidth + d[0], el.offsetHeight + d[1], true);
++ });
++ }
++
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape' || !stack.length || e.defaultPrevented) return;
e.preventDefault();
ddiff --git a/package-lock.json b/package-lock.json
iindex aa5a23a..9231a02 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.203.0",
++ "version": "4.204.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.203.0",
++ "version": "4.204.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex de50936..a5ba70e 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.203.0",
++ "version": "4.204.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -127,6 +127,7 @@
"./player": "./player.js",
"./vibes-pay": "./vibes-pay.js",
"./trace": "./trace.js",
++ "./csp": "./csp.js",
"./ui": "./ui.js",
"./url-resolver": "./url-resolver.js",
"./ov-user": "./ov-user.js",
ddiff --git a/player.js b/player.js
iindex 142f884..696868c 100644
---- a/player.js
++++ b/player.js
@@@ -3,7 +3,7 @@
* video players across all our sites and vods … for a more unified look").
*
* <link rel="stylesheet" href="/shared/player.css">
-- * <video data-ov-player controls src="…" poster="…" preload="metadata" playsinline></video>
++ * <video data-ov-player controls src="…" poster="…" preload="none" playsinline></video>
* <script src="/shared/player.js" defer></script>
*
* Progressive: without this script the browser's own controls stay. With it, each video[data-ov-player] is wrapped
@@@ -207,8 +207,131 @@
} catch { /* an action this browser does not know */ }
}
++ // Startup policy is shared with pages that own their HLS attachment. Unknown connections use the fast policy.
++ const nav = typeof navigator === 'undefined' ? {} : navigator;
++ let battery = null;
++ if (nav.getBattery) Promise.resolve().then(() => nav.getBattery()).then((b) => { battery = b; }).catch(() => {});
++ function playbackPolicy(connection = nav.connection || {}, power = battery) {
++ const slow = connection.saveData || /^(slow-)?2g$/.test(connection.effectiveType || '');
++ const limited = slow || connection.effectiveType === '3g';
++ const lowBattery = !!power && !power.charging && power.level <= 0.2;
++ return {
++ preload: slow ? 'none' : limited ? 'metadata' : 'auto',
++ warm: !limited && !lowBattery,
++ hls: { startLevel: 0, maxBufferLength: lowBattery ? 15 : limited ? 30 : 60,
++ maxMaxBufferLength: lowBattery ? 30 : limited ? 60 : 120,
++ backBufferLength: lowBattery || slow ? 0 : limited ? 15 : 30 },
++ };
++ }
++ /** Attach using the page's Hls constructor; native HLS keeps the same connection-aware preload. */
++ function attachHls(video, Hls, url, options = {}) {
++ const policy = playbackPolicy();
++ video.preload = policy.preload;
++ if (!Hls || !Hls.isSupported()) {
++ if (video.canPlayType('application/vnd.apple.mpegurl')) video.src = url;
++ return null;
++ }
++ const hls = new Hls({ ...options, ...policy.hls, autoStartLoad: policy.preload === 'auto' });
++ let loading = policy.preload === 'auto';
++ const start = () => { if (!loading) { loading = true; hls.startLoad(); } };
++ const update = () => {
++ video.preload = playbackPolicy().preload;
++ Object.assign(hls.config, playbackPolicy().hls);
++ if (video.paused && video.preload !== 'auto' && loading) { hls.stopLoad(); loading = false; }
++ };
++ video.addEventListener('play', start);
++ if (nav.connection && nav.connection.addEventListener) nav.connection.addEventListener('change', update);
++ // getBattery is optional and async; register only while this attachment is alive.
++ let disposed = false, power = null;
++ if (nav.getBattery) Promise.resolve().then(() => nav.getBattery()).then((b) => {
++ if (disposed) return;
++ power = battery = b; update();
++ b.addEventListener('levelchange', update); b.addEventListener('chargingchange', update);
++ }).catch(() => {});
++ hls.on(Hls.Events.DESTROYING, () => {
++ disposed = true; video.removeEventListener('play', start);
++ if (nav.connection && nav.connection.removeEventListener) nav.connection.removeEventListener('change', update);
++ if (power) { power.removeEventListener('levelchange', update); power.removeEventListener('chargingchange', update); }
++ });
++ hls.loadSource(url); hls.attachMedia(video);
++ if (!video.paused) start();
++ return hls;
++ }
++ // Warm only explicit public HLS card sources, at most 20 per minute and 2 MiB per card. No whole-file prefetch.
++ const warmed = new Map();
++ async function warmHls(url, signal) {
++ if (!playbackPolicy().warm || typeof fetch !== 'function') return false;
++ const now = Date.now();
++ for (const [key, at] of warmed) if (now - at >= 60000) warmed.delete(key);
++ const safe = (value, base) => {
++ const u = new URL(value, base || undefined);
++ if (!/^https?:$/.test(u.protocol) || u.username || u.password || /(?:sig|token|key|exp|signature)/i.test(u.search)) throw new Error('Only public playlists are warmed');
++ return u.href;
++ };
++ let source;
++ try { source = safe(url, document.baseURI); } catch { return false; }
++ if (warmed.has(source) || warmed.size >= 20) return false;
++ warmed.set(source, now);
++ let remaining = 2 * 1024 * 1024;
++ const read = async (u, manifest = false) => {
++ const response = await fetch(u, { signal, credentials: 'same-origin', cache: 'force-cache' });
++ if (!response.ok || !response.body) throw new Error('Not cacheable');
++ const reader = response.body.getReader(), chunks = [];
++ let size = 0;
++ try {
++ for (;;) {
++ const { done, value } = await reader.read(); if (done) break;
++ size += value.byteLength; remaining -= value.byteLength;
++ if (remaining < 0 || (manifest && size > 65536)) throw new Error('Warm budget');
++ if (manifest) chunks.push(value);
++ }
++ } finally { await reader.cancel().catch(() => {}); }
++ if (!manifest) return '';
++ const bytes = new Uint8Array(size); let offset = 0;
++ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
++ return new TextDecoder().decode(bytes);
++ };
++ try {
++ let body = await read(source, true);
++ if (!body.startsWith('#EXTM3U') || /#EXT-X-(?:KEY|SESSION-KEY):|BYTERANGE[=:]/.test(body)) return false;
++ if (body.includes('#EXT-X-STREAM-INF:')) {
++ const lines = body.split(/\r?\n/), variants = [];
++ lines.forEach((line, i) => {
++ if (line.startsWith('#EXT-X-STREAM-INF:') && lines[i + 1] && !lines[i + 1].startsWith('#'))
++ variants.push({ bandwidth: Number((line.match(/(?:^|,)BANDWIDTH=(\d+)/) || line.match(/:BANDWIDTH=(\d+)/) || [])[1]) || Infinity, uri: lines[i + 1] });
++ });
++ variants.sort((a, b) => a.bandwidth - b.bandwidth);
++ if (!variants.length) return false;
++ source = safe(variants[0].uri, source); body = await read(source, true);
++ }
++ // Live playlists, encrypted media and byte-range playlists need their player's own loader.
++ if (!body.includes('#EXT-X-ENDLIST') || /#EXT-X-(?:KEY|SESSION-KEY):|BYTERANGE[=:]/.test(body)) return false;
++ const init = body.match(/#EXT-X-MAP:URI="([^"]+)"/);
++ const segment = body.split(/\r?\n/).find((line) => line && !line.startsWith('#'));
++ if (init) await read(safe(init[1], source));
++ if (segment) await read(safe(segment, source));
++ return !!segment;
++ } catch { return false; }
++ }
++ function warmCards(scope = document) {
++ scope.querySelectorAll('[data-ov-player-hls]').forEach((card) => {
++ if (card.__ovWarm) return; card.__ovWarm = true;
++ let controller = null, timer = null;
++ const cancel = () => { clearTimeout(timer); if (controller) controller.abort(); };
++ const begin = () => {
++ cancel();
++ if (!playbackPolicy().warm || typeof AbortController !== 'function') return;
++ controller = new AbortController();
++ timer = setTimeout(() => warmHls(card.getAttribute('data-ov-player-hls'), controller.signal), 120);
++ };
++ card.addEventListener('pointerenter', begin); card.addEventListener('focusin', begin);
++ card.addEventListener('pointerleave', cancel); card.addEventListener('focusout', cancel);
++ });
++ }
++
function enhance(video, options = {}) {
if (!video || video.__ovPlayer) return video && video.__ovPlayer;
++ video.preload = playbackPolicy().preload;
const idleMs = options.idleMs || 2500;
const root = el('div', 'ov-player is-paused is-start', { tabindex: '0', role: 'region', 'aria-label': options.label || video.getAttribute('aria-label') || 'Video player' });
video.parentNode.insertBefore(root, video);
@@@ -236,7 +359,14 @@
root.insertBefore(posterLayer, video);
root.insertBefore(audioLayer, video);
const shown = () => root.classList.add('has-frame');
-- if (video.readyState >= 2) shown(); else video.addEventListener('loadeddata', shown, { once: true });
++ if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(shown);
++ else if (video.readyState >= 2) shown();
++ else video.addEventListener('loadeddata', shown, { once: true });
++ video.addEventListener('emptied', () => {
++ root.classList.remove('has-frame');
++ if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(shown);
++ else video.addEventListener('loadeddata', shown, { once: true });
++ });
// ── Build ────────────────────────────────────────────
const big = button('ov-player-big', 'Play', ICON.play);
@@@ -605,9 +735,10 @@
function enhanceAll(scope) {
(scope || document).querySelectorAll('video[data-ov-player]').forEach((v) => enhance(v));
++ warmCards(scope || document);
}
-- window.OpenVibePlayer = { enhance, enhanceAll, format: fmt };
++ window.OpenVibePlayer = { enhance, enhanceAll, format: fmt, playbackPolicy, attachHls, warmHls, warmCards };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => enhanceAll());
else enhanceAll();
})();
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex 4aa8cfa..63dd38a 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -27,7 +27,7 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-code.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js', 'dock-chat-edits.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
-- 'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'ov-thread.js': { name: 'THREAD_REV', files: ['ov-composer.js', 'ov-combo.js'] }, 'ov-composer.js': { name: 'COMPOSER_REV', files: ['ov-window.js'] }, 'ov-user.js': { name: 'USER_REV', files: ['ov-window.js'] }, 'ov-wallet.js': { name: 'WALLET_REV', files: ['ov-window.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };
++ 'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'ov-thread.js': { name: 'THREAD_REV', files: ['ov-composer.js', 'ov-combo.js'] }, 'ov-composer.js': { name: 'COMPOSER_REV', files: ['ov-window.js'] }, 'ov-user.js': { name: 'USER_REV', files: ['ov-window.js'] }, 'ov-wallet.js': { name: 'WALLET_REV', files: ['ov-window.js'] }, 'dock-vibe.js': { name: 'VIBE_REV', files: ['ov-composer.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };
const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;
function writeOwn() {
for (const [file, { name, files }] of Object.entries(OWN)) {
ddiff --git a/test/composer-options-chrome.test.js b/test/composer-options-chrome.test.js
iindex e7ff711..4548215 100644
---- a/test/composer-options-chrome.test.js
++++ b/test/composer-options-chrome.test.js
@@@ -5,6 +5,7 @@
// closes it, a changed picker shows as a chip whose × puts the default back, the placeholder walks through the examples,
// and a kit sidebar's button moves into the navbar on a phone, where the empty head row goes away. Skipped without Chrome.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -51,8 +52,8 @@ const server = http.createServer((req, res) => {
await p.goto(`${base}/`, { maxMs: 6000 });
await until(p, "!!document.querySelector('[data-ovt-opts]')", 'the Options button');
// Only the button and Send in the bar; the pickers are behind it.
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-panel').checkVisibility()"), false, 'the pickers start closed');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-dot').hidden"), true, 'no dot while everything is the default');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-panel').checkVisibility()"), false, 'the pickers start closed'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-dot').hidden"), true, 'no dot while everything is the default'));
// Open: a sheet at the bottom of a phone, the page behind does not scroll.
await p.evaluate("document.querySelector('.ovt-opts > summary').click()");
await until(p, "document.querySelector('[data-ovt-opts]').open", 'it opens');
@@@ -63,16 +64,16 @@ const server = http.createServer((req, res) => {
assert.ok(sheet.locked, 'the page behind stays put');
// Change a picker: a chip shows beside the button, and the dot.
await p.evaluate("(() => { const s = document.querySelector('select[name=who]'); s.value = 'codex'; s.dispatchEvent(new Event('change', { bubbles: true })); })()");
-- assert.strictEqual(await p.evaluate("[...document.querySelectorAll('.ovt-opt-chip > span')].map((s) => s.textContent).join()"), 'Codex', 'the changed one is a chip');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-dot').hidden"), false, 'and the button wears a dot');
++ await eventually(async () => assert.strictEqual(await p.evaluate("[...document.querySelectorAll('.ovt-opt-chip > span')].map((s) => s.textContent).join()"), 'Codex', 'the changed one is a chip'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-dot').hidden"), false, 'and the button wears a dot'));
// A tap outside closes it and unlocks the page.
await p.evaluate("(() => { const e = new PointerEvent('pointerdown', { bubbles: true }); document.querySelector('h1').dispatchEvent(e); })()");
await until(p, "!document.querySelector('[data-ovt-opts]').open", 'a tap outside closes it');
-- assert.strictEqual(await p.evaluate("document.documentElement.classList.contains('ovt-sheet-open')"), false, 'the page scrolls again');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.documentElement.classList.contains('ovt-sheet-open')"), false, 'the page scrolls again'));
// The chip's × puts the default back.
await p.evaluate("document.querySelector('.ovt-opt-chip-x').click()");
-- assert.strictEqual(await p.evaluate("document.querySelector('select[name=who]').value"), '', 'the default is back');
-- assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovt-opt-chip').length"), 0, 'and the chip is gone');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('select[name=who]').value"), '', 'the default is back'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovt-opt-chip').length"), 0, 'and the chip is gone'));
// The examples walk through the placeholder while the box is empty.
await until(p, "document.querySelector('[data-ovt-text]').placeholder !== 'What should change?'", 'the placeholder moves to an example', 6000);
// The sidebar button: in the navbar before the brand, and the head row with nothing else in it is gone.
@@@ -83,23 +84,23 @@ const server = http.createServer((req, res) => {
assert.strictEqual(lead.head, 'none', 'the empty head row goes');
await p.evaluate("document.getElementById('ovnav-lead').click()");
await until(p, "document.getElementById('ch-rail').classList.contains('is-open')", 'the slot opens the drawer');
-- assert.strictEqual(await p.evaluate("document.getElementById('ovnav-lead').getAttribute('aria-expanded')"), 'true', 'and says it is open');
-- assert.ok(await p.evaluate('document.documentElement.scrollWidth <= 390'), 'no sideways scroll');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ovnav-lead').getAttribute('aria-expanded')"), 'true', 'and says it is open'));
++ await eventually(async () => assert.ok(await p.evaluate('document.documentElement.scrollWidth <= 390'), 'no sideways scroll'));
// A site's own action on the right, by the API.
await p.evaluate("OpenVibeNavbar.action({ id: 'help', icon: 'info', label: 'Help', badge: 2, onClick: () => { window.__help = (window.__help || 0) + 1; } })");
await p.evaluate("document.getElementById('ovnav-act-help').click()");
-- assert.strictEqual(await p.evaluate('window.__help'), 1, 'an action button calls its handler');
-- assert.strictEqual(await p.evaluate("document.querySelector('#ovnav-act-help b').textContent"), '2', 'and shows its count');
++ await eventually(async () => assert.strictEqual(await p.evaluate('window.__help'), 1, 'an action button calls its handler'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('#ovnav-act-help b').textContent"), '2', 'and shows its count'));
// On a computer: the panel opens beside the button, not as a sheet, and the navbar slot is not shown.
const d = await h.openPage(browser, context, { width: 1280, height: 900, block: ['*openvibe.network*', '*openvibe.tools*'] });
await d.goto(`${base}/`, { maxMs: 6000 });
await until(d, "!!document.querySelector('[data-ovt-opts]')", 'the Options button on a computer');
await d.evaluate("document.querySelector('.ovt-opts > summary').click()");
await wait(250);
-- assert.strictEqual(await d.evaluate("getComputedStyle(document.querySelector('.ovt-opts-panel')).position"), 'absolute', 'a panel on a computer');
-- assert.strictEqual(await d.evaluate("document.documentElement.classList.contains('ovt-sheet-open')"), false, 'the page is not locked for a panel');
++ await eventually(async () => assert.strictEqual(await d.evaluate("getComputedStyle(document.querySelector('.ovt-opts-panel')).position"), 'absolute', 'a panel on a computer'));
++ await eventually(async () => assert.strictEqual(await d.evaluate("document.documentElement.classList.contains('ovt-sheet-open')"), false, 'the page is not locked for a panel'));
await until(d, "!!document.getElementById('ovnav-lead')", 'the slot exists');
-- assert.strictEqual(await d.evaluate("getComputedStyle(document.getElementById('ovnav-lead')).display"), 'none', 'but hides where the sidebar is always shown');
++ await eventually(async () => assert.strictEqual(await d.evaluate("getComputedStyle(document.getElementById('ovnav-lead')).display"), 'none', 'but hides where the sidebar is always shown'));
console.log('composer-options-chrome: one Options button, a sheet on a phone, chips for changes, examples, the sidebar button in the navbar, actions by API');
} finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/csp.test.js b/test/csp.test.js
nnew file mode 100644
iindex 0000000..da3ebe2
---- /dev/null
++++ b/test/csp.test.js
@@@ -0,0 +1,23 @@
++'use strict';
++// openvibe-shared/csp: connect-src names Media (the composer's upload origin) and Network by default, a site can add
++// its own origins, and policy() is a complete CSP string.
++const assert = require('assert');
++const csp = require('../csp');
++
++const src = csp.connectSources();
++assert.ok(src.startsWith("'self'"));
++for (const origin of ['https://openvibe.network', 'wss://openvibe.network', 'https://openvibe.events', 'wss://openvibe.events', 'https://openvibe.media']) {
++ assert.ok(src.split(' ').includes(origin), `connect-src names ${origin}`);
++}
++assert.deepStrictEqual(csp.connectSources({ self: false, sites: ['wss://openvibe.chat'], extra: ['https://cloudflareinsights.com'] }).split(' '), [
++ 'https://openvibe.network', 'wss://openvibe.network', 'https://openvibe.events', 'wss://openvibe.events', 'https://openvibe.media', 'wss://openvibe.chat', 'https://cloudflareinsights.com',
++]);
++assert.strictEqual(csp.MEDIA_UPLOAD_ORIGIN, 'https://openvibe.media');
++
++const policy = csp.policy({ nonce: 'abc123', sites: ['wss://openvibe.chat'], frames: ["'self'", 'https://openvibe.bot'] });
++for (const part of ["default-src 'self'", "script-src 'self' 'nonce-abc123'", "style-src 'self' 'unsafe-inline'", "img-src https:", "media-src https:", "font-src 'self' data:", "frame-src 'self' https://openvibe.bot", "frame-ancestors 'self'", "base-uri 'self'", "object-src 'none'", "form-action 'self' https://openvibe.network"]) {
++ assert.ok(policy.includes(part), `policy has ${part}`);
++}
++assert.ok(policy.includes('https://openvibe.media') && policy.includes('wss://openvibe.chat'), 'policy connect-src has Media and the site');
++assert.ok(csp.policy({ extra: { 'worker-src': "'self' blob:" } }).includes("worker-src 'self' blob:"));
++console.log('csp: all checks passed');
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex 8a6a620..6b49e25 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -20,6 +20,7 @@
// Dock went, and closing the window brings the panel back; the Network is told.
// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -319,7 +320,7 @@ async function drag(p, from, to) {
const p = await fresh(390);
await p.goto(`${base}/page`, { maxMs: 5000 });
await wait(700);
-- assert.strictEqual(await p.evaluate(PILL), null, 'no pill while nothing runs or waits (unread alone is the bell\'s)');
++ await eventually(async () => assert.strictEqual(await p.evaluate(PILL), null, 'no pill while nothing runs or waits (unread alone is the bell\'s)'));
scenario.state = { ...defaultState(), pinned: true };
await p.goto(`${base}/page`, { maxMs: 5000 });
const pin = await until(p, PILL, 'the pinned pill');
@@@ -343,7 +344,7 @@ async function drag(p, from, to) {
await new Promise((ok) => setTimeout(ok, 450)); // the sheet has finished rising
const placed = await p.evaluate(CARD);
assert.ok(placed.w === 390 && placed.t === 0 && placed.h === 780, `the whole phone, over the bar (owner, 11-09: "all the way to the top"; ${placed.t}, ${placed.h})`);
-- assert.strictEqual(await p.evaluate('document.getElementById("ovdk-pill").hidden'), true, 'the pill steps aside while the Dock is open');
++ await eventually(async () => assert.strictEqual(await p.evaluate('document.getElementById("ovdk-pill").hidden'), true, 'the pill steps aside while the Dock is open'));
await p.evaluate("document.querySelector('.ovdk .ovac-item[data-bucket=waiting] button[data-do=approve]').click()");
await until(p, "document.querySelector('.ovdk .ovac-item[data-bucket=waiting] .primary').textContent === 'Approved'", 'approved in place');
assert.deepStrictEqual(decided, ['actor:apr_1:approve'], 'the approval goes to the Network for that item');
@@@ -351,7 +352,7 @@ async function drag(p, from, to) {
await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'activity' && c.notes === 2; })()`, 'the Activity tab');
await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').focus(); document.querySelector('.ovdk').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))");
await until(p, `!(${CARD})`, 'Escape closes');
-- assert.strictEqual(await p.evaluate('document.activeElement && document.activeElement.id'), 'ovdk-pill', 'the focus goes back to the pill');
++ await eventually(async () => assert.strictEqual(await p.evaluate('document.activeElement && document.activeElement.id'), 'ovdk-pill', 'the focus goes back to the pill'));
await wait(900);
const all = Object.assign({}, ...puts);
assert.deepStrictEqual([all.open, all.tab], [false, 'activity'], `the Network heard where the Dock was left (${JSON.stringify(puts)})`);
@@@ -361,7 +362,7 @@ async function drag(p, from, to) {
await p.goto(`${base}/page`, { maxMs: 5000 });
await until(p, PILL, 'the pill again');
await wait(400);
-- assert.strictEqual(await p.evaluate(CARD), null, 'a phone opens the Dock only when asked');
++ await eventually(async () => assert.strictEqual(await p.evaluate(CARD), null, 'a phone opens the Dock only when asked'));
await p.close();
}
@@@ -384,12 +385,12 @@ async function drag(p, from, to) {
const cards = await until(p, "(() => { const c = [...document.querySelectorAll('.ovdk .ovdk-msg .ovdk-card')]; return c.length === 2 ? c.map((x) => { const t = x.querySelector('.ovdk-card-t'), g = x.querySelector('.ovdk-card-go'), i = x.querySelector('img'); return [t.tagName, t.textContent, t.getAttribute('href'), i ? i.getAttribute('src') : null, g ? g.textContent + ' ' + g.getAttribute('href') : null]; }) : null; })()", 'the link cards');
assert.deepStrictEqual(cards[0], ['A', 'The studio tour', 'https://openvibe.video/v/42', 'https://openvibe.media/t/abc?w=320', 'Watch together https://openvibe.party/new?media=https%3A%2F%2Fopenvibe.video%2Fv%2F42'], 'a card: its picture, its title as a link, its one move');
assert.deepStrictEqual(cards[1], ['B', '<img src=x>', null, null, null], 'a card is data: no script link, no picture from elsewhere, no script button, the title as text');
-- assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"dock-card.js\"]').length"), 1, 'dock-card.js is loaded once, when a card first shows');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"dock-card.js\"]').length"), 1, 'dock-card.js is loaded once, when a card first shows'));
await until(p, "!!document.querySelector('.ovdk .ovdk-edit')", 'the edit action');
await p.evaluate("(() => { window.prompt = () => 'updated by me'; document.querySelector('.ovdk .ovdk-edit').click(); })()");
await until(p, "document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-text').textContent === 'updated by me'", 'the edited message');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-edited').textContent.includes('edited')"), true);
-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'opening a chat on a phone is not a tap outside the sheet');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-edited').textContent.includes('edited')"), true));
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'opening a chat on a phone is not a tap outside the sheet'));
await p.evaluate("(() => { const b = document.querySelector('.ovdk-send textarea'); b.value = 'Looks good'; b.dispatchEvent(new Event('input')); b.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); })()");
await until(p, "document.querySelectorAll('.ovdk .ovdk-msg').length === 3", 'the sent message in the thread');
assert.deepStrictEqual(said, ['Looks good'], 'Enter sends it to Chat');
@@@ -412,12 +413,12 @@ async function drag(p, from, to) {
const share = (title) => p.evaluate(`OpenVibeDock.share({ text: ${JSON.stringify(TEXT)}${title ? `, title: ${JSON.stringify(title)}` : ''} })`);
await share('Friday film');
await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the Send to… bar');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-share-what').textContent"), 'Friday film', 'what is being sent, by its title');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-share-what').textContent"), 'Friday film', 'what is being sent, by its title'));
await until(p, "document.querySelectorAll('.ovdk .ovdk-chat').length === 2", 'your conversations under it');
await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");
await until(p, "document.querySelector('.ovdk-send textarea') && document.querySelector('.ovdk-send textarea').value.length > 0", 'the message in the box');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-send textarea').value"), TEXT, 'the link waits in the box');
-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk-ready')"), true, 'and says it is ready to send');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-send textarea').value"), TEXT, 'the link waits in the box'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk-ready')"), true, 'and says it is ready to send'));
await wait(300);
assert.deepStrictEqual(said, [], 'nothing is sent until the person sends it');
// Someone new, by their name: a name nobody has says so; the right one starts the conversation.
@@@ -425,7 +426,7 @@ async function drag(p, from, to) {
await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar again');
await p.evaluate("(() => { document.querySelector('.ovdk-to input').value = '@zz'; document.querySelector('.ovdk-to').requestSubmit(); })()");
await until(p, "!document.querySelector('.ovdk-share .ovdk-err').hidden", 'a name nobody has');
-- assert.match(await p.evaluate("document.querySelector('.ovdk-share .ovdk-err').textContent"), /Nobody is called @zz\. Did you mean @mia\?/);
++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('.ovdk-share .ovdk-err').textContent"), /Nobody is called @zz\. Did you mean @mia\?/));
await p.evaluate("(() => { document.querySelector('.ovdk-to input').value = 'mia'; document.querySelector('.ovdk-to').requestSubmit(); })()");
await until(p, `document.querySelector('.ovdk-send textarea') && document.querySelector('.ovdk-send textarea').value === ${JSON.stringify(TEXT)}`, 'a new conversation with the message in the box');
assert.deepStrictEqual(started, [[4]], 'the conversation is started with Mia, by her id');
@@@ -436,7 +437,7 @@ async function drag(p, from, to) {
await p.evaluate("document.getElementById('pagesend').click()");
await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar, opened by a tap on the page');
await wait(300);
-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'the opening tap does not close the sheet');
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'the opening tap does not close the sheet'));
await p.evaluate("document.body.click()");
// A phone's sheet folds back into the navbar's orb before it goes (4.185.0: 240 ms), so it is gone a moment
// after the tap, not in the same instant.
@@@ -473,15 +474,15 @@ async function drag(p, from, to) {
// The stream: Live's embed, the task, the numbers.
const src = await until(p, "document.querySelector('.ovda iframe') && document.querySelector('.ovda iframe').getAttribute('src')", 'the embed');
assert.strictEqual(src, view().embed_url, 'the embed Codes answered, with the ticket in its address');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovda-line').textContent"), 'Fix the flaky upload test', 'the task, one line');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Live · 2 watching · worked 12 min');
-- assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ovda-acts button')].map((b) => b.dataset.act)"), ['pause', 'curtain', 'mute', 'end', 'float'], 'the owner\'s four controls (S5), and Float while the screen plays (4.154.0)');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), true, 'the view is shown above the list');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda-line').textContent"), 'Fix the flaky upload test', 'the task, one line'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Live · 2 watching · worked 12 min'));
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ovda-acts button')].map((b) => b.dataset.act)"), ['pause', 'curtain', 'mute', 'end', 'float'], 'the owner\'s four controls (S5), and Float while the screen plays (4.154.0)'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), true, 'the view is shown above the list'));
// Curtain now: Live is asked, and the line says so.
await p.evaluate("document.querySelector('.ovda-acts button[data-act=curtain]').click()");
await until(p, "document.querySelector('.ovda-meta').textContent.includes('Private screen')", 'the curtain in the line');
assert.deepStrictEqual(controls, ['curtain'], 'Curtain now reached Codes, which puts it to Live');
-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda-acts button[data-act=curtain]')"), false, 'no Curtain now while the curtain is up');
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda-acts button[data-act=curtain]')"), false, 'no Curtain now while the curtain is up'));
// S5: Pause and Mute reach Codes, which puts them to Live; the line says whose pause it is.
await p.evaluate("document.querySelector('.ovda-acts button[data-act=pause]').click()");
await until(p, "document.querySelector('.ovda-acts button[data-act=pause]').textContent.includes('Resume')", 'the pause in the button');
@@@ -492,9 +493,9 @@ async function drag(p, from, to) {
const stillSrc = await until(p, "document.querySelector('.ovda img') && document.querySelector('.ovda img').getAttribute('src')", 'the still after End');
assert.ok(stillSrc.startsWith('blob:'), 'the still is read with the token, never a bare <img> URL');
assert.deepStrictEqual(controls, ['curtain', 'pause', 'mute', 'end'], 'Pause, Mute and End reached Codes, which puts them to Live');
-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda iframe')"), false, 'no player once it has ended');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Ended · worked 12 min');
-- assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the agents list is untouched under the stream');
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda iframe')"), false, 'no player once it has ended'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Ended · worked 12 min'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the agents list is untouched under the stream'));
// No stream at all: the view hides itself and the tab is just the list.
agentView = null;
await p.goto(`${base}/page`, { maxMs: 8000 });
@@@ -502,8 +503,8 @@ async function drag(p, from, to) {
await p.evaluate("document.getElementById('ovdk-pill').click()");
await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents tab again');
await wait(400);
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), false, 'nothing to show, and nowhere to show it');
-- assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the list alone');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), false, 'nothing to show, and nowhere to show it'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the list alone'));
await p.close();
}
@@@ -516,7 +517,7 @@ async function drag(p, from, to) {
assert.strictEqual(pill.text, 'Working');
await p.evaluate("document.getElementById('ovdk-pill').click()");
await until(p, CARD, 'the sheet at 320');
-- assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 320, 'no sideways scroll');
++ await eventually(async () => assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 320, 'no sideways scroll'));
await p.close();
}
@@@ -566,8 +567,8 @@ async function drag(p, from, to) {
await p.evaluate("document.getElementById('ovdk-pill').click()");
await until(p, CARD, 'the card');
const POP = "document.querySelector('.ovdk [data-act=pop]')";
-- assert.strictEqual(await p.evaluate(`!!${POP}`), true, 'the header carries a pop-out button');
-- assert.strictEqual(await p.evaluate(`${POP}.hidden`), true, 'hidden where the browser cannot open a window of its own');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`!!${POP}`), true, 'the header carries a pop-out button'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${POP}.hidden`), true, 'hidden where the browser cannot open a window of its own'));
// The browser can: the button appears.
await p.evaluate(PIP);
await p.evaluate("document.querySelector('.ovdk [data-act=close]').click()");
@@@ -577,10 +578,10 @@ async function drag(p, from, to) {
// The panel moves into the window: the same DOM, the same tabs, the styles come with it.
await p.evaluate(`${POP}.click()`);
await until(p, "!!(window.__pip && window.__pip.win.document.querySelector('.ovdk'))", 'the panel in the window');
-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk')"), false, 'and not in the page any more');
-- assert.strictEqual(await p.evaluate("window.__pip.win.document.querySelectorAll('.ovdk [role=tab]').length"), 5, 'the same five tabs (Vibe, Code, Agents, Chat, Activity)');
-- assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovdk-styles')"), true, "the Dock's styles come with it");
-- assert.deepStrictEqual(await p.evaluate("window.__pip.opts"), { width: 360, height: 520 }, 'a window of about 360 × 520');
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk')"), false, 'and not in the page any more'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("window.__pip.win.document.querySelectorAll('.ovdk [role=tab]').length"), 5, 'the same five tabs (Vibe, Code, Agents, Chat, Activity)'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovdk-styles')"), true, "the Dock's styles come with it"));
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate("window.__pip.opts"), { width: 360, height: 520 }, 'a window of about 360 × 520'));
const inWindow = await until(p, PILL, 'the pill stays');
assert.strictEqual(inWindow.text, 'In a window', 'the page keeps the pill, and it says where the Dock went');
// The state write is debounced; a slow runner (macOS CI) needs more than a fixed wait.
@@@ -590,7 +591,7 @@ async function drag(p, from, to) {
// The window is closed: the panel comes back to the page.
await p.evaluate("window.__pip.close()");
await until(p, "!!document.querySelector('.ovdk')", 'the panel back on the page');
-- assert.strictEqual(await p.evaluate("!!window.__pip.win.document.querySelector('.ovdk')"), false, 'and out of the window');
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!window.__pip.win.document.querySelector('.ovdk')"), false, 'and out of the window'));
await wait(900);
assert.ok(!puts.some((b) => 'popped' in b), `nor that it is back (${JSON.stringify(puts)})`);
await p.close();
@@@ -619,12 +620,12 @@ async function drag(p, from, to) {
await p.evaluate("window.__pip.win.document.querySelector('#ovdk-tab-agent').click()");
const src = await until(p, "(() => { const w = window.__pip.win; const f = w.document.querySelector('.ovda iframe'); return f && f.getAttribute('src'); })()", 'the stream in the window');
assert.strictEqual(src, agentView.embed_url, 'the same ticket and stream, in the window');
-- assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovda-styles')"), true, 'the agent view keeps its styles in the window');
-- assert.strictEqual(await p.evaluate("!!document.getElementById('ovda-styles')"), false, 'and does not put them on the page behind it');
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovda-styles')"), true, 'the agent view keeps its styles in the window'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.getElementById('ovda-styles')"), false, 'and does not put them on the page behind it'));
// Back on the page, the same view is still there.
await p.evaluate("window.__pip.close()");
await until(p, "!!document.querySelector('.ovdk .ovda iframe')", 'the stream back on the page');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovda iframe').getAttribute('src')"), agentView.embed_url);
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda iframe').getAttribute('src')"), agentView.embed_url));
await p.close();
}
@@@ -639,7 +640,7 @@ async function drag(p, from, to) {
await until(p, PILL, 'the pinned pill');
await p.evaluate("document.getElementById('ovdk-pill').click()");
await until(p, CARD, 'the card');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab]').dataset.tab"), 'vibe', 'Vibe is the first tab');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab]').dataset.tab"), 'vibe', 'Vibe is the first tab'));
await p.evaluate("document.querySelector('#ovdk-tab-vibe').click()");
await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the conversation list');
await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");
@@@ -660,10 +661,10 @@ async function drag(p, from, to) {
await p.evaluate("document.querySelector('.ovdk .ovdk-res-c button').click()");
await until(p, "!!document.querySelector('.ovdk .ovdk-res-c.is-stopped')", 'the chip says it is stopped');
assert.deepStrictEqual(undone[0], { message_id: vibeMsgs.find((m) => m.from === 'vibe').id, index: 0 }, 'undo names the answer and the result');
-- assert.match(await p.evaluate("document.querySelector('.ovdk .ovdk-res-c').innerText"), /Reminder stopped: Stretch/);
-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-res-c button')"), false, 'and is not offered again');
-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-typing')"), false, 'the writing dots go when it answers');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-away').href"), 'https://openvibe.chat/vibe/v1', 'Open in Chat goes to the same chat');
++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('.ovdk .ovdk-res-c').innerText"), /Reminder stopped: Stretch/));
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-res-c button')"), false, 'and is not offered again'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-typing')"), false, 'the writing dots go when it answers'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-away').href"), 'https://openvibe.chat/vibe/v1', 'Open in Chat goes to the same chat'));
await wait(900);
assert.ok(puts.some((b) => b.tab === 'vibe'), `the Network keeps the Vibe tab, so it follows the person to the next site (${JSON.stringify(puts)})`);
await p.close();
@@@ -691,15 +692,15 @@ async function drag(p, from, to) {
await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the Vibe list');
await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");
await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-mic')", 'a mic, though the browser has no speech recognition');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] textarea').placeholder"), 'Ask Vibe, or hold the mic');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] textarea').placeholder"), 'Ask Vibe, or hold the mic'));
await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");
await until(p, "window.__recording === true && document.querySelector('.ovdk .ovdk-mic').getAttribute('aria-pressed') === 'true'", 'recording, and the mic says so');
await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");
await until(p, "[...document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-msg.is-mine')].some((m) => /drink water at 3/.test(m.innerText))", 'what was said, asked');
assert.deepStrictEqual(listened[0], { type: 'audio/ogg', bytes: 4096, lang: 'en', auth: 'Bearer tok_user' }, 'the recording goes to Chat as itself, with the person\'s token');
assert.strictEqual(asked[0].message, 'remind me to drink water at 3');
-- assert.strictEqual(await p.evaluate('window.__tracksStopped'), 1, 'the microphone is released');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-mic').getAttribute('aria-pressed')"), 'false');
++ await eventually(async () => assert.strictEqual(await p.evaluate('window.__tracksStopped'), 1, 'the microphone is released'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-mic').getAttribute('aria-pressed')"), 'false'));
await p.close();
}
@@@ -711,11 +712,11 @@ async function drag(p, from, to) {
await p.goto(`${base}/page`, { maxMs: 5000 });
await until(p, "!!document.querySelector('.openvibe-navbar')", 'the navbar');
await wait(600);
-- assert.strictEqual(await p.evaluate("!!document.getElementById('ov-glyphs-loader')"), false, 'no icon on the page: no glyphs loaded');
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.getElementById('ov-glyphs-loader')"), false, 'no icon on the page: no glyphs loaded'));
await p.evaluate("(() => { const i = document.createElement('i'); i.className = 'fa-solid fa-gear'; i.id = 'late-icon'; document.querySelector('main').prepend(i); })()");
await until(p, "!!document.getElementById('ov-glyphs-loader') && !!window.OVGlyphs", 'the glyphs load once an icon appears');
await until(p, "(() => { const e = document.getElementById('late-icon'); return !e || !!e.querySelector('svg') || e.tagName.toLowerCase() === 'svg'; })()", 'and the late icon is drawn');
-- assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"ov-glyphs.js\"]').length"), 1, 'once');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"ov-glyphs.js\"]').length"), 1, 'once'));
await p.close();
}
@@@ -769,14 +770,14 @@ async function drag(p, from, to) {
const p = await fresh(1280);
await p.goto(`${base}/page`, { maxMs: 5000 });
await wait(700);
-- assert.strictEqual(await p.evaluate(PILL), null, 'no pill without the route');
++ await eventually(async () => assert.strictEqual(await p.evaluate(PILL), null, 'no pill without the route'));
const off = await p.evaluate("JSON.parse(localStorage.getItem('ov-dock') || 'null')");
assert.ok(off && off.off > Date.now() + 23 * 3600e3, 'and it waits a day before asking again');
await p.evaluate("localStorage.removeItem('ov-dock')");
scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
await p.goto(`${base}/page?in=0`, { maxMs: 5000 });
await wait(600);
-- assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all'));
await p.close();
}
// ── Ask your agent (4.135.0): a page's [data-ov-ask] chip opens Vibe with the question in the box, unsent. ──
@@@ -790,7 +791,7 @@ async function drag(p, from, to) {
await until(p, CARD, 'the Dock opens');
const box = await until(p, "(() => { const t = document.querySelector('.ovdk [data-panel=vibe] textarea'); return t && t.value ? { value: t.value } : null; })()", 'the question in the box');
assert.strictEqual(box.value, 'About this stream: night walk in Asakusa. ', 'the page question, as given');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').dataset.tab"), 'vibe', 'on the Vibe tab');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').dataset.tab"), 'vibe', 'on the Vibe tab'));
await wait(400);
assert.strictEqual(asked.length, before, 'nothing is sent for the person');
await p.close();
ddiff --git a/test/dock-code-chrome.test.js b/test/dock-code-chrome.test.js
iindex d350fee..7a04e9e 100644
---- a/test/dock-code-chrome.test.js
++++ b/test/dock-code-chrome.test.js
@@@ -5,6 +5,7 @@
// task with the person's token and the list reads again; Stop stops it; signed out, it says to sign in; when Codes cannot
// be reached, the box opens openvibe.codes with what was written in it.
const assert = require('node:assert/strict');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
@@@ -87,7 +88,7 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (
b = await page.evaluate(`({ text: document.querySelector('.ovdc-text').value, note: document.querySelector('.ovdc-note').textContent, focused: document.activeElement === document.querySelector('.ovdc-text'), go: document.querySelector('.ovdc-go').disabled })`);
assert.deepEqual(b, { text: 'Build a goal bar for my sub goal', note: 'From your chat with Vibe. Start it, or change it first.', focused: true, go: false }, 'handed-over words are in the box, ready to start (My code keeps its repository)');
await page.evaluate(`document.querySelector('[data-mode="create"]').click()`);
-- assert.equal(await page.evaluate(`document.querySelector('.ovdc-go').disabled`), false);
++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelector('.ovdc-go').disabled`), false));
await page.evaluate(`(() => { const t = document.querySelector('.ovdc-text'); t.value = ''; t.dispatchEvent(new Event('input')); })()`);
// Stop.
@@@ -101,7 +102,7 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (
const out = await h.openPage(browser, await browser.newContext(), { width: 390, height: 800 });
await out.goto(base + '/?out=1', { maxMs: 5000 });
await wait(300);
-- assert.match(await out.evaluate(`document.querySelector('.ovdc-list').textContent`), /Sign in to start coding tasks/);
++ await eventually(async () => assert.match(await out.evaluate(`document.querySelector('.ovdc-list').textContent`), /Sign in to start coding tasks/));
assert.equal(seen.length, before, 'nothing is asked without a token');
await out.close();
@@@ -110,7 +111,7 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (
const down = await h.openPage(browser, await browser.newContext(), { width: 390, height: 800 });
await down.goto(base + '/', { maxMs: 5000 });
await wait(300);
-- assert.match(await down.evaluate(`document.querySelector('.ovdc-list').textContent`), /Your tasks are on openvibe\.codes/);
++ await eventually(async () => assert.match(await down.evaluate(`document.querySelector('.ovdc-list').textContent`), /Your tasks are on openvibe\.codes/));
await down.evaluate(`(() => { const t = document.querySelector('.ovdc-text'); t.value = 'A goal bar'; t.dispatchEvent(new Event('input')); document.querySelector('.ovdc-go').click(); })()`);
const went = await (async () => { const end = Date.now() + 5000; while (Date.now() < end) { const v = await down.evaluate('location.pathname + location.search').catch(() => ''); if (v.startsWith('/codes')) return v; await wait(50); } return ''; })();
assert.equal(went, '/codes/?prompt=A+goal+bar&mode=create', 'it opens Codes with what you wrote');
ddiff --git a/test/fixed-waits.test.js b/test/fixed-waits.test.js
nnew file mode 100644
iindex 0000000..2b89dae
---- /dev/null
++++ b/test/fixed-waits.test.js
@@@ -0,0 +1,40 @@
++'use strict';
++// A browser test checks the page after it is ready, not after a guessed sleep (2026-11-11, the owner: "Can we fix the
++// Mac OS bug once and for all?"). The macOS runners are slow: menu-places-chrome slept 180 ms for a 140 ms debounce and
++// failed now and then, holding back releases 4.202.0 and 4.203.0. A check after `await wait(n)` now goes through
++// browser-harness's eventually() (or the test's own until()), which retries until the page is there.
++//
++// This is a ratchet: test/fixtures/fixed-waits.json counts the sleep-then-assert places each file still has. A file may
++// lose them, never gain them, and a new file starts at none. Lower the number in the fixture when you fix one.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const DIR = __dirname;
++const baseline = JSON.parse(fs.readFileSync(path.join(DIR, 'fixtures', 'fixed-waits.json'), 'utf8'));
++
++/** The sleep-then-assert places in one file: an `await wait(<number>)`, then within two lines a bare assert. */
++function count(text) {
++ const lines = text.split('\n');
++ let n = 0;
++ for (let i = 0; i < lines.length; i++) {
++ if (!/await\s+wait\(\s*\d+\s*\)/.test(lines[i])) continue;
++ const same = lines[i].slice(lines[i].search(/await\s+wait\(/));
++ const next = [same.replace(/^await\s+wait\(\s*\d+\s*\)\s*;?/, ''), lines[i + 1] || '', lines[i + 2] || ''];
++ // Only a check that reads the page now (an evaluate) races the sleep; one of a value measured before does not.
++ if (next.some((l) => /^\s*assert[.(]/.test(l) && /evaluate\(|inside\(\)/.test(l) && !/eventually\(|until\(/.test(l))) n++;
++ }
++ return n;
++}
++
++const now = {};
++for (const f of fs.readdirSync(DIR).filter((x) => x.endsWith('.test.js')).sort()) {
++ const n = count(fs.readFileSync(path.join(DIR, f), 'utf8'));
++ if (n) now[f] = n;
++}
++const grew = Object.entries(now).filter(([f, n]) => n > (baseline[f] || 0)).map(([f, n]) => `${f}: ${n} (was ${baseline[f] || 0})`);
++assert.deepStrictEqual(grew, [], 'a check right after a fixed sleep: use eventually() from browser-harness (or until()) instead');
++const better = Object.entries(baseline).filter(([f, n]) => (now[f] || 0) < n).map(([f, n]) => `${f}: ${now[f] || 0} (fixture says ${n})`);
++assert.deepStrictEqual(better, [], 'fewer than the fixture says: lower test/fixtures/fixed-waits.json so it cannot grow back');
++assert.strictEqual(count(fs.readFileSync(path.join(DIR, 'menu-places-chrome.test.js'), 'utf8')), 0, 'the test that held back 4.202 and 4.203 has none');
++console.log(`fixed-waits: ${Object.values(now).reduce((a, b) => a + b, 0)} sleep-then-assert places left in ${Object.keys(now).length} files, none new`);
ddiff --git a/test/fixtures/fixed-waits.json b/test/fixtures/fixed-waits.json
nnew file mode 100644
iindex 0000000..0967ef4
---- /dev/null
++++ b/test/fixtures/fixed-waits.json
@@@ -0,0 +1 @@
++{}
ddiff --git a/test/forms-when-seen-chrome.test.js b/test/forms-when-seen-chrome.test.js
iindex f6a7921..ba89d4a 100644
---- a/test/forms-when-seen-chrome.test.js
++++ b/test/forms-when-seen-chrome.test.js
@@@ -3,6 +3,7 @@
// and more optimized"). Live's shell carries hidden modal forms, so every Live page fetched ov-forms.js and .css
// (17 KB) for selects nobody saw. A real headless Chrome; skipped without Chrome.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -45,12 +46,12 @@ const server = http.createServer((req, res) => {
const p = await h.openPage(browser, context, { width: 390 });
await p.goto(`${base}/hidden`, { maxMs: 5000 });
await wait(3000);
-- assert.strictEqual(await p.evaluate(asked), false, 'a hidden dialog\'s select does not fetch the forms kit');
-- assert.strictEqual(await p.evaluate('!!window.OVForms'), false);
++ await eventually(async () => assert.strictEqual(await p.evaluate(asked), false, 'a hidden dialog\'s select does not fetch the forms kit'));
++ await eventually(async () => assert.strictEqual(await p.evaluate('!!window.OVForms'), false));
await p.evaluate("(document.getElementById('modal').hidden = false, 1)");
assert.ok(await until(p, '!!window.OVForms'), 'the kit loads when the dialog opens');
-- assert.strictEqual(await p.evaluate(asked), true);
++ await eventually(async () => assert.strictEqual(await p.evaluate(asked), true));
const q = await h.openPage(browser, context, { width: 390 });
await q.goto(`${base}/shown`, { maxMs: 5000 });
ddiff --git a/test/kit-buttons.test.js b/test/kit-buttons.test.js
nnew file mode 100644
iindex 0000000..e9a1457
---- /dev/null
++++ b/test/kit-buttons.test.js
@@@ -0,0 +1,39 @@
++'use strict';
++// 4.204.0, buttons that show what they do and what is happening (docs/kit.md#buttons-42040): the builder's options,
++// the sheet's rules (every motion scaled by --ov-move), and busy() on a fake button.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const kit = require('../kit.js');
++
++const hero = kit.button({ label: 'Go live', icon: 'live', kind: 'hero', nudge: 'forward', iconEnd: 'chevron', attention: true, badge: 120 });
++assert.match(hero, /^<button class="ovk-btn ovk-btn--hero" type="submit" data-nudge="forward" data-attention="" data-badge="99\+">/);
++assert.ok((hero.match(/<svg/g) || []).length === 2, 'an icon before and one after the words');
++assert.match(kit.button({ label: 'Delete', kind: 'danger', hold: 99999, type: 'button' }), /data-hold="4000"/, 'a hold is 4 s at most');
++assert.match(kit.button({ label: 'Delete', kind: 'danger', hold: 10, type: 'button' }), /data-hold="400"/, 'and 0.4 s at least');
++assert.match(kit.button({ label: 'Following', kind: 'soft', pressed: true, dot: 'live', type: 'button' }), /data-dot="live" aria-pressed="true"/);
++assert.ok(!/data-badge/.test(kit.button({ label: 'x', badge: 0 })), 'no badge for nothing');
++assert.match(kit.button({ label: '<b>', attrs: { 'data-x': '1' } }), /data-x="1"><span>&lt;b&gt;<\/span>/, 'the words are text and other attributes stay');
++
++const css = fs.readFileSync(path.join(__dirname, '..', 'kit.css'), 'utf8');
++for (const k of ['hero', 'soft', 'live']) assert.ok(css.includes(`.ovk-btn--${k} {`), k);
++for (const sel of ['[data-attention]', '[data-badge]', '[data-dot="ok"]', '[data-state="busy"]', '[data-state="done"]', '[data-state="error"]', '[data-hold]', '--ovk-progress']) assert.ok(css.includes(sel), sel);
++const start = css.indexOf('Buttons that show what they do');
++const block = css.slice(start, css.indexOf('@media (hover: none) { .ovk-btn:hover', start));
++const moves = block.match(/(translate[XY]?|rotate|scale)\([^;]*\)/g) || [];
++assert.ok(moves.length >= 10 && moves.every((m) => /--ov-move/.test(m) || /\((0|0px)\)$/.test(m)), 'every move is scaled by --ov-move: ' + moves.filter((m) => !/--ov-move/.test(m) && !/\((0|0px)\)$/.test(m)).join(' | '));
++
++// busy(): a fake document and button.
++global.document = { readyState: 'complete', addEventListener() {}, createElement: () => ({ className: '', innerHTML: '', setAttribute() {}, remove() { this.gone = true; } }) };
++const web = require('../kit-web.js');
++const btn = { dataset: {}, disabled: false, attrs: {}, kids: [], setAttribute(k, v) { this.attrs[k] = v; }, removeAttribute(k) { delete this.attrs[k]; }, appendChild(c) { this.kids.push(c); } };
++(async () => {
++ const p = web.busy(btn, Promise.resolve(7));
++ assert.strictEqual(btn.dataset.state, 'busy'); assert.strictEqual(btn.attrs['aria-busy'], 'true'); assert.strictEqual(btn.disabled, true);
++ assert.strictEqual(await p, 7);
++ assert.strictEqual(btn.dataset.state, 'done'); assert.strictEqual(btn.disabled, false); assert.ok(btn.kids[0].gone);
++ await assert.rejects(web.busy(btn, () => Promise.reject(new Error('no'))), /no/);
++ assert.strictEqual(btn.dataset.state, 'error');
++ console.log('kit-buttons: kinds, status, attention, hold and busy');
++ process.exit(0);
++})();
ddiff --git a/test/kit-web-chrome.test.js b/test/kit-web-chrome.test.js
iindex 51793a1..b7d330a 100644
---- a/test/kit-web-chrome.test.js
++++ b/test/kit-web-chrome.test.js
@@@ -5,6 +5,7 @@
// sent without asking. This checks each of those, and the tool frame's clear button and "Try" examples, in the page.
// Skipped without Chrome.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -50,27 +51,27 @@ const server = http.createServer((req, res) => {
const p = await h.openPage(browser, context, { width: 390 });
await p.goto(`${base}/`, { maxMs: 5000 });
await wait(200);
-- assert.strictEqual(await p.evaluate('Math.round(scrollY)'), 0, 'tabs below the fold never scroll the page to themselves');
-- assert.ok(await p.evaluate("document.querySelector('.ovk-tabs').scrollLeft") > 0, 'but their strip shows the open tab');
++ await eventually(async () => assert.strictEqual(await p.evaluate('Math.round(scrollY)'), 0, 'tabs below the fold never scroll the page to themselves'));
++ await eventually(async () => assert.ok(await p.evaluate("document.querySelector('.ovk-tabs').scrollLeft") > 0, 'but their strip shows the open tab'));
const hasClipboard = await p.evaluate('!!navigator.clipboard');
if (hasClipboard) assert.strictEqual(await p.evaluate("document.querySelector('[data-ovk-copy]').hidden"), false, 'the copy button shows where the clipboard works');
await p.evaluate("document.querySelector('[data-ovk-sheet]').click()");
await wait(100);
-- assert.strictEqual(await p.evaluate("document.getElementById('sh').open"), true, 'the sheet button opens its sheet');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('sh').open"), true, 'the sheet button opens its sheet'));
await p.evaluate("document.querySelector('#sh [data-ovk-close]').click()");
await p.evaluate("document.querySelector('form[data-ovk-confirm] button').click()");
await wait(100);
-- assert.deepStrictEqual(await p.evaluate('window.__asked'), ['Delete it?'], 'a form that asks first asks');
-- assert.ok(!(await p.evaluate("window.__sent.some(([a]) => a === '/gone')")), 'and is not sent when the answer is no');
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__asked'), ['Delete it?'], 'a form that asks first asks'));
++ await eventually(async () => assert.ok(!(await p.evaluate("window.__sent.some(([a]) => a === '/gone')")), 'and is not sent when the answer is no'));
// The tool frame: the clear button follows the box; a Try example fills the box and runs the tool.
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovk-tool-clear').hidden"), true, 'an empty box hides its clear button');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovk-tool-clear').hidden"), true, 'an empty box hides its clear button'));
await p.evaluate("document.querySelector('a[data-ovk-try]').click()");
await wait(100);
-- assert.strictEqual(await p.evaluate("document.getElementById('ovk-tool-q').value"), 'openvibe.network', 'the example fills the box');
-- assert.ok(await p.evaluate("window.__sent.some(([, q]) => q === 'openvibe.network')"), 'and runs the tool (its form is sent)');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ovk-tool-clear').hidden"), false, 'a box with text shows its clear button');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ovk-tool-q').value"), 'openvibe.network', 'the example fills the box'));
++ await eventually(async () => assert.ok(await p.evaluate("window.__sent.some(([, q]) => q === 'openvibe.network')"), 'and runs the tool (its form is sent)'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovk-tool-clear').hidden"), false, 'a box with text shows its clear button'));
await p.evaluate("document.querySelector('.ovk-tool-clear').click()");
-- assert.strictEqual(await p.evaluate("document.getElementById('ovk-tool-q').value"), '', 'which empties it');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ovk-tool-q').value"), '', 'which empties it'));
await p.close();
console.log(`kit-web-chrome: ${hasClipboard ? 'copy shows, ' : ''}sheets open, confirms ask, the tool frame's clear and Try work ok`);
} finally {
ddiff --git a/test/menu-close-chrome.test.js b/test/menu-close-chrome.test.js
iindex 348e5ad..91c8726 100644
---- a/test/menu-close-chrome.test.js
++++ b/test/menu-close-chrome.test.js
@@@ -4,6 +4,7 @@
// area, named for a screen reader, and a tap closes the menu. On a wide screen the button keeps its word, Close.
// Skipped without Chrome.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -64,26 +65,26 @@ async function openMenu(p) {
await openMenu(p);
const x = await p.evaluate(BUTTON);
assert.ok(x && x.shown, 'the close button shows on a phone');
-- assert.strictEqual(await p.evaluate('document.body.style.position'), 'fixed', 'the menu locks the page without moving it');
++ await eventually(async () => assert.strictEqual(await p.evaluate('document.body.style.position'), 'fixed', 'the menu locks the page without moving it'));
assert.strictEqual(x.vw, 390, 'a phone layout');
assert.ok(x.w >= 40 && x.h >= 40 && x.right <= x.vw && x.top >= 0, `a 40 px target on screen: ${JSON.stringify(x)}`);
assert.deepStrictEqual([x.label, x.icon, x.word], ['Close the menu', true, false], 'the orb, named for a screen reader');
-- assert.strictEqual(await p.evaluate(`(() => { const o = document.querySelector('.ovs.is-open .ovs-orb'); return o.classList.contains('is-x') && o.querySelectorAll('.ovmb .ovmb-app').length === 3 && getComputedStyle(o.querySelector('.ovmb-bar')).transform !== 'none'; })()`), true, 'its bar and three apps have crossed into an x');
-- assert.strictEqual(await p.evaluate(`getComputedStyle(document.querySelector('.ovs.is-open .ovs-x')).display`), 'none', 'one close: the round x steps aside for the orb');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`(() => { const o = document.querySelector('.ovs.is-open .ovs-orb'); return o.classList.contains('is-x') && o.querySelectorAll('.ovmb .ovmb-app').length === 3 && getComputedStyle(o.querySelector('.ovmb-bar')).transform !== 'none'; })()`), true, 'its bar and three apps have crossed into an x'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`getComputedStyle(document.querySelector('.ovs.is-open .ovs-x')).display`), 'none', 'one close: the round x steps aside for the orb'));
const hit = await p.evaluate(`(() => { const b = document.querySelector('.ovs.is-open .ovs-orb').getBoundingClientRect(); const e = document.elementFromPoint(b.left - 2, b.top + b.height / 2); return !!e && !!e.closest('.ovs-orb'); })()`);
assert.ok(hit, 'the hit area reaches past the circle');
-- assert.ok(await p.evaluate('document.activeElement.classList.contains("ovs-orb")'), 'the phone menu keeps focus on its orb');
++ await eventually(async () => assert.ok(await p.evaluate('document.activeElement.classList.contains("ovs-orb")'), 'the phone menu keeps focus on its orb'));
await p.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
await p.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
// Up to three seconds: the fold takes 280 ms, and a slow runner (macOS in CI) has needed more than 450.
let stillOpen = true;
for (let i = 0; i < 30 && stillOpen; i += 1) { await wait(100); stillOpen = await p.evaluate('!!document.querySelector(".ovs.is-open")'); }
assert.strictEqual(stillOpen, false, 'Escape closes the menu from its orb');
-- assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'closing unlocks the page');
++ await eventually(async () => assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'closing unlocks the page'));
await openMenu(p);
await p.evaluate('document.querySelector(".ovs.is-open .ovs-orb").click(); 1');
await wait(450); // the menu folds back into the orb
-- assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'a tap closes the menu');
++ await eventually(async () => assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'a tap closes the menu'));
const wide = await h.openPage(browser, context, { width: 1280, height: 900 });
await wide.goto(`${base}/`, { maxMs: 5000 });
ddiff --git a/test/menu-feeds-chrome.test.js b/test/menu-feeds-chrome.test.js
iindex d798253..957422b 100644
---- a/test/menu-feeds-chrome.test.js
++++ b/test/menu-feeds-chrome.test.js
@@@ -5,6 +5,7 @@
// and arrows; ticking one shows it, an arrow reorders, at most four, and the choice is kept on this device. A real
// headless Chrome; skipped without Chrome.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -64,11 +65,11 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`), 'the menu opened');
// 4.149.0 (the owner: tabs at the top, a Feed tab, remember the last one): this site's tab first, then Feed and You.
-- assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-starttabs .ovs-tab')].map((t) => t.getAttribute('data-tab') + ':' + t.getAttribute('aria-selected'))`), ['here:true', 'feed:false', 'you:false'], 'three tabs, this site first');
-- assert.deepStrictEqual(await p.evaluate(SHOWN), [], 'the feeds are the Feed tab\'s, not this site\'s');
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-starttabs .ovs-tab')].map((t) => t.getAttribute('data-tab') + ':' + t.getAttribute('aria-selected'))`), ['here:true', 'feed:false', 'you:false'], 'three tabs, this site first'));
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), [], 'the feeds are the Feed tab\'s, not this site\'s'));
await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click(), true`);
await wait(150);
-- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds', 'Top stories', 'Today'], 'by default, every feed (owner, 11-09)');
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds', 'Top stories', 'Today'], 'by default, every feed (owner, 11-09)'));
// 4.139.0: a row with a picture has no site icon beside it; in its group a row without one carries the icon in a
// picture-sized tile, so the titles line up. A group with no pictures keeps plain icons.
const looks = await p.evaluate(`[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="net:"]')].map((g) => [...document.querySelectorAll('.ovs-list li.ovs-item')].filter((li) => li.getAttribute('data-sec') === g.getAttribute('data-sec')).map((li) => { const a = li.firstElementChild; const first = a && a.firstElementChild; return first ? (first.tagName === 'IMG' ? 'img' : first.classList.contains('ovs-thumb--ico') ? 'tile' : first.classList.contains('ovs-ic') ? 'icon' : first.className) : '?'; }))`);
@@@ -78,33 +79,33 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
await p.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);
assert.ok(await until(p, `!!document.querySelector('.ovs-feeds')`), 'the chooser opens in the menu');
-- assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-feed b')].map((b) => b.textContent)`), ['Trending', 'New worlds', 'Top stories', 'Today']);
-- assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('[data-ovs-feed]')].map((x) => x.checked)`), [true, true, true, true]);
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-feed b')].map((b) => b.textContent)`), ['Trending', 'New worlds', 'Top stories', 'Today']));
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('[data-ovs-feed]')].map((x) => x.checked)`), [true, true, true, true]));
// Turn two off: the rest show.
await p.evaluate(`document.querySelector('[data-ovs-feed="news:top"]').click(), true`);
await wait(80);
await p.evaluate(`document.querySelector('[data-ovs-feed="today:today"]').click(), true`);
await wait(100);
-- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds'], 'a feed turned off goes');
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds'], 'a feed turned off goes'));
// Tick Today: it shows, after the two.
await p.evaluate(`document.querySelector('[data-ovs-feed="today:today"]').click(), true`);
await wait(100);
-- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds', 'Today']);
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds', 'Today']));
// Move Today up twice: first.
await p.evaluate(`document.querySelector('[data-ovs-feed-move="up|today:today"]').click(), true`);
await wait(60);
await p.evaluate(`document.querySelector('[data-ovs-feed-move="up|today:today"]').click(), true`);
await wait(100);
-- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'Trending', 'New worlds'], 'the arrows order them');
-- assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-ovs-feed-move')`), 'up|today:today', 'focus stays on the arrow');
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'Trending', 'New worlds'], 'the arrows order them'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-ovs-feed-move')`), 'up|today:today', 'focus stays on the arrow'));
// Untick Trending; tick Top stories: four at most.
await p.evaluate(`document.querySelector('[data-ovs-feed="media-hub:trending"]').click(), true`);
await wait(60);
await p.evaluate(`document.querySelector('[data-ovs-feed="news:top"]').click(), true`);
await wait(100);
-- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories']);
-- assert.deepStrictEqual(await p.evaluate(`JSON.parse(localStorage.getItem('ovs-feeds'))`), { on: ['today:today', 'games:new-worlds', 'news:top'] }, 'kept on this device');
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories']));
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`JSON.parse(localStorage.getItem('ovs-feeds'))`), { on: ['today:today', 'games:new-worlds', 'news:top'] }, 'kept on this device'));
const box = await p.evaluate(`document.documentElement.scrollWidth`);
assert.ok(box <= 390, `nothing runs off a phone (${box})`);
@@@ -114,10 +115,10 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`));
await wait(150);
-- assert.strictEqual(await p.evaluate(`document.querySelector('.ovs-starttabs [aria-selected="true"]').getAttribute('data-tab')`), 'here', 'it reopens on this site\'s tab, whatever was open last');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.querySelector('.ovs-starttabs [aria-selected="true"]').getAttribute('data-tab')`), 'here', 'it reopens on this site\'s tab, whatever was open last'));
await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click(), true`);
await wait(250);
-- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories'], 'your feeds, in your order');
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories'], 'your feeds, in your order'));
// Signed in on a new device: the person's saved feeds come from Network, and a change is saved back.
const ctx2 = await browser.newContext();
const q = await h.openPage(browser, ctx2, { width: 390, block: ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'] });
@@@ -131,7 +132,7 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
await until(q, `!!document.querySelector('.ovs-feeds')`);
await q.evaluate(`document.querySelector('[data-ovs-feed="games:new-worlds"]').click(), true`);
assert.ok(await until(q, `window.__puts.length > 0`, 4000), 'the change is saved to Network');
-- assert.deepStrictEqual(await q.evaluate(`window.__puts[window.__puts.length - 1]`), { on: ['news:top', 'today:today', 'games:new-worlds'] });
++ await eventually(async () => assert.deepStrictEqual(await q.evaluate(`window.__puts[window.__puts.length - 1]`), { on: ['news:top', 'today:today', 'games:new-worlds'] }));
await q.close();
console.log('menu-feeds-chrome: every feed by default, every card choosable, tick, reorder, four at most, kept on this device and on Network for a signed-in person');
} finally {
ddiff --git a/test/menu-home-screen-chrome.test.js b/test/menu-home-screen-chrome.test.js
iindex dec96ee..0bdcb7b 100644
---- a/test/menu-home-screen-chrome.test.js
++++ b/test/menu-home-screen-chrome.test.js
@@@ -9,6 +9,7 @@
// one row of names, Show more when there is more, and the composer only when Community says the person can post.
// Nothing runs off a 390 px screen. Skipped without Chrome.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -100,11 +101,11 @@ const server = http.createServer((req, res) => {
assert.strictEqual(first.length, 13, `thirteen on Home by default (${first})`);
assert.deepStrictEqual(first.slice(-3), ['folder:g-play', 'folder:build', 'folder:tools'], 'the final group, Build and Quick tools folders stay at the end');
assert.ok(!first.includes('ai') && !first.includes('run'), 'developer services wait in the Build folder');
-- assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Popular on OpenVibe', 'until arranged, it says what it is');
-- assert.notStrictEqual(await p.evaluate(`document.querySelector('.ovs [data-tab="here"]').textContent`), 'Home', 'a page titled Home never names the first tab');
-- assert.ok(await p.evaluate(`!document.querySelector('[data-sec="recent"]') && ![...document.querySelectorAll('.ovs-group')].some((g) => /Recent searches/.test(g.textContent))`), 'no Recent searches');
-- assert.ok(await p.evaluate(`(() => { const b = document.querySelector('.ovh-headtools .ovh-allbtn'); return !!b && /All apps \\(\\d+\\)/.test(b.getAttribute('aria-label')) && /^All\\d+$/.test(b.textContent.replace(/\\s/g, '')) && b.getBoundingClientRect().width < 120; })()`), 'All apps is a small button beside Edit, and says how many');
-- assert.ok(await p.evaluate(`!document.querySelector('.ovh-actor')`), 'signed out: no Actor strip');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Popular on OpenVibe', 'until arranged, it says what it is'));
++ await eventually(async () => assert.notStrictEqual(await p.evaluate(`document.querySelector('.ovs [data-tab="here"]').textContent`), 'Home', 'a page titled Home never names the first tab'));
++ await eventually(async () => assert.ok(await p.evaluate(`!document.querySelector('[data-sec="recent"]') && ![...document.querySelectorAll('.ovs-group')].some((g) => /Recent searches/.test(g.textContent))`), 'no Recent searches'));
++ await eventually(async () => assert.ok(await p.evaluate(`(() => { const b = document.querySelector('.ovh-headtools .ovh-allbtn'); return !!b && /All apps \\(\\d+\\)/.test(b.getAttribute('aria-label')) && /^All\\d+$/.test(b.textContent.replace(/\\s/g, '')) && b.getBoundingClientRect().width < 120; })()`), 'All apps is a small button beside Edit, and says how many'));
++ await eventually(async () => assert.ok(await p.evaluate(`!document.querySelector('.ovh-actor')`), 'signed out: no Actor strip'));
// A long-press starts Edit, and the same press drags the first app onto the third's place.
await wait(400); // the menu's opening animation and the catalogue's repaint are over before measuring
@@@ -113,23 +114,23 @@ const server = http.createServer((req, res) => {
await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: a0.x, y: a0.y });
await p.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: a0.x, y: a0.y, button: 'left', clickCount: 1 });
await wait(650);
-- assert.ok(await p.evaluate(`!!document.querySelector('.ovh.is-edit')`), 'a long-press starts Edit');
++ await eventually(async () => assert.ok(await p.evaluate(`!!document.querySelector('.ovh.is-edit')`), 'a long-press starts Edit'));
for (let k = 1; k <= 6; k++) { await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: a0.x + ((a2.x - a0.x) * k) / 6, y: a0.y + ((a2.y - a0.y) * k) / 6, button: 'left' }); await wait(40); }
await p.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: a2.x, y: a2.y, button: 'left', clickCount: 1 });
await wait(450); // a person's pause after a drop (a tap within 300 ms of it is taken as part of the drag)
const dragged = await order(p);
assert.strictEqual(dragged.indexOf(first[0]), 2, `the app moved to where it was dropped (${dragged})`);
-- assert.ok(await p.evaluate(`location.pathname === '/'`), 'the long-press did not open the app');
++ await eventually(async () => assert.ok(await p.evaluate(`location.pathname === '/'`), 'the long-press did not open the app'));
const stored = await p.evaluate(`JSON.parse(localStorage.getItem('ovs-home')).home`);
assert.deepStrictEqual(stored, dragged, 'the arrangement is kept on the device');
-- assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Your home', 'once arranged it is yours');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Your home', 'once arranged it is yours'));
// Keys: the focused app moves; Delete takes it off Home.
await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${dragged[0]}"]').focus(), true`);
await p.evaluate(`document.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })), true`);
const keyed = await order(p);
assert.strictEqual(keyed.indexOf(dragged[0]), 1, 'ArrowRight moves the focused app one later');
-- assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-id')`), dragged[0], 'and it keeps the focus');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-id')`), dragged[0], 'and it keeps the focus'));
await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge').click(), true`);
const removed = await order(p);
assert.ok(!removed.includes(keyed[0]) && removed.length === 12, '− takes it off Home');
@@@ -148,7 +149,7 @@ const server = http.createServer((req, res) => {
assert.strictEqual(folder.n, 24, 'the folder shows two dozen tools');
assert.deepStrictEqual(folder.icons, ['pdf', 'image', 'qr', 'audio'], 'each tool wears its own icon');
assert.strictEqual(folder.more, 'All 30 tools →', 'and says how many there are in all');
-- assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on You');
++ await eventually(async () => assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on You'));
// ── Feed, signed out: today, the wall, went live in one row, text only, Show more; no composer.
await p.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
@@@ -169,7 +170,7 @@ const server = http.createServer((req, res) => {
assert.match(news.head, /What's new \(2 today\)/, 'the recently updated stay on the Feed, counted (library bumps left out)');
assert.deepStrictEqual(news.rows[0], ['Live', 'Updated 1 h ago · Big update', 'Go live from a phone: the Dock no longer covers the button'], 'each says what changed, when, and a big one says so');
assert.deepStrictEqual(news.rows[1], ['Video', 'Updated 2 h ago', 'Clips play from where you left off']);
-- assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on Feed');
++ await eventually(async () => assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on Feed'));
await p.close();
// ── Signed in: the Actor strip, Recent, the composer, and the arrangement goes to Network.
@@@ -178,38 +179,38 @@ const server = http.createServer((req, res) => {
assert.ok(await until(s, `!!document.getElementById('openvibe-burger')`), 'the navbar is up');
await openTab(s, 'you');
assert.ok(await until(s, `!!document.querySelector('.ovh-actor')`), 'the Actor strip is on top');
-- assert.match(await s.evaluate(`document.querySelector('.ovh-actor').textContent`), /Juniper.*Working.*Reading job posts/, 'it names the Actor, its state and its step');
++ await eventually(async () => assert.match(await s.evaluate(`document.querySelector('.ovh-actor').textContent`), /Juniper.*Working.*Reading job posts/, 'it names the Actor, its state and its step'));
// You on top: the name, Display (the Look panel) and Settings beside it; then your home, Most used and Jump back in.
// 4.195.0 (the owner, the old block crossed out: "get rid of this thing … we didn't really need [it] here, other than
// like adding your account"): one slim row of its own, and the old account block is not set in the menu at all.
-- assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-me .ovh-who-txt b').textContent`), 'Ada', 'your name on top');
-- assert.ok(await s.evaluate(`!document.querySelector('.ovs .ovh-account, .ovs .openvibe-navbar-dropdown, .ovs .ud-grid')`), 'the old account block and its grid of tiles are not in the menu');
-- assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-me > .ovh-ib')].map((b) => b.getAttribute('aria-label'))`), ['Add or switch account', 'Display: theme, text size, motion', 'Account and settings', 'Sign out'], 'add or switch account, Display, Settings, Sign out, in that order');
-- assert.ok(await s.evaluate(`/[?&]add_account=1/.test(document.querySelector('.ovh-me > a.ovh-ib').getAttribute('href')) && !!document.querySelector('.ovh-me [data-ov-look-open]') && !!document.querySelector('.ovh-me a[href="https://openvibe.network/my"]')`), 'the add-account address is the bar\'s; Display and Settings are in the row');
-- assert.ok(await s.evaluate(`!document.querySelector('.ovs-displayrow')`), 'Display is not at the foot any more');
++ await eventually(async () => assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-me .ovh-who-txt b').textContent`), 'Ada', 'your name on top'));
++ await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovs .ovh-account, .ovs .openvibe-navbar-dropdown, .ovs .ud-grid')`), 'the old account block and its grid of tiles are not in the menu'));
++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-me > .ovh-ib')].map((b) => b.getAttribute('aria-label'))`), ['Add or switch account', 'Display: theme, text size, motion', 'Account and settings', 'Sign out'], 'add or switch account, Display, Settings, Sign out, in that order'));
++ await eventually(async () => assert.ok(await s.evaluate(`/[?&]add_account=1/.test(document.querySelector('.ovh-me > a.ovh-ib').getAttribute('href')) && !!document.querySelector('.ovh-me [data-ov-look-open]') && !!document.querySelector('.ovh-me a[href="https://openvibe.network/my"]')`), 'the add-account address is the bar\'s; Display and Settings are in the row'));
++ await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovs-displayrow')`), 'Display is not at the foot any more'));
assert.ok(await until(s, `document.querySelectorAll('.ovh-rail .ovh-card').length === 2`), 'Jump back in shows the places opened lately');
-- assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-rail .ovh-card')].map((a) => a.getAttribute('href'))`), ['https://openvibe.live/ada', 'https://openvibe.pics/1']);
-- assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => k.textContent)`), ['Live5', 'Pics2'], 'Most used: pages opened per site');
-- assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec a.ovh-link[href="https://openvibe.report/me"]') || {}).textContent`), 'Your stats', 'Most used links to your stats on Report');
++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-rail .ovh-card')].map((a) => a.getAttribute('href'))`), ['https://openvibe.live/ada', 'https://openvibe.pics/1']));
++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => k.textContent)`), ['Live5', 'Pics2'], 'Most used: pages opened per site'));
++ await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec a.ovh-link[href="https://openvibe.report/me"]') || {}).textContent`), 'Your stats', 'Most used links to your stats on Report'));
// The owner, 2026-11-10: "Your home should be above Jump back in… the thing at the top a little more compact… links
// to whatever the user uses the most". Your home comes first, then what you use most, then where you were last;
// the bar with your name is short; each most-used app is a way in (its icon, a full touch target), not a legend.
-- assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-sec > span')].map((x) => x.textContent)`), ['Your home', 'Most used', 'Jump back in'], 'your home, then most used, then where you were');
-- assert.ok(await s.evaluate(`document.querySelector('.ovh-me').getBoundingClientRect().height <= 64`), 'the row with your name is one short row');
-- assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-me > .ovh-ib')].every((b) => { const r = b.getBoundingClientRect(); return r.height >= 44 && r.width >= 40; })`), 'and its buttons are full touch targets');
-- assert.ok(await s.evaluate(`(() => { const m = document.querySelector('.ovh-me').getBoundingClientRect(); return m.right <= innerWidth && [...document.querySelectorAll('.ovh-me > *')].every((x) => x.getBoundingClientRect().right <= m.right + 1); })()`), 'all of it on the screen');
++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-sec > span')].map((x) => x.textContent)`), ['Your home', 'Most used', 'Jump back in'], 'your home, then most used, then where you were'));
++ await eventually(async () => assert.ok(await s.evaluate(`document.querySelector('.ovh-me').getBoundingClientRect().height <= 64`), 'the row with your name is one short row'));
++ await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-me > .ovh-ib')].every((b) => { const r = b.getBoundingClientRect(); return r.height >= 44 && r.width >= 40; })`), 'and its buttons are full touch targets'));
++ await eventually(async () => assert.ok(await s.evaluate(`(() => { const m = document.querySelector('.ovh-me').getBoundingClientRect(); return m.right <= innerWidth && [...document.querySelectorAll('.ovh-me > *')].every((x) => x.getBoundingClientRect().right <= m.right + 1); })()`), 'all of it on the screen'));
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => [k.getAttribute('href'), k.getAttribute('aria-label'), !!k.querySelector('.ovh-ic > *')].join('|'))`),
['https://openvibe.live/|Live, opened 5 times|true', 'https://openvibe.pics/|Pics, opened 2 times|true'], 'each most-used app is a link with its icon and says how often');
-- assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].every((k) => { const r = k.getBoundingClientRect(); return r.height >= 44 && r.width >= 44; })`), 'each a full touch target');
-- assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-rail').parentElement.querySelector('.ovh-sec small') || {}).textContent`), 'where you were last', 'Jump back in says what it is');
-- assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start');
-- assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons');
++ await eventually(async () => assert.ok(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].every((k) => { const r = k.getBoundingClientRect(); return r.height >= 44 && r.width >= 44; })`), 'each a full touch target'));
++ await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-rail').parentElement.querySelector('.ovh-sec small') || {}).textContent`), 'where you were last', 'Jump back in says what it is'));
++ await eventually(async () => assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start'));
++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons'));
await s.evaluate(`document.querySelector('.ovh-sec .ovh-edit').click(), true`);
await s.evaluate(`(() => { const t = document.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); t.focus(); t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); })()`);
assert.ok(await until(s, `window.__calls.some((c) => c.m === 'PUT' && c.u.endsWith('/api/v1/me/home-screen') && c.auth === 'Bearer tok-1')`, 3000), 'the arrangement goes to Network with the bearer');
await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
assert.ok(await until(s, `!!document.querySelector('.ovh-compose textarea')`), 'the composer shows when Community says you can post');
-- assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-compose textarea').placeholder`), "What's new, Ada?");
++ await eventually(async () => assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-compose textarea').placeholder`), "What's new, Ada?"));
// Pictures (11-09: "you can make posts and put images"): two picked, each must say what it shows, then each
// uploads through Network and the post names them with that text.
await s.evaluate(`(async () => {
@@@ -220,9 +221,9 @@ const server = http.createServer((req, res) => {
const input = document.querySelector('.ovh-compose input[type=file]'); input.files = dt.files; input.dispatchEvent(new Event('change'));
})(), true`);
assert.ok(await until(s, `document.querySelectorAll('.ovh-pic img[src^="data:"]').length === 2`), 'two pictures, each with a preview');
-- assert.ok(await s.evaluate(`document.querySelector('.ovh-post').disabled && document.querySelector('.ovh-compose-hint').textContent === 'Describe each picture'`), 'no post until each picture says what it shows');
++ await eventually(async () => assert.ok(await s.evaluate(`document.querySelector('.ovh-post').disabled && document.querySelector('.ovh-compose-hint').textContent === 'Describe each picture'`), 'no post until each picture says what it shows'));
await s.evaluate(`[...document.querySelectorAll('.ovh-pic input')].forEach((x, i) => { x.value = ['A dark sky', 'The same sky'][i]; x.dispatchEvent(new Event('input')); }), true`);
-- assert.ok(!(await s.evaluate(`document.querySelector('.ovh-post').disabled`)), 'described: Post is on');
++ await eventually(async () => assert.ok(!(await s.evaluate(`document.querySelector('.ovh-post').disabled`)), 'described: Post is on'));
await s.evaluate(`document.querySelector('.ovh-post').click(), true`);
assert.ok(await until(s, `/Posted/.test(document.querySelector('.ovh-compose ~ .ovh-note, .ovh-note').textContent)`), 'it posts');
const sent = await s.evaluate(`window.__calls.filter((c) => c.u.endsWith('/api/frame/attachments') || c.u.endsWith('/api/frame/posts')).map((c) => ({ u: c.u.replace('https://openvibe.network/api/frame/', ''), auth: c.auth, body: typeof c.body === 'string' ? c.body : '' }))`);
@@@ -252,7 +253,7 @@ const server = http.createServer((req, res) => {
const closing = await s.evaluate(`(() => { window.OpenVibeDock.close(); const dock = document.querySelector('.ovdk'); return { hidden: dock.hidden, out: dock.classList.contains('is-out'), back: dock.querySelector('.ovdk-orbx').classList.contains('is-from'), navIsDock: !!document.querySelector('.ovnav-orb.is-dock') }; })()`);
assert.ok(!closing.hidden && closing.out && closing.back && !closing.navIsDock, `closing folds it back into the orb first (${JSON.stringify(closing)})`);
assert.ok(await until(s, `document.querySelector('.ovdk').hidden`), 'then the sheet is gone');
-- assert.ok(await s.evaluate(`!document.querySelector('.ovdk').classList.contains('from-orb') && !document.querySelector('.ovnav-orb.is-dock') && [...document.querySelectorAll('.openvibe-navbar .ovnav-orb')].every((b) => !b.style.transition)`), 'and the navbar\'s orb is as it was');
++ await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovdk').classList.contains('from-orb') && !document.querySelector('.ovnav-orb.is-dock') && [...document.querySelectorAll('.openvibe-navbar .ovnav-orb')].every((b) => !b.style.transition)`), 'and the navbar\'s orb is as it was'));
const still = await s.evaluate(`(() => { document.documentElement.setAttribute('data-ov-motion', 'off'); window.OpenVibeDock.open({ tab: 'activity' }); const dock = document.querySelector('.ovdk'); const a = dock.classList.contains('from-orb'); window.OpenVibeDock.close(); const out = { fromOrb: a, hidden: dock.hidden }; document.documentElement.removeAttribute('data-ov-motion'); return out; })()`);
assert.ok(!still.fromOrb && still.hidden, 'motion off: no move, and it closes at once');
await s.close();
ddiff --git a/test/menu-navigate-chrome.test.js b/test/menu-navigate-chrome.test.js
iindex 18d03ca..913695b 100644
---- a/test/menu-navigate-chrome.test.js
++++ b/test/menu-navigate-chrome.test.js
@@@ -9,6 +9,7 @@
// (defaultPrevented) draws nothing.
// Skipped without Chrome.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -73,8 +74,8 @@ const server = http.createServer((req, res) => {
await p.evaluate(`[...document.querySelectorAll('.ovs-links .ovs-link')].find((a) => a.textContent === 'Camera').click(), true`);
assert.ok(await until(p, `!document.querySelector('.ovs.is-open')`, 1500), 'the menu closed when a page was chosen');
await wait(300);
-- assert.strictEqual(await p.evaluate('location.pathname'), '/camera', 'the page moved, and the menu did not step Back over it');
-- assert.ok(await p.evaluate('history.length') >= before, 'no history entry was taken back');
++ await eventually(async () => assert.strictEqual(await p.evaluate('location.pathname'), '/camera', 'the page moved, and the menu did not step Back over it'));
++ await eventually(async () => assert.ok(await p.evaluate('history.length') >= before, 'no history entry was taken back'));
// Open again on the new page: Camera is marked; swap back to / in place while it is open: Home is marked.
await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);
@@@ -88,7 +89,7 @@ const server = http.createServer((req, res) => {
// A click the page handled draws no bar; a link that leaves draws it at once, while the next page loads.
await p.evaluate(`document.getElementById('handled').click(), true`);
await wait(100);
-- assert.strictEqual(await p.evaluate(`!!document.querySelector('#ovnav-leave.is-run')`), false, 'a handled click draws nothing');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`!!document.querySelector('#ovnav-leave.is-run')`), false, 'a handled click draws nothing'));
await p.evaluate(`document.getElementById('plain').click(), true`);
assert.ok(await until(p, `window.__left === true`, 1500), 'the plain link was clicked');
assert.ok(await until(p, `(() => { const b = document.getElementById('ovnav-leave'); return !!b && b.classList.contains('is-run') && getComputedStyle(b).position === 'fixed' && parseFloat(getComputedStyle(b).height) === 3; })()`, 1500), 'the top bar runs as the page is left');
ddiff --git a/test/menu-places-chrome.test.js b/test/menu-places-chrome.test.js
iindex e27c202..ba115d5 100644
---- a/test/menu-places-chrome.test.js
++++ b/test/menu-places-chrome.test.js
@@@ -10,6 +10,7 @@ const os = require('node:os');
const path = require('node:path');
const http = require('node:http');
const h = require('../browser-harness');
++const { eventually } = h; // a check retries until the page is there (5 s ceiling), never a fixed sleep
if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('menu-places-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
const root = path.join(__dirname, '..');
const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Chat</title><link rel="icon" href="data:,"></head>
@@@ -49,39 +50,39 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
await page.evaluate(OPEN(true).replace("service: 'chat'", "service: 'design'"));
assert.ok(await until(`document.querySelectorAll('.ovs.is-open .ovs-starttabs .ovs-tab').length === 5`), `${name}: five places on another site`);
-- assert.deepEqual((await page.evaluate(ROW)).slice(2, 4).map((t) => t.label), ['Chat', 'Vibe'], `${name}: on another site, Chat and Vibe`);
++ await eventually(async () => assert.deepEqual((await page.evaluate(ROW)).slice(2, 4).map((t) => t.label), ['Chat', 'Vibe'], `${name}: on another site, Chat and Vibe`));
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
await page.evaluate(OPEN(true)); await wait(350);
}
assert.ok(row.every((t) => t.top === row[0].top), `${name}: one row (${row.map((t) => t.top).join(',')})`);
assert.ok(row.every((t) => Math.abs(t.w - row[0].w) <= 1 && t.h >= 42), `${name}: the same width each, and tall enough to press (${row.map((t) => `${t.w}x${t.h}`).join(' ')})`);
assert.ok(row.every((t) => t.role === 'tab') && row[row.length - 1].right <= width, `${name}: all on the screen`);
-- assert.deepEqual(await page.evaluate(`Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).map((t) => t.dataset.tab)`), ['here', 'feed', 'chats', 'vibe', 'you'], `${name}: stable place ids`);
-- assert.equal(await page.evaluate('OpenVibeSearch._state().from === document.body'), true, `${name}: opening source retained`);
-- assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-bridge, .ovs-bridge-b').length`), 0, `${name}: no second row of buttons under the tabs`);
-- assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-starttabs .ovs-tab svg, .ovs-starttabs .ovs-tab .ovs-tab-ic').length >= 4`), true, `${name}: each place has its icon`);
++ await eventually(async () => assert.deepEqual(await page.evaluate(`Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).map((t) => t.dataset.tab)`), ['here', 'feed', 'chats', 'vibe', 'you'], `${name}: stable place ids`));
++ await eventually(async () => assert.equal(await page.evaluate('OpenVibeSearch._state().from === document.body'), true, `${name}: opening source retained`));
++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-bridge, .ovs-bridge-b').length`), 0, `${name}: no second row of buttons under the tabs`));
++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-starttabs .ovs-tab svg, .ovs-starttabs .ovs-tab .ovs-tab-ic').length >= 4`), true, `${name}: each place has its icon`));
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click()`);
-- assert.equal(await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"] .ovs-tabnew-red').textContent`), '7', `${name}: You has the red unread badge`);
-- assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-notifications-host [data-role="list"]')?.textContent`), 'five newest', `${name}: You starts with the notifications preview`);
-- assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead-more')?.textContent`), 'Show all 12', `${name}: notification expansion count, in the section's head`);
-- assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead b')?.textContent`), 'Notifications', `${name}: the section is named`);
++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"] .ovs-tabnew-red').textContent`), '7', `${name}: You has the red unread badge`));
++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-notifications-host [data-role="list"]')?.textContent`), 'five newest', `${name}: You starts with the notifications preview`));
++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead-more')?.textContent`), 'Show all 12', `${name}: notification expansion count, in the section's head`));
++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead b')?.textContent`), 'Notifications', `${name}: the section is named`));
await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead-more').click()`);
-- assert.equal(await page.evaluate('window.notificationsExpanded'), true, `${name}: notifications expand in place`);
++ await eventually(async () => assert.equal(await page.evaluate('window.notificationsExpanded'), true, `${name}: notifications expand in place`));
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click()`);
if (width <= 640) {
await page.evaluate(`document.querySelector('.ovs-list > li > .ovs-dockyou-b').click()`);
-- assert.deepEqual(await page.evaluate(`Array.from(document.querySelectorAll('.ovs-dock-you button')).map((b) => b.textContent)`), ['Start a timer', 'Keep the Dock button on every page', 'Open the Dock in its own window'], `${name}: The Dock row opens its three actions in place`);
++ await eventually(async () => assert.deepEqual(await page.evaluate(`Array.from(document.querySelectorAll('.ovs-dock-you button')).map((b) => b.textContent)`), ['Start a timer', 'Keep the Dock button on every page', 'Open the Dock in its own window'], `${name}: The Dock row opens its three actions in place`));
await page.evaluate(`document.querySelector('.ovs-dock-you button').click()`);
-- assert.equal(await page.evaluate('window.timerCalled'), 'timer', `${name}: You Dock timer action`);
++ await eventually(async () => assert.equal(await page.evaluate('window.timerCalled'), 'timer', `${name}: You Dock timer action`));
}
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="here"]').click()`);
if (width > 640) {
// A computer opens the floating Dock beside the menu.
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`);
-- assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: Vibe opens the Dock's Vibe`);
++ await eventually(async () => assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: Vibe opens the Dock's Vibe`));
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`);
-- assert.equal(await page.evaluate('window.went'), 'chats', `${name}: Messages opens the Dock's conversations`);
-- assert.deepEqual((await page.evaluate(ROW)).filter((t) => t.chosen).map((t) => t.label), ['Chat'], `${name}: the chosen tab is still this site's`);
++ await eventually(async () => assert.equal(await page.evaluate('window.went'), 'chats', `${name}: Messages opens the Dock's conversations`));
++ await eventually(async () => assert.deepEqual((await page.evaluate(ROW)).filter((t) => t.chosen).map((t) => t.label), ['Chat'], `${name}: the chosen tab is still this site's`));
}
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`);
assert.ok(await until(`(document.querySelector('.ovs-starttabs [aria-selected="true"]') || {}).textContent.trim().startsWith('Feed')`), `${name}: Feed is chosen in place`);
@@@ -97,32 +98,32 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
chosen: Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab[aria-selected="true"]')).map((t) => t.textContent.trim()).join(' ') })`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(120);
if (width <= 640) {
-- assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat* Code Agents', chosen: 'Vibe' }, `${name}: Vibe opens inside the menu, on its chats`);
-- assert.equal(await page.evaluate('window.went'), null, `${name}: no floating Dock`);
++ await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat* Code Agents', chosen: 'Vibe' }, `${name}: Vibe opens inside the menu, on its chats`));
++ await eventually(async () => assert.equal(await page.evaluate('window.went'), null, `${name}: no floating Dock`));
await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="code"]').click()`); await wait(80);
-- assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat Code* Agents', chosen: 'Vibe' }, `${name}: the switch goes to Code`);
++ await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat Code* Agents', chosen: 'Vibe' }, `${name}: the switch goes to Code`));
await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="agent"]').click()`); await wait(80);
-- assert.equal((await inside()).sw, 'Chat Code Agents*', `${name}: the switch goes to Agents`);
++ await eventually(async () => assert.equal((await inside()).sw, 'Chat Code Agents*', `${name}: the switch goes to Agents`));
await page.evaluate(`document.querySelector('.ovs-ai-timer').click()`);
-- assert.equal(await page.evaluate('window.timerCalled'), 'timer', `${name}: AI timer entry`);
++ await eventually(async () => assert.equal(await page.evaluate('window.timerCalled'), 'timer', `${name}: AI timer entry`));
await page.evaluate(`document.querySelector('.ovs-input').value = 'find this'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(170);
await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));`);
-- assert.equal(await page.evaluate('window.asked'), 'find this', `${name}: AI Enter hands its text to Vibe`);
++ await eventually(async () => assert.equal(await page.evaluate('window.asked'), 'find this', `${name}: AI Enter hands its text to Vibe`));
await page.evaluate(`document.querySelector('.ovs-input').value = ''; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`); await wait(80);
-- assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: '', chosen: 'Messages' }, `${name}: Messages inside the menu, no switch`);
++ await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: '', chosen: 'Messages' }, `${name}: Messages inside the menu, no switch`));
await page.evaluate(`document.querySelector('.ovs-input').value = 'sara'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(180);
-- assert.equal(await page.evaluate('window.filtered'), 'sara', `${name}: Messages search filters conversations in place`);
++ await eventually(async () => assert.equal(await page.evaluate('window.filtered'), 'sara', `${name}: Messages search filters conversations in place`));
await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));`);
-- assert.equal(await page.evaluate(`!!(document.querySelector('.ovs-input').value === '' && document.querySelector('.ovs.is-open'))`), true, `${name}: Escape clears before closing`);
++ await eventually(async () => assert.equal(await page.evaluate(`!!(document.querySelector('.ovs-input').value === '' && document.querySelector('.ovs.is-open'))`), true, `${name}: Escape clears before closing`));
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`); await wait(80);
-- assert.deepEqual(await inside(), { door: false, list: true, stub: false, sw: '', chosen: 'Feed' }, `${name}: Feed hands the panel back`);
++ await eventually(async () => assert.deepEqual(await inside(), { door: false, list: true, stub: false, sw: '', chosen: 'Feed' }, `${name}: Feed hands the panel back`));
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(80);
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
-- assert.deepEqual(await page.evaluate('window.log'), ['mount:vibe', 'show:code', 'show:agent', 'show:chats', 'destroy', 'mount:agent', 'destroy'], `${name}: one mount at a time, handed back on Feed and on close`);
++ await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), ['mount:vibe', 'show:code', 'show:agent', 'show:chats', 'destroy', 'mount:agent', 'destroy'], `${name}: one mount at a time, handed back on Feed and on close`));
} else {
-- assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: AI still opens the floating Dock`);
-- assert.deepEqual(await page.evaluate('window.log'), [], `${name}: nothing mounted in the menu`);
++ await eventually(async () => assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: AI still opens the floating Dock`));
++ await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), [], `${name}: nothing mounted in the menu`));
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
}
await page.evaluate('delete window.OpenVibeDock');
@@@ -141,7 +142,7 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
// A visitor: three places, as before.
await page.evaluate(OPEN(false));
assert.ok(await until(`document.querySelectorAll('.ovs.is-open .ovs-starttabs .ovs-tab').length === 3`), `${name}: a visitor has this site, Feed and You`);
-- assert.deepEqual((await page.evaluate(ROW)).map((t) => t.label), ['Chat', 'Feed', 'You'].map((x, i) => x), `${name}: in that order`);
++ await eventually(async () => assert.deepEqual((await page.evaluate(ROW)).map((t) => t.label), ['Chat', 'Feed', 'You'].map((x, i) => x), `${name}: in that order`));
await page.close();
}
for (const width of [390, 1280]) {
@@@ -154,13 +155,13 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
: new Response('{}', { status: 404 }); localStorage.setItem('ovs-feed-seen', '1');`);
const endBadge = Date.now() + 7000;
while (!await page.evaluate("!!document.querySelector('.ovnav-avatar-count:not([hidden])')") && Date.now() < endBadge) await wait(80);
-- assert.equal(await page.evaluate("!!document.querySelector('.openvibe-bell, #openvibe-bell-mount')"), false, `${width}px: no bar bell or bell mount`);
-- assert.equal(await page.evaluate("document.querySelector('.ovnav-avatar-count')?.textContent"), '7', `${width}px: avatar carries the unread count`);
++ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.openvibe-bell, #openvibe-bell-mount')"), false, `${width}px: no bar bell or bell mount`));
++ await eventually(async () => assert.equal(await page.evaluate("document.querySelector('.ovnav-avatar-count')?.textContent"), '7', `${width}px: avatar carries the unread count`));
await page.evaluate("document.querySelector('#openvibe-avatar-btn').click()"); await wait(500);
-- assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open .ovs-starttabs [data-tab=you][aria-selected=true]')"), true, `${width}px avatar opens You`);
-- assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open [data-sec=notifications]')"), true, `${width}px avatar opens at Notifications`);
-- assert.equal(await page.evaluate("!!document.querySelector('.openvibe-navbar .ovs-feed-dot:not([hidden])')"), true, `${width}px menu button carries the calm Feed dot`);
-- assert.equal(await page.evaluate("!!document.querySelector('.ovdk.is-open')"), false, `${width}px avatar does not open the floating Dock`);
++ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open .ovs-starttabs [data-tab=you][aria-selected=true]')"), true, `${width}px avatar opens You`));
++ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovs.is-open [data-sec=notifications]')"), true, `${width}px avatar opens at Notifications`));
++ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.openvibe-navbar .ovs-feed-dot:not([hidden])')"), true, `${width}px menu button carries the calm Feed dot`));
++ await eventually(async () => assert.equal(await page.evaluate("!!document.querySelector('.ovdk.is-open')"), false, `${width}px avatar does not open the floating Dock`));
await page.close();
}
// Two pages in one browser context share the Feed seen time; the receiving open menu redraws its badge at once.
ddiff --git a/test/mini-chrome.test.js b/test/mini-chrome.test.js
iindex 1c2d74a..38603f0 100644
---- a/test/mini-chrome.test.js
++++ b/test/mini-chrome.test.js
@@@ -5,6 +5,7 @@
// draws nothing there; Close stops and forgets; on a phone it is a bar across the bottom; a page's own video starting
// closes it.
const assert = require('node:assert/strict');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
@@@ -80,7 +81,7 @@ const server = http.createServer((req, res) => {
await page.evaluate(`document.querySelector('.ovmini-text').click()`);
assert.ok(await until(`location.pathname === '/watch' && !!document.getElementById('v') && document.getElementById('v').readyState >= 1`), 'back on its page');
assert.ok(await until(`document.getElementById('v').currentTime >= ${at - 0.05}`), 'the page takes the second back');
-- assert.deepEqual(await page.evaluate(`[!!document.querySelector('.ovmini'), OpenVibeMini.record()]`), [false, null], 'no small player on the video\'s own page, and nothing left written down');
++ await eventually(async () => assert.deepEqual(await page.evaluate(`[!!document.querySelector('.ovmini'), OpenVibeMini.record()]`), [false, null], 'no small player on the video\'s own page, and nothing left written down'));
// Close stops and forgets.
await take();
@@@ -89,13 +90,13 @@ const server = http.createServer((req, res) => {
await page.evaluate(`document.querySelector('.ovmini [data-m="close"]').click()`);
assert.ok(await until(`!document.querySelector('.ovmini') && OpenVibeMini.record() === null`), 'closed and forgotten');
await page.goto(`${base}/second`, { maxMs: 6000 }); await wait(300);
-- assert.equal(await page.evaluate(`!!document.querySelector('.ovmini')`), false, 'and it does not come back on the next page');
++ await eventually(async () => assert.equal(await page.evaluate(`!!document.querySelector('.ovmini')`), false, 'and it does not come back on the next page'));
// A file that cannot be played here: the card says so and keeps the way back; nothing follows to the next page.
await page.goto(`${base}/second`, { maxMs: 6000 });
await page.evaluate(`OpenVibeMini.open({ page: '/watch', src: '/no-such-file.webm', title: 'The harbour at night', by: 'Kim', t: 3, playing: true })`);
assert.ok(await until(`!!document.querySelector('.ovmini.is-broken')`), 'a file that cannot play is said, not hidden');
-- assert.deepEqual(await page.evaluate(`(() => { const e = document.querySelector('.ovmini'); return [e.querySelector('.ovmini-by').textContent, e.querySelector('[data-m="play"]').hidden, e.querySelector('.ovmini-text').getAttribute('href'), OpenVibeMini.record()]; })()`), ['Open its page to keep watching', true, '/watch', null]);
++ await eventually(async () => assert.deepEqual(await page.evaluate(`(() => { const e = document.querySelector('.ovmini'); return [e.querySelector('.ovmini-by').textContent, e.querySelector('[data-m="play"]').hidden, e.querySelector('.ovmini-text').getAttribute('href'), OpenVibeMini.record()]; })()`), ['Open its page to keep watching', true, '/watch', null]));
await page.evaluate(`OpenVibeMini.close()`);
// A phone: one bar across the bottom, the picture at its left, 44 px buttons.
ddiff --git a/test/motion-ghost-chrome.test.js b/test/motion-ghost-chrome.test.js
iindex 02af167..713c6e7 100644
---- a/test/motion-ghost-chrome.test.js
++++ b/test/motion-ghost-chrome.test.js
@@@ -4,6 +4,7 @@
// the reader touches the scene; and under reduced motion there is no cursor at all. Skipped (exit 0) without Chrome or
// with OV_SKIP_BROWSER=1.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -46,17 +47,17 @@ const server = http.createServer((req, res) => {
if (now && now !== seen[seen.length - 1]) seen.push(now);
}
assert.deepStrictEqual(seen, ['First', 'Second', 'Third'], 'the steps in their numbered order, one chosen at a time');
-- assert.strictEqual(await p.evaluate('!!document.querySelector(".ov-ghost[aria-hidden=true]")'), true, 'the cursor is there, hidden from assistive tech');
-- assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 390, 'the page stays its width');
++ await eventually(async () => assert.strictEqual(await p.evaluate('!!document.querySelector(".ov-ghost[aria-hidden=true]")'), true, 'the cursor is there, hidden from assistive tech'));
++ await eventually(async () => assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 390, 'the page stays its width'));
await p.evaluate('document.querySelector(".row").dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); 1');
await wait(200);
-- assert.strictEqual(await p.evaluate(`!document.querySelector(".ov-ghost") && ${chosen} === ""`), true, 'a touch ends it for good');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`!document.querySelector(".ov-ghost") && ${chosen} === ""`), true, 'a touch ends it for good'));
const still = await h.openPage(browser, context, { width: 390 });
await still.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
await still.goto(`${base}/still`, { maxMs: 5000 });
await wait(1500);
-- assert.strictEqual(await still.evaluate('!document.querySelector(".ov-ghost") && document.querySelectorAll("[data-ov-ghost-on]").length === 0'), true, 'motion off: no cursor at all');
++ await eventually(async () => assert.strictEqual(await still.evaluate('!document.querySelector(".ov-ghost") && document.querySelectorAll("[data-ov-ghost-on]").length === 0'), true, 'motion off: no cursor at all'));
const errors = p.state.errors.concat(still.state.errors).filter((e) => !/Failed to load resource/.test(e.text));
assert.deepStrictEqual(errors, [], JSON.stringify(errors));
console.log('motion-ghost-chrome: all checks passed');
ddiff --git a/test/navbar-launcher.test.js b/test/navbar-launcher.test.js
iindex e5ca21d..c49cd8c 100644
---- a/test/navbar-launcher.test.js
++++ b/test/navbar-launcher.test.js
@@@ -5,6 +5,7 @@
// the magnifier or the drawer's "All sites". Runs in a real headless Chrome (skipped without Chrome or with
// OV_SKIP_BROWSER=1), with the Frame answer served through a stubbed fetch.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -61,7 +62,7 @@ const server = http.createServer((req, res) => {
assert.strictEqual(first.display, false, 'this site\'s tab is its pages and every site');
await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click(), true`);
await wait(150);
-- assert.strictEqual(await p.evaluate(`!!document.querySelector('.ovs-displayrow .ovnav-display')`), true, 'Display is on the You tab');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`!!document.querySelector('.ovs-displayrow .ovnav-display')`), true, 'Display is on the You tab'));
assert.strictEqual(first.expanded, 'true', 'the burger says the menu is open');
// The drawer's "All sites" (kept for sites that ask for the legacy drawer) opens the list of every site.
await p.evaluate(`document.querySelector('#openvibe-drawer [data-open-launcher]').click(), true`);
ddiff --git a/test/ov-canvas-chrome.test.js b/test/ov-canvas-chrome.test.js
iindex 8aae76b..cb6d7ca 100644
---- a/test/ov-canvas-chrome.test.js
++++ b/test/ov-canvas-chrome.test.js
@@@ -4,6 +4,7 @@
// panel back, and a wide table never makes a phone's page scroll sideways.
// node test/ov-canvas-chrome.test.js
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -61,42 +62,42 @@ const server = http.createServer((req, res) => {
await p.goto(`${base}/`, { maxMs: 5000 });
await wait(200);
const C = "document.getElementById('c')";
-- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Lisbon, 14–17 November');
-- assert.strictEqual(await p.evaluate(`${C}.querySelector('h3').textContent`), 'Lisbon, 14–17 November', 'a heading');
-- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-metric-v').textContent`), '€612', 'a metric');
-- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-when').textContent`), '5 min ago', 'when it changed, in words');
-- assert.deepStrictEqual(await p.evaluate(`[...${C}.querySelectorAll('.ovc-check input')].map((i) => i.checked)`), [true, false], 'a checklist');
-- assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('.ovc-table tbody tr').length`), 2, 'a table');
-- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-table td.is-num').textContent`), '118', 'numbers align as numbers');
-- assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg').length`), 2, 'two charts');
-- assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg polyline').length`), 1, 'a line');
-- assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg rect').length`), 2, 'grouped bars');
-- assert.ok(await p.evaluate(`${C}.querySelector('table.ovc-sr').textContent.includes('240€')`), "the chart's numbers are a table for a screen reader");
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Lisbon, 14–17 November'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('h3').textContent`), 'Lisbon, 14–17 November', 'a heading'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-metric-v').textContent`), '€612', 'a metric'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-when').textContent`), '5 min ago', 'when it changed, in words'));
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`[...${C}.querySelectorAll('.ovc-check input')].map((i) => i.checked)`), [true, false], 'a checklist'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('.ovc-table tbody tr').length`), 2, 'a table'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-table td.is-num').textContent`), '118', 'numbers align as numbers'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg').length`), 2, 'two charts'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg polyline').length`), 1, 'a line'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('svg rect').length`), 2, 'grouped bars'));
++ await eventually(async () => assert.ok(await p.evaluate(`${C}.querySelector('table.ovc-sr').textContent.includes('240€')`), "the chart's numbers are a table for a screen reader"));
// Only openvibe.media pictures, only https links, never markup, never a block it does not know.
-- assert.strictEqual(await p.evaluate("[...document.querySelectorAll('img')].every((i) => i.getAttribute('src').startsWith('https://openvibe.media/'))"), true, 'only openvibe.media pictures (one that fails to load is removed)');
++ await eventually(async () => assert.strictEqual(await p.evaluate("[...document.querySelectorAll('img')].every((i) => i.getAttribute('src').startsWith('https://openvibe.media/'))"), true, 'only openvibe.media pictures (one that fails to load is removed)'));
assert.deepStrictEqual(await p.evaluate(`[...${C}.querySelectorAll('a')].map((a) => [a.getAttribute('href'), a.target, a.rel, a.querySelector('span').textContent])`),
[['https://openvibe.video/watch?v=AbCdEfGhIjK', '_blank', 'noopener noreferrer nofollow', 'openvibe.video']], 'a link shows its host; a javascript: link is not drawn');
-- assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('script, iframe, style:not(#ov-canvas-css)').length`), 0, 'no script, frame or style inside');
-- assert.strictEqual(await p.evaluate('window.__xss === undefined'), true, 'text is text');
-- assert.match(await p.evaluate(`${C}.querySelector('.ovc-text').textContent`), /^<script>window.__xss = 1<\/script>/, 'shown as written');
-- assert.strictEqual(await p.evaluate(`${C}.textContent.includes('never')`), false, 'an html block and a running button are left out');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelectorAll('script, iframe, style:not(#ov-canvas-css)').length`), 0, 'no script, frame or style inside'));
++ await eventually(async () => assert.strictEqual(await p.evaluate('window.__xss === undefined'), true, 'text is text'));
++ await eventually(async () => assert.match(await p.evaluate(`${C}.querySelector('.ovc-text').textContent`), /^<script>window.__xss = 1<\/script>/, 'shown as written'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.textContent.includes('never')`), false, 'an html block and a running button are left out'));
// A button only asks; a tick tells; a canvas without the callbacks only shows.
-- assert.deepStrictEqual(await p.evaluate(`[...${C}.querySelectorAll('.ovc-btn')].map((b) => b.textContent)`), ['Book Casa A']);
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate(`[...${C}.querySelectorAll('.ovc-btn')].map((b) => b.textContent)`), ['Book Casa A']));
await p.evaluate(`${C}.querySelector('.ovc-btn').click()`);
-- assert.deepStrictEqual(await p.evaluate('window.__asked'), ['Book Casa A'], "the label goes to the agent as the person's message");
-- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-btn').textContent`), 'Sent');
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__asked'), ['Book Casa A'], "the label goes to the agent as the person's message"));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-btn').textContent`), 'Sent'));
await p.evaluate(`${C}.querySelectorAll('.ovc-check input')[1].click()`);
-- assert.deepStrictEqual(await p.evaluate('window.__checked'), [['todo', 'hotel', true]], 'the tick names the block and the item');
-- assert.strictEqual(await p.evaluate("document.querySelector('#ro .ovc-btn').disabled && document.querySelector('#ro .ovc-check input').disabled"), true, 'no callbacks, nothing to press');
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__checked'), [['todo', 'hotel', true]], 'the tick names the block and the item'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('#ro .ovc-btn').disabled && document.querySelector('#ro .ovc-check input').disabled"), true, 'no callbacks, nothing to press'));
// An older version never rolls the panel back; a newer one replaces it.
-- assert.strictEqual(await p.evaluate(`OVCanvas.render(${C}, { ...window.__DOC, version: 2, title: 'Old' })`), 3);
-- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Lisbon, 14–17 November');
-- assert.strictEqual(await p.evaluate(`OVCanvas.render(${C}, { ...window.__DOC, version: 4, title: 'Newer' })`), 4);
-- assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Newer');
-- assert.strictEqual(await p.evaluate("OVCanvas.valid({ canvas: 'something-else', version: 1, blocks: [] })"), false);
++ await eventually(async () => assert.strictEqual(await p.evaluate(`OVCanvas.render(${C}, { ...window.__DOC, version: 2, title: 'Old' })`), 3));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Lisbon, 14–17 November'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`OVCanvas.render(${C}, { ...window.__DOC, version: 4, title: 'Newer' })`), 4));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${C}.querySelector('.ovc-title').textContent`), 'Newer'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("OVCanvas.valid({ canvas: 'something-else', version: 1, blocks: [] })"), false));
// A phone: the wide table scrolls in its own box; the page never scrolls sideways.
-- assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 390, 'no sideways scroll at 390 px');
-- assert.ok(await p.evaluate(`${C}.querySelector('.ovc-table').scrollWidth > ${C}.querySelector('.ovc-table').clientWidth`), 'the table scrolls inside');
++ await eventually(async () => assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 390, 'no sideways scroll at 390 px'));
++ await eventually(async () => assert.ok(await p.evaluate(`${C}.querySelector('.ovc-table').scrollWidth > ${C}.querySelector('.ovc-table').clientWidth`), 'the table scrolls inside'));
if (process.env.OV_SHOT) {
const { data } = await p.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
fs.writeFileSync(process.env.OV_SHOT, Buffer.from(data, 'base64'));
ddiff --git a/test/ov-forms-chrome.test.js b/test/ov-forms-chrome.test.js
iindex 7c66944..d4df13d 100644
---- a/test/ov-forms-chrome.test.js
++++ b/test/ov-forms-chrome.test.js
@@@ -4,6 +4,7 @@
// is followed, the keyboard works, options say their state, and data-ov-native keeps the browser's own. Skipped
// without Chrome.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -63,68 +64,68 @@ const key = (k, target = 'document.activeElement') => `(${target}).dispatchEvent
await p.goto(`${base}/`, { maxMs: 5000 });
await wait(250);
const btn = "document.querySelector('#agent + .ov-sel')";
-- assert.strictEqual(await p.evaluate("document.getElementById('agent').classList.contains('ov-sel-native')"), true, 'the select is enhanced');
-- assert.strictEqual(await p.evaluate(`${btn}.textContent.trim()`), 'Choose…');
-- assert.strictEqual(await p.evaluate("!!document.querySelector('#raw + .ov-sel')"), false, 'data-ov-native keeps the browser\'s own');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('agent').classList.contains('ov-sel-native')"), true, 'the select is enhanced'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${btn}.textContent.trim()`), 'Choose…'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('#raw + .ov-sel')"), false, 'data-ov-native keeps the browser\'s own'));
assert.deepStrictEqual(await p.evaluate("(() => { const b = document.querySelector('#bare + .ov-sel'); const cs = getComputedStyle(b); return [b.classList.contains('is-bare'), cs.borderTopWidth, cs.backgroundColor, getComputedStyle(b.querySelector('.ov-sel-chev')).display]; })()"),
[true, '0px', 'rgba(0, 0, 0, 0)', 'none'], 'a bare select keeps its chip\'s look: no box, no chevron');
-- assert.strictEqual(await p.evaluate(`${btn}.getAttribute('aria-labelledby').split(' ').some((id) => document.getElementById(id) && document.getElementById(id).tagName === 'LABEL')`), true, 'the button is named by the select\'s label');
-- assert.strictEqual(await p.evaluate(`${btn}.getBoundingClientRect().width === document.getElementById('f').getBoundingClientRect().width`), true, 'a full-width select gives a full-width button');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${btn}.getAttribute('aria-labelledby').split(' ').some((id) => document.getElementById(id) && document.getElementById(id).tagName === 'LABEL')`), true, 'the button is named by the select\'s label'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${btn}.getBoundingClientRect().width === document.getElementById('f').getBoundingClientRect().width`), true, 'a full-width select gives a full-width button'));
await p.evaluate(`${btn}.click()`);
await wait(80);
-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop.is-open')"), true, 'a click opens the menu');
-- assert.strictEqual(await p.evaluate("document.querySelectorAll('.ov-sel-pop [role=option]').length"), 4);
-- assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop [role=option][aria-disabled=true] a.ov-sel-link').getAttribute('href')"), '/setup/opencode', 'a choice that is not ready links to its setup');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop .ov-sel-desc').textContent"), 'Your own Claude login, on your machine');
-- assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop .ov-sel-badge.is-warn').textContent"), '91% used');
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop.is-open')"), true, 'a click opens the menu'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ov-sel-pop [role=option]').length"), 4));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop [role=option][aria-disabled=true] a.ov-sel-link').getAttribute('href')"), '/setup/opencode', 'a choice that is not ready links to its setup'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop .ov-sel-desc').textContent"), 'Your own Claude login, on your machine'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ov-sel-pop .ov-sel-badge.is-warn').textContent"), '91% used'));
await p.evaluate(key('ArrowDown'));
await p.evaluate(key('ArrowDown'));
await p.evaluate(key('ArrowDown')); // past the end: OpenCode is disabled, Codex stays the last
await p.evaluate(key('Enter'));
await wait(60);
-- assert.strictEqual(await p.evaluate("document.getElementById('agent').value"), 'codex', 'the keyboard chooses, skipping a disabled choice');
-- assert.strictEqual(await p.evaluate('window.__changes'), 1, 'and the select says change, as a person\'s choice would');
-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), false, 'and the menu closes');
-- assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'with the focus back on the button');
-- assert.match(await p.evaluate(`${btn}.textContent`), /Codex.*91% used/, 'the button shows the choice and its badge');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('agent').value"), 'codex', 'the keyboard chooses, skipping a disabled choice'));
++ await eventually(async () => assert.strictEqual(await p.evaluate('window.__changes'), 1, 'and the select says change, as a person\'s choice would'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), false, 'and the menu closes'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'with the focus back on the button'));
++ await eventually(async () => assert.match(await p.evaluate(`${btn}.textContent`), /Codex.*91% used/, 'the button shows the choice and its badge'));
await p.evaluate("document.getElementById('agent').value = 'claude-code'");
-- assert.match(await p.evaluate(`${btn}.textContent`), /Claude Code/, 'a script that sets the value is followed');
++ await eventually(async () => assert.match(await p.evaluate(`${btn}.textContent`), /Claude Code/, 'a script that sets the value is followed'));
// A long list has a search box, and groups.
await p.evaluate("document.querySelector('#long + .ov-sel').click()");
await wait(80);
-- assert.strictEqual(await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-group').length"), 2);
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-group').length"), 2));
await p.evaluate("(() => { const i = document.querySelector('.ov-sel-pop .ov-sel-search input'); i.value = 'tw'; i.dispatchEvent(new Event('input', { bubbles: true })); })()");
-- assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ov-sel-pop .ov-sel-label')].map((x) => x.textContent)"), ['two', 'twelve'], 'typing narrows the list');
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ov-sel-pop .ov-sel-label')].map((x) => x.textContent)"), ['two', 'twelve'], 'typing narrows the list'));
await p.evaluate(key('Enter', "document.querySelector('.ov-sel-pop .ov-sel-search input')"));
await wait(60);
-- assert.strictEqual(await p.evaluate("document.getElementById('long').value"), 'two');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('long').value"), 'two'));
// A multiple select keeps its menu open and counts.
await p.evaluate("document.querySelector('#many + .ov-sel').click()");
await wait(80);
await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[0].click()");
await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[2].click()");
-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), true, 'the menu stays open while choosing several');
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), true, 'the menu stays open while choosing several'));
await p.evaluate(key('Escape', "document.querySelector('.ov-sel-pop')"));
await wait(30);
-- assert.strictEqual(await p.evaluate("document.querySelector('#many + .ov-sel').textContent.trim()"), 'a, c');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('#many + .ov-sel').textContent.trim()"), 'a, c'));
// A click elsewhere closes the menu; the form posts what was chosen.
await p.evaluate("document.querySelector('#agent + .ov-sel').click()");
await wait(60);
await p.evaluate("document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))");
-- assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), false, 'a click elsewhere closes it');
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ov-sel-pop')"), false, 'a click elsewhere closes it'));
await p.evaluate("document.getElementById('send').click()");
-- assert.deepStrictEqual(await p.evaluate('window.__sent'), { agent: 'claude-code', many: ['a', 'c'], long: 'two' }, 'the form posts the hidden selects\' values');
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__sent'), { agent: 'claude-code', many: ['a', 'c'], long: 'two' }, 'the form posts the hidden selects\' values'));
// A required select left empty: the button says so, not a bubble on a hidden control.
await p.evaluate("document.getElementById('agent').value = ''");
await p.evaluate("document.getElementById('f').requestSubmit()");
-- assert.strictEqual(await p.evaluate(`${btn}.classList.contains('is-invalid')`), true, 'an empty required select marks its button');
-- assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'and takes the focus');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${btn}.classList.contains('is-invalid')`), true, 'an empty required select marks its button'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'and takes the focus'));
// A select inside a modal dialog opens over it (the top layer), and can be chosen from.
await p.evaluate("document.getElementById('dlg').showModal()");
@@@ -135,15 +136,15 @@ const key = (k, target = 'document.activeElement') => `(${target}).dispatchEvent
assert.strictEqual(seen, true, `the list is seen over a modal dialog: ${seen}`);
await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[1].click()");
await wait(60);
-- assert.strictEqual(await p.evaluate("document.getElementById('indlg').value"), 'two', 'and a choice in it lands');
-- assert.strictEqual(await p.evaluate("document.getElementById('dlg').open"), true, 'without closing the dialog');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('indlg').value"), 'two', 'and a choice in it lands'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('dlg').open"), true, 'without closing the dialog'));
await p.evaluate("document.getElementById('dlg').close()");
// Markup added later is enhanced too.
await p.evaluate("document.body.insertAdjacentHTML('beforeend', '<select id=\"late\"><option>x</option><option>y</option></select>')");
await wait(200);
-- assert.strictEqual(await p.evaluate("!!document.querySelector('#late + .ov-sel')"), true, 'a select added later is enhanced');
-- assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('sw')).appearance"), 'none', 'a checkbox with role=switch is a switch');
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('#late + .ov-sel')"), true, 'a select added later is enhanced'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('sw')).appearance"), 'none', 'a checkbox with role=switch is a switch'));
await p.close();
// ── A phone: the sheet ──
@@@ -152,17 +153,17 @@ const key = (k, target = 'document.activeElement') => `(${target}).dispatchEvent
await wait(250);
await phone.evaluate("document.querySelector('#agent + .ov-sel').click()");
await wait(320);
-- assert.strictEqual(await phone.evaluate("!!document.querySelector('.ov-sel-sheet.is-open') && !!document.querySelector('.ov-sel-scrim.is-open')"), true, 'a phone gets a sheet over a scrim');
-- assert.strictEqual(await phone.evaluate("document.querySelector('.ov-sel-sheet-title').textContent"), 'Agent', 'titled with the select\'s label');
-- assert.strictEqual(await phone.evaluate("document.documentElement.classList.contains('ov-sel-locked')"), true, 'the page under it does not scroll');
-- assert.strictEqual(await phone.evaluate('document.body.style.position'), 'fixed', 'the shared helper locks the page in place');
-- assert.ok(await phone.evaluate("document.querySelector('.ov-sel-sheet .ov-sel-opt').getBoundingClientRect().height") >= 44, 'rows are a finger tall');
++ await eventually(async () => assert.strictEqual(await phone.evaluate("!!document.querySelector('.ov-sel-sheet.is-open') && !!document.querySelector('.ov-sel-scrim.is-open')"), true, 'a phone gets a sheet over a scrim'));
++ await eventually(async () => assert.strictEqual(await phone.evaluate("document.querySelector('.ov-sel-sheet-title').textContent"), 'Agent', 'titled with the select\'s label'));
++ await eventually(async () => assert.strictEqual(await phone.evaluate("document.documentElement.classList.contains('ov-sel-locked')"), true, 'the page under it does not scroll'));
++ await eventually(async () => assert.strictEqual(await phone.evaluate('document.body.style.position'), 'fixed', 'the shared helper locks the page in place'));
++ await eventually(async () => assert.ok(await phone.evaluate("document.querySelector('.ov-sel-sheet .ov-sel-opt').getBoundingClientRect().height") >= 44, 'rows are a finger tall'));
await phone.evaluate("document.querySelectorAll('.ov-sel-sheet .ov-sel-opt')[1].click()");
await wait(320);
-- assert.strictEqual(await phone.evaluate("document.getElementById('agent').value"), 'claude-code');
-- assert.strictEqual(await phone.evaluate("!!document.querySelector('.ov-sel-sheet') || document.documentElement.classList.contains('ov-sel-locked')"), false, 'choosing closes the sheet and frees the page');
-- assert.strictEqual(await phone.evaluate('document.body.style.position'), '', 'closing restores the page');
-- assert.strictEqual(await phone.evaluate('document.documentElement.scrollWidth'), 390, 'nothing is wider than the phone');
++ await eventually(async () => assert.strictEqual(await phone.evaluate("document.getElementById('agent').value"), 'claude-code'));
++ await eventually(async () => assert.strictEqual(await phone.evaluate("!!document.querySelector('.ov-sel-sheet') || document.documentElement.classList.contains('ov-sel-locked')"), false, 'choosing closes the sheet and frees the page'));
++ await eventually(async () => assert.strictEqual(await phone.evaluate('document.body.style.position'), '', 'closing restores the page'));
++ await eventually(async () => assert.strictEqual(await phone.evaluate('document.documentElement.scrollWidth'), 390, 'nothing is wider than the phone'));
await phone.close();
console.log('ov-forms-chrome: the menu, the sheet, the keyboard, search, several choices, the form\'s values and native opt-outs ok');
} finally {
ddiff --git a/test/ov-locker-chrome.test.js b/test/ov-locker-chrome.test.js
iindex c06711d..5f0517d 100644
---- a/test/ov-locker-chrome.test.js
++++ b/test/ov-locker-chrome.test.js
@@@ -5,6 +5,7 @@
// locker; making a locker refuses a weak or mismatched passphrase, shows 24 words once and checks two of them; and,
// the point of all of it, the passphrase and the recovery words never leave the page, and nothing is stored.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -84,51 +85,51 @@ const server = http.createServer((req, res) => {
// A password lock: only its password opens it; the card says so plainly.
await p.evaluate(openItem('password:hunter22:the notes'));
await wait(150);
-- assert.match(await p.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /is locked/);
++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /is locked/));
await p.evaluate(type('item-pass', 'wrong')); await p.evaluate(submit('item-open')); await wait(120);
-- assert.match(await p.evaluate("document.querySelector('.ovl-card .ovl-msg').textContent"), /does not open it/);
-- assert.deepStrictEqual(await p.evaluate('window.__opened'), []);
++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('.ovl-card .ovl-msg').textContent"), /does not open it/));
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__opened'), []));
await p.evaluate(type('item-pass', 'hunter22')); await p.evaluate(submit('item-open')); await wait(120);
-- assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes'], 'the right password opens it');
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes'], 'the right password opens it'));
// A link lock without its key, then with it.
await p.evaluate(openItem('link::linked'));
await wait(120);
-- assert.match(await p.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /missing its key/);
++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /missing its key/));
const key = 'A'.repeat(43);
await p.evaluate(openItem('link::linked', `, link: 'https://x.test/p/1#k=${key}'`));
await wait(120);
-- assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes', 'linked'], 'the key after #k= opens it by itself');
-- assert.strictEqual(await p.evaluate("OVLocker.keyFromLink('https://x.test/#k=short')"), null, 'a key that is not 32 bytes is no key');
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes', 'linked'], 'the key after #k= opens it by itself'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("OVLocker.keyFromLink('https://x.test/#k=short')"), null, 'a key that is not 32 bytes is no key'));
// A people lock: unlock the locker (wrong passphrase first), then it opens.
await p.evaluate(openItem('people::for you'));
await wait(120);
await p.evaluate(submit('item-unlock')); await wait(300);
await p.evaluate(type('pass', 'nope')); await p.evaluate(submit('unlock')); await wait(150);
-- assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /does not open your locker/);
++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /does not open your locker/));
await p.evaluate(type('pass', PASS)); await p.evaluate(submit('unlock')); await wait(400);
-- assert.strictEqual(await p.evaluate('OVLocker.state().unlocked'), true);
-- assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes', 'linked', 'for you'], 'unlocked, it opens');
++ await eventually(async () => assert.strictEqual(await p.evaluate('OVLocker.state().unlocked'), true));
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__opened'), ['the notes', 'linked', 'for you'], 'unlocked, it opens'));
await p.evaluate('OVLocker.forget()');
-- assert.strictEqual(await p.evaluate('OVLocker.state().unlocked'), false, 'forget() drops the keys');
++ await eventually(async () => assert.strictEqual(await p.evaluate('OVLocker.state().unlocked'), false, 'forget() drops the keys'));
// Making a locker: weak, then mismatched, then good; 24 words; two checked; saved locked.
await p.evaluate('(() => { OVLocker.setup(); })()');
await wait(300);
await p.evaluate(type('pass', 'short')); await p.evaluate(type('pass2', 'short')); await p.evaluate(submit('next')); await wait(80);
-- assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /longer/, 'a weak passphrase is refused');
++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /longer/, 'a weak passphrase is refused'));
await p.evaluate(type('pass', 'violet lantern river cobalt')); await p.evaluate(type('pass2', 'violet lantern river cobalT')); await p.evaluate(submit('next')); await wait(80);
-- assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /do not match/);
++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /do not match/));
await p.evaluate(type('pass2', 'violet lantern river cobalt')); await p.evaluate(submit('next')); await wait(300);
const words = await p.evaluate("[...document.querySelectorAll('.ovl-words li')].map((li) => li.textContent)");
assert.strictEqual(words.length, 24, 'the kit is 24 words');
const labels = await p.evaluate("[...document.querySelectorAll('dialog .ovl-pair .ovl-label')].map((l) => Number(l.textContent.replace(/\\D/g, '')))");
await p.evaluate(type('w1', 'wrong')); await p.evaluate(type('w2', 'words')); await p.evaluate(submit('make')); await wait(100);
-- assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /not the words/);
++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /not the words/));
assert.ok(!sent.some((s) => s.startsWith('PUT')), 'nothing is saved before the kit is checked');
await p.evaluate(type('w1', words[labels[0] - 1])); await p.evaluate(type('w2', words[labels[1] - 1])); await p.evaluate(submit('make')); await wait(400);
-- assert.strictEqual(await p.evaluate(`${q('fingerprint')}.textContent`), 'cedar ember lumen onyx fable maple gecko iris', 'the fingerprint, eight words');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('fingerprint')}.textContent`), 'cedar ember lumen onyx fable maple gecko iris', 'the fingerprint, eight words'));
const put = sent.find((s) => s.startsWith('PUT /api/v1/me/keys'));
assert.ok(put, 'the locked copies are saved');
assert.ok(!put.includes('violet') && !put.includes('lantern'), 'the passphrase never leaves the page');
@@@ -138,7 +139,7 @@ const server = http.createServer((req, res) => {
assert.ok(!put.includes('"d":"private"') && !put.includes('private'), 'nor the private keys in the clear');
assert.match(put, /"backups":\{"passphrase":\{"format":"locker.envelope.v1"/, 'what is saved is two Locker envelopes');
assert.match(put, /"self_signature":"sig"/, 'with the keys\u2019 self-signature (the SDK\u2019s fingerprint_signature)');
-- assert.strictEqual(await p.evaluate('localStorage.length + sessionStorage.length'), 0, 'nothing is stored in the browser');
++ await eventually(async () => assert.strictEqual(await p.evaluate('localStorage.length + sessionStorage.length'), 0, 'nothing is stored in the browser'));
// Lost both: a plain warning, a checkbox that gates the button, then a new locker saved with reset: true.
await p.evaluate('OVLocker.forget()');
@@@ -146,8 +147,8 @@ const server = http.createServer((req, res) => {
await p.evaluate('(() => { OVLocker.unlock(); })()'); await wait(300);
await p.evaluate(submit('other')); await wait(80);
await p.evaluate(submit('lost')); await wait(80);
-- assert.match(await p.evaluate("document.querySelector('dialog .ovl-body').textContent"), /cannot be opened again/, 'it says what is lost');
-- assert.strictEqual(await p.evaluate(`${q('lost-go')}.disabled`), true, 'nothing starts until the person says they understand');
++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog .ovl-body').textContent"), /cannot be opened again/, 'it says what is lost'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('lost-go')}.disabled`), true, 'nothing starts until the person says they understand'));
await p.evaluate(`${q('lost-ok')}.click()`); await p.evaluate(submit('lost-go')); await wait(400);
await p.evaluate(type('pass', 'amber window tide falcon')); await p.evaluate(type('pass2', 'amber window tide falcon')); await p.evaluate(submit('next')); await wait(300);
const w2 = await p.evaluate("[...document.querySelectorAll('.ovl-words li')].map((li) => li.textContent)");
@@@ -173,7 +174,7 @@ const server = http.createServer((req, res) => {
await wait(300);
authError = { error: 'locker.proof_required' };
await p.evaluate(type('pass', PASS)); await p.evaluate(submit('unlock')); await wait(150);
-- assert.strictEqual(new URL(await p.evaluate("document.querySelector('dialog .ovl-msg a').href")).searchParams.get('reauth'), '1');
++ await eventually(async () => assert.strictEqual(new URL(await p.evaluate("document.querySelector('dialog .ovl-msg a').href")).searchParams.get('reauth'), '1'));
authError = null;
// A phone: the kit fits, no sideways scroll.
@@@ -182,8 +183,8 @@ const server = http.createServer((req, res) => {
await phone.evaluate('(() => { OVLocker.setup(); })()');
await wait(300);
await phone.evaluate(type('pass', 'violet lantern river cobalt')); await phone.evaluate(type('pass2', 'violet lantern river cobalt')); await phone.evaluate(submit('next')); await wait(300);
-- assert.strictEqual(await phone.evaluate('document.documentElement.scrollWidth'), 390);
-- assert.ok(await phone.evaluate("(() => { const d = document.querySelector('dialog').getBoundingClientRect(); return [...document.querySelectorAll('dialog input')].every((i) => i.getBoundingClientRect().right <= d.right + 0.5); })()"), 'every field fits inside the sheet');
++ await eventually(async () => assert.strictEqual(await phone.evaluate('document.documentElement.scrollWidth'), 390));
++ await eventually(async () => assert.ok(await phone.evaluate("(() => { const d = document.querySelector('dialog').getBoundingClientRect(); return [...document.querySelectorAll('dialog input')].every((i) => i.getBoundingClientRect().right <= d.right + 0.5); })()"), 'every field fits inside the sheet'));
await phone.close();
console.log('ov-locker-chrome: password, link and people locks; making and unlocking a locker; nothing secret leaves the page or is stored; the phone ok');
} finally {
ddiff --git a/test/ov-locker-e2e-chrome.test.js b/test/ov-locker-e2e-chrome.test.js
iindex d5c002f..bd6b284 100644
---- a/test/ov-locker-e2e-chrome.test.js
++++ b/test/ov-locker-e2e-chrome.test.js
@@@ -6,6 +6,7 @@
// an item locked in Node for that person's public key opens in the browser; a stranger's locker cannot open it;
// a password-locked item opens with its password only; the recovery words open the locker too.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -93,16 +94,16 @@ const server = http.createServer((req, res) => {
await b.evaluate(type('pass', PASS)); await b.evaluate(press('unlock'));
await wait(5000);
if (process.env.LOCKER_E2E_DEBUG) console.log('DEBUG', await b.evaluate("JSON.stringify({ card: (document.querySelector('.ovl-card') || {}).textContent, msg: (document.querySelector('dialog .ovl-msg') || {}).textContent, state: OVLocker.state() })"));
-- assert.deepStrictEqual(await b.evaluate('window.__opened'), ['for you only'], 'a new device unlocks with the passphrase and opens what was locked for this person');
++ await eventually(async () => assert.deepStrictEqual(await b.evaluate('window.__opened'), ['for you only'], 'a new device unlocks with the passphrase and opens what was locked for this person'));
await b.evaluate(`(() => { OVLocker.open(document.getElementById('item'), { url: '/item/theirs' }); })()`);
await wait(800);
-- assert.match(await b.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /not locked for you/, 'someone else’s item says so');
++ await eventually(async () => assert.match(await b.evaluate("document.querySelector('.ovl-cardtitle').textContent"), /not locked for you/, 'someone else’s item says so'));
await b.evaluate(`(() => { OVLocker.open(document.getElementById('item'), { url: '/item/pw', onOpen: (x) => window.__opened.push(new TextDecoder().decode(x)) }); })()`);
await wait(300);
await b.evaluate(type('item-pass', 'wrong one')); await b.evaluate(press('item-open')); await wait(3500);
-- assert.match(await b.evaluate("document.querySelector('.ovl-card .ovl-msg').textContent"), /does not open it/);
++ await eventually(async () => assert.match(await b.evaluate("document.querySelector('.ovl-card .ovl-msg').textContent"), /does not open it/));
await b.evaluate(type('item-pass', 'hunter22 hunter22')); await b.evaluate(press('item-open')); await wait(3500);
-- assert.deepStrictEqual(await b.evaluate('window.__opened'), ['for you only', 'with a password'], 'the password item opens with its password');
++ await eventually(async () => assert.deepStrictEqual(await b.evaluate('window.__opened'), ['for you only', 'with a password'], 'the password item opens with its password'));
// Device three: the recovery words open the locker too.
const c = await h.openPage(browser, await browser.newContext(), { width: 1280 });
@@@ -111,7 +112,7 @@ const server = http.createServer((req, res) => {
await c.evaluate(press('other')); await wait(80);
await c.evaluate(type('words', words.join(' ').toUpperCase())); await c.evaluate(press('unlock'));
await wait(5000);
-- assert.strictEqual(await c.evaluate('OVLocker.state().fingerprint'), fp, 'the recovery words open the same locker');
++ await eventually(async () => assert.strictEqual(await c.evaluate('OVLocker.state().fingerprint'), fp, 'the recovery words open the same locker'));
// A PRF-capable authenticator is stubbed at the browser boundary; sealing and opening remain real WebCrypto.
// Hardware availability varies, so the unsupported case must leave setup usable too.
const d = await h.openPage(browser, await browser.newContext(), { width: 390 });
@@@ -130,7 +131,7 @@ const server = http.createServer((req, res) => {
await wait(300); await d.evaluate(press('setup-passkey')); await wait(1500);
const recovery = await d.evaluate("[...document.querySelectorAll('.ovl-words li')].map(x => x.textContent)");
assert.strictEqual(recovery.length, 24);
-- assert.strictEqual(await d.evaluate("!!document.querySelector('[data-k=print]')"), true);
++ await eventually(async () => assert.strictEqual(await d.evaluate("!!document.querySelector('[data-k=print]')"), true));
const ask = await d.evaluate("[...document.querySelectorAll('dialog .ovl-pair .ovl-label')].map(x => Number(x.textContent.replace(/\\D/g, '')))");
await d.evaluate(type('w1', recovery[ask[0] - 1])); await d.evaluate(type('w2', recovery[ask[1] - 1])); await d.evaluate(press('make')); await wait(9000);
assert.ok(stored.backups.passkey && stored.backups.recovery && !stored.backups.passphrase);
@@@ -140,18 +141,18 @@ const server = http.createServer((req, res) => {
await d.evaluate(press('done'));
await d.evaluate('(() => { OVLocker.forget(); OVLocker.unlock(); })()'); await wait(300);
await d.evaluate(press('unlock-passkey')); await wait(5000);
-- assert.strictEqual(await d.evaluate('OVLocker.state().fingerprint'), pkFp, 'PRF opens the same SDK-protected keys');
++ await eventually(async () => assert.strictEqual(await d.evaluate('OVLocker.state().fingerprint'), pkFp, 'PRF opens the same SDK-protected keys'));
items.stream = (await locker.seal('streamed on device', { lock: 'people', keys: [{ subject: { type: 'user', id: 'usr_x' }, publicKey: stored.ecdh_public }] })).bytes;
-- assert.strictEqual(await d.evaluate("(async () => new Response(await OVLocker.openStream((await fetch('/item/stream')).body)).text())()"), 'streamed on device');
++ await eventually(async () => assert.strictEqual(await d.evaluate("(async () => new Response(await OVLocker.openStream((await fetch('/item/stream')).body)).text())()"), 'streamed on device'));
await d.evaluate('(() => { OVLocker.forget(); OVLocker.unlock(); })()'); await wait(300);
await d.evaluate(type('pass', PASS)); await d.evaluate(press('unlock')); await wait(300);
-- assert.match(await d.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /recovery kit/);
-- assert.strictEqual(await d.evaluate("!!document.querySelector('[data-k=make-instead]')"), false, 'an existing passkey locker is never described as missing');
++ await eventually(async () => assert.match(await d.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /recovery kit/));
++ await eventually(async () => assert.strictEqual(await d.evaluate("!!document.querySelector('[data-k=make-instead]')"), false, 'an existing passkey locker is never described as missing'));
await d.evaluate("document.querySelector('dialog button[aria-label=Close]').click()");
await d.evaluate(`(() => { OVLocker.forget(); navigator.credentials.create = async () => ({ getClientExtensionResults: () => ({}) }); OVLocker.setup(); })()`);
await wait(300); await d.evaluate(press('setup-passkey')); await wait(300);
-- assert.match(await d.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /does not support Locker/);
-- assert.strictEqual(await d.evaluate("document.querySelector('[data-k=setup-passkey]').disabled"), false);
++ await eventually(async () => assert.match(await d.evaluate("document.querySelector('dialog .ovl-msg').textContent"), /does not support Locker/));
++ await eventually(async () => assert.strictEqual(await d.evaluate("document.querySelector('[data-k=setup-passkey]').disabled"), false));
console.log('ov-locker-e2e-chrome: real crypto: make a locker, unlock it on a new device by passphrase and by recovery words, open what was locked for you, refuse what was not');
} finally {
await browser.close();
ddiff --git a/test/ov-share-chrome.test.js b/test/ov-share-chrome.test.js
iindex 85388a1..34515c1 100644
---- a/test/ov-share-chrome.test.js
++++ b/test/ov-share-chrome.test.js
@@@ -6,6 +6,7 @@
// and the recipient's banner and the not-on-the-list gate call the right routes. Names and titles are text, never
// markup. A phone gets a sheet no wider than the phone. Skipped without Chrome.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -99,22 +100,22 @@ const server = http.createServer((req, res) => {
await p.evaluate("document.getElementById('sh').click()");
await wait(300);
-- assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share').open"), true, 'a [data-ov-share-sheet] button opens the sheet as a modal dialog');
-- assert.strictEqual(await p.evaluate("document.getElementById('ov-share-title').textContent"), 'Share “<b>notes</b>”', 'the title is text, never markup');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share').open"), true, 'a [data-ov-share-sheet] button opens the sheet as a modal dialog'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ov-share-title').textContent"), 'Share “<b>notes</b>”', 'the title is text, never markup'));
// A site's own h2 rule (Wiki: h2 { border-bottom; padding-bottom; margin }) never reaches the sheet's title.
await p.evaluate("(() => { const st = document.createElement('style'); st.textContent = 'h2 { border-bottom: 3px solid red; padding-bottom: 9px; text-transform: uppercase; }'; document.head.appendChild(st); })()");
-- assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).borderBottomWidth"), '0px', 'a page h2 border does not reach the sheet title');
-- assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).paddingBottom"), '0px', 'nor its padding');
-- assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).textTransform"), 'none', 'nor its text-transform');
-- assert.strictEqual(await p.evaluate(`${q('aud-private')}.checked`), true, 'the service\'s audience is chosen');
-- assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share [data-k=\"done\"]').textContent"), 'Done', 'nothing changed: Done, not Save');
++ await eventually(async () => assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).borderBottomWidth"), '0px', 'a page h2 border does not reach the sheet title'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).paddingBottom"), '0px', 'nor its padding'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("getComputedStyle(document.getElementById('ov-share-title')).textTransform"), 'none', 'nor its text-transform'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('aud-private')}.checked`), true, 'the service\'s audience is chosen'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share [data-k=\"done\"]').textContent"), 'Done', 'nothing changed: Done, not Save'));
// A name nobody has: said plainly, nothing added, nothing saved.
await p.evaluate(typeIn('add', '@nobody'));
await p.evaluate(`${q('add-btn')}.click()`);
await wait(150);
-- assert.match(await p.evaluate("document.getElementById('ov-share-add-msg').textContent"), /No OpenVibe account is called @nobody/);
-- assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-list').children.length"), 1, 'only the owner');
++ await eventually(async () => assert.match(await p.evaluate("document.getElementById('ov-share-add-msg').textContent"), /No OpenVibe account is called @nobody/));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-list').children.length"), 1, 'only the owner'));
// A name that is not a name is refused before anyone is asked.
await p.evaluate(typeIn('add', 'a b!'));
await p.evaluate(`${q('add-btn')}.click()`);
@@@ -124,23 +125,23 @@ const server = http.createServer((req, res) => {
await p.evaluate(typeIn('add', 'slow'));
await p.evaluate(`${q('add-btn')}.click()`);
await wait(150);
-- assert.match(await p.evaluate("document.getElementById('ov-share-add-msg').textContent"), /Too many name checks/);
++ await eventually(async () => assert.match(await p.evaluate("document.getElementById('ov-share-add-msg').textContent"), /Too many name checks/));
// A real name: added, Only me becomes Specific people, the display name stays text.
await p.evaluate(typeIn('add', '@Quinn'));
await p.evaluate(`${q('add-btn')}.click()`);
await wait(150);
-- assert.strictEqual(await p.evaluate(`${q('aud-people')}.checked`), true, 'adding someone to Only me switches to Specific people');
-- assert.strictEqual(await p.evaluate('window.__xss'), undefined, 'a display name is never markup');
-- assert.strictEqual(await p.evaluate("[...document.querySelectorAll('dialog.ov-share .ov-share-display')].map((n) => n.textContent)[1]"), PEOPLE.quinn.display_name);
-- assert.strictEqual(await p.evaluate(`${q('done')}.textContent`), 'Save', 'a change asks to be saved');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('aud-people')}.checked`), true, 'adding someone to Only me switches to Specific people'));
++ await eventually(async () => assert.strictEqual(await p.evaluate('window.__xss'), undefined, 'a display name is never markup'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("[...document.querySelectorAll('dialog.ov-share .ov-share-display')].map((n) => n.textContent)[1]"), PEOPLE.quinn.display_name));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('done')}.textContent`), 'Save', 'a change asks to be saved'));
assert.ok(!calls.some((c) => c.startsWith('PUT')), 'nothing is saved before Save');
// Leaving with unsaved changes asks first; Keep editing keeps them.
await p.evaluate(`${q('close')}.click()`);
await wait(80);
-- assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share').open"), true, 'unsaved changes keep the sheet open');
-- assert.ok(await p.evaluate(`!!${q('discard')}`), 'and ask: Discard or Keep editing');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share').open"), true, 'unsaved changes keep the sheet open'));
++ await eventually(async () => assert.ok(await p.evaluate(`!!${q('discard')}`), 'and ask: Discard or Keep editing'));
await p.evaluate(`${q('keep-editing')}.click()`);
await wait(50);
@@@ -152,62 +153,62 @@ const server = http.createServer((req, res) => {
assert.deepStrictEqual(S.lastPut, { audience: 'people', members: [{ role: 'viewer', subject: PEOPLE.quinn.subject }, { role: 'viewer', subject: PEOPLE.maticus.subject }], notify: true },
'Save sends the audience and the members by subject (never by display name), with no end date when there is none');
assert.strictEqual(S.lastIfMatch, '"7"', 'and only over the version it was shown (If-Match)');
-- assert.strictEqual(await p.evaluate('window.__changes'), 1, 'and tells the page (ov-share:change)');
-- assert.strictEqual(await p.evaluate(`${q('done')}.textContent`), 'Done');
++ await eventually(async () => assert.strictEqual(await p.evaluate('window.__changes'), 1, 'and tells the page (ov-share:change)'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('done')}.textContent`), 'Done'));
// Transfer: to a person on the list, removing my access by default, bound to their subject.
await p.evaluate(`${q('give')}.click()`);
await wait(80);
-- assert.strictEqual(await p.evaluate(`${q('keep-false')}.checked`), true, '"Remove my access" is the default');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('keep-false')}.checked`), true, '"Remove my access" is the default'));
await p.evaluate(`${q('give-maticus')}.click()`);
await wait(50);
await p.evaluate(`${q('give-send')}.click()`);
await wait(200);
assert.deepStrictEqual(S.lastTransfer, { to: { subject: PEOPLE.maticus.subject }, keep_access: false });
-- assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-banner').textContent"), /Waiting for @maticus to accept/);
-- assert.strictEqual(await p.evaluate(`!!${q('give')}`), false, 'one offer at a time');
++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-banner').textContent"), /Waiting for @maticus to accept/));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`!!${q('give')}`), false, 'one offer at a time'));
await p.evaluate(`${q('cancel-give')}.click()`);
await wait(200);
assert.strictEqual(S.transfer, null, 'the owner can cancel');
assert.deepStrictEqual(S.lastCancel, { transfer_id: '00000000-0000-4000-8000-0000000000aa' }, 'naming the offer it cancels');
await p.evaluate(`${q('close')}.click()`);
await wait(320);
-- assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog').length + (document.documentElement.classList.contains('ov-share-locked') ? 1 : 0)"), 0, 'closing removes the sheet and frees the page');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog').length + (document.documentElement.classList.contains('ov-share-locked') ? 1 : 0)"), 0, 'closing removes the sheet and frees the page'));
// A gate in markup starts by itself, once, with its values as plain attributes (a quote or </script> in one is
// just text); signed out, it offers the sign-in link it was given.
-- assert.ok(await p.evaluate("!!document.querySelector('#gate2 .ov-share-gate')"), 'a gate in markup starts when ov-share.js loads');
-- assert.strictEqual(await p.evaluate("document.querySelector('#gate2 a').getAttribute('href')"), '/login?next=%2Fp%2F"</script>', 'its sign-in link is the attribute, as text');
-- assert.strictEqual(await p.evaluate('OVShare.startGates()'), 0, 'and it starts only once');
++ await eventually(async () => assert.ok(await p.evaluate("!!document.querySelector('#gate2 .ov-share-gate')"), 'a gate in markup starts when ov-share.js loads'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('#gate2 a').getAttribute('href')"), '/login?next=%2Fp%2F"</script>', 'its sign-in link is the attribute, as text'));
++ await eventually(async () => assert.strictEqual(await p.evaluate('OVShare.startGates()'), 0, 'and it starts only once'));
// The recipient's banner and the gate.
await p.evaluate("OVShare.offer(document.getElementById('offer'), { kind: 'paste', id: 'abc', transfer_id: '00000000-0000-4000-8000-0000000000aa', from: { username: 'goosely' }, title: 'notes' })");
await p.evaluate("[...document.querySelectorAll('#offer button')].find((b) => b.textContent === 'Accept').click()");
await wait(200);
assert.ok(calls.includes('POST /api/v1/paste/abc/share/transfer/accept'));
assert.deepStrictEqual(S.lastAccept, { transfer_id: '00000000-0000-4000-8000-0000000000aa' }, 'the answer names the offer');
-- assert.match(await p.evaluate("document.getElementById('offer').textContent"), /It is yours now/);
++ await eventually(async () => assert.match(await p.evaluate("document.getElementById('offer').textContent"), /It is yours now/));
await p.evaluate("OVShare.gate(document.getElementById('gate'), { kind: 'paste', id: 'abc', signedInAs: 'quinn' })");
-- assert.ok(!/notes/.test(await p.evaluate("document.getElementById('gate').textContent")), 'the gate never shows the title');
++ await eventually(async () => assert.ok(!/notes/.test(await p.evaluate("document.getElementById('gate').textContent")), 'the gate never shows the title'));
await p.evaluate("document.querySelector('#gate .ov-share-btn').click()");
await wait(200);
assert.ok(calls.includes('POST /api/v1/paste/abc/share/requests'));
-- assert.match(await p.evaluate("document.getElementById('gate').textContent"), /Asked/);
++ await eventually(async () => assert.match(await p.evaluate("document.getElementById('gate').textContent"), /Asked/));
await p.evaluate("OVShare.gate(document.getElementById('gate'), { kind: 'paste', id: 'abc', signInUrl: '/login' })");
-- assert.strictEqual(await p.evaluate("document.querySelector('#gate a').getAttribute('href')"), '/login', 'signed out: sign in');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('#gate a').getAttribute('href')"), '/login', 'signed out: sign in'));
// Send it: for anyone, with no kind or id; the link, Copy, the places, "Start at", embed code.
await p.evaluate("Object.defineProperty(navigator, 'clipboard', { value: { writeText: (t) => { window.__clip = t; return Promise.resolve(); } }, configurable: true })"); // headless Chrome refuses the real one without a grant
await p.evaluate("document.getElementById('send').click()");
await wait(300);
-- assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog.ov-share .ov-share-aud').length"), 0, 'no "who can open it" without kind and id');
-- assert.strictEqual(await p.evaluate(`${q('link')}.value`), 'video.test/v/1', 'the link, shown without https://');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog.ov-share .ov-share-aud').length"), 0, 'no "who can open it" without kind and id'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('link')}.value`), 'video.test/v/1', 'the link, shown without https://'));
await p.evaluate(`${q('copy')}.click()`);
await wait(120);
-- assert.strictEqual(await p.evaluate('window.__clip'), 'https://video.test/v/1', 'Copy copies the whole link');
-- assert.ok(await p.evaluate("window.__sent.includes('copy https://video.test/v/1')"), 'and says so (ov-share:sent)');
-- assert.strictEqual(await p.evaluate(`${q('copy')}.textContent`), 'Copied');
++ await eventually(async () => assert.strictEqual(await p.evaluate('window.__clip'), 'https://video.test/v/1', 'Copy copies the whole link'));
++ await eventually(async () => assert.ok(await p.evaluate("window.__sent.includes('copy https://video.test/v/1')"), 'and says so (ov-share:sent)'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('copy')}.textContent`), 'Copied'));
// No Dock on this page (signed out): no OpenVibe Chat tile.
-- assert.strictEqual(await p.evaluate(`!!${q('to-ovchat')}`), false, 'no OpenVibe Chat without the Dock');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`!!${q('to-ovchat')}`), false, 'no OpenVibe Chat without the Dock'));
// OpenVibe Community is offered to everyone (4.179.0): a link to its new-topic form, the title and the link filled in.
const comm = await p.evaluate(`(() => { const a = ${q('to-ovcommunity')}; return a ? { href: a.getAttribute('href'), text: a.textContent, first: document.querySelector('dialog.ov-share .ov-share-grid .ov-share-tile') === a } : null; })()`);
assert.ok(comm, 'the Community tile');
@@@ -217,20 +218,20 @@ const server = http.createServer((req, res) => {
assert.ok(decodeURIComponent(comm.href.split('&body=')[1]).includes('http'), 'the body carries the link');
const x = await p.evaluate(`${q('to-x')}.getAttribute('href')`);
assert.strictEqual(x, 'https://x.com/intent/post?text=A%20robot%20%26%20a%20wave&url=https%3A%2F%2Fvideo.test%2Fv%2F1', 'X gets the title and the link, encoded');
-- assert.strictEqual(await p.evaluate(`${q('to-x')}.getAttribute('rel')`), 'noopener noreferrer');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('to-x')}.getAttribute('rel')`), 'noopener noreferrer'));
assert.ok(!/utm_|ref=/.test(x), 'no tracking is added to a link');
await p.evaluate(`${q('at-time')}.click()`);
await wait(60);
-- assert.strictEqual(await p.evaluate(`${q('link')}.value`), 'video.test/v/1?t=83', '"Start at 1:23" adds the time');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('link')}.value`), 'video.test/v/1?t=83', '"Start at 1:23" adds the time'));
await p.evaluate(`${q('more')} && ${q('more')}.click()`);
await wait(60);
await p.evaluate(`${q('to-embed')}.click()`);
await wait(60);
-- assert.match(await p.evaluate(`${q('embed-code')}.value`), /^<iframe src="https:\/\/video\.test\/embed\/1\?t=83" [^>]*title="A robot &amp; a wave"/, 'embed code, with the time and an escaped title');
++ await eventually(async () => assert.match(await p.evaluate(`${q('embed-code')}.value`), /^<iframe src="https:\/\/video\.test\/embed\/1\?t=83" [^>]*title="A robot &amp; a wave"/, 'embed code, with the time and an escaped title'));
await p.evaluate(`${q('to-mastodon')}.click()`);
await wait(60);
await p.evaluate(`(() => { const i = ${q('masto')}; i.value = 'not a server'; i.form.requestSubmit(); })()`);
-- assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-panel .ov-share-addmsg').textContent"), /address of your server/, 'Mastodon asks for a real server address');
++ await eventually(async () => assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-panel .ov-share-addmsg').textContent"), /address of your server/, 'Mastodon asks for a real server address'));
await p.evaluate(`${q('close')}.click()`);
await wait(320);
// Signed in (the Dock is on the page): OpenVibe Chat comes first; it closes the sheet and hands the Dock the title
@@@ -238,25 +239,25 @@ const server = http.createServer((req, res) => {
await p.evaluate("window.__docked = []; window.OpenVibeDock = { share: (o) => { window.__docked.push(o); }, isOpen: () => false }");
await p.evaluate("document.getElementById('send').click()");
await wait(300);
-- assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-grid .ov-share-tile').getAttribute('data-k')"), 'to-ovchat', 'OpenVibe Chat is the first place');
-- assert.strictEqual(await p.evaluate(`${q('to-ovchat')}.textContent`), 'OpenVibe Chat');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-grid .ov-share-tile').getAttribute('data-k')"), 'to-ovchat', 'OpenVibe Chat is the first place'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`${q('to-ovchat')}.textContent`), 'OpenVibe Chat'));
await p.evaluate(`${q('to-ovchat')}.click()`);
await wait(320);
-- assert.deepStrictEqual(await p.evaluate('window.__docked'), [{ text: 'A robot & a wave https://video.test/v/1', title: 'A robot & a wave' }], 'the Dock gets the title and the link');
-- assert.strictEqual(await p.evaluate("!!document.querySelector('dialog.ov-share')"), false, 'the sheet closes');
++ await eventually(async () => assert.deepStrictEqual(await p.evaluate('window.__docked'), [{ text: 'A robot & a wave https://video.test/v/1', title: 'A robot & a wave' }], 'the Dock gets the title and the link'));
++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('dialog.ov-share')"), false, 'the sheet closes'));
// A Dock that may not send (a read-only Dock token): no OpenVibe Chat.
await p.evaluate("window.OpenVibeDock = { share: () => {}, isOpen: () => false, canSend: () => false }");
await p.evaluate("document.getElementById('send').click()");
await wait(300);
-- assert.strictEqual(await p.evaluate(`!!${q('to-ovchat')}`), false, 'no OpenVibe Chat when the Dock cannot send');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`!!${q('to-ovchat')}`), false, 'no OpenVibe Chat when the Dock cannot send'));
await p.evaluate(`${q('close')}.click()`);
await wait(320);
await p.evaluate("delete window.OpenVibeDock");
// Somebody else's paste: they can send it; who can open it is not theirs to see, and nothing says it failed.
await p.evaluate("document.getElementById('theirs').click()");
await wait(300);
-- assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog.ov-share .ov-share-aud, dialog.ov-share .ov-share-error').length"), 0, 'a 404 from the share API leaves the send half alone');
-- assert.ok(await p.evaluate(`!!${q('copy')}`));
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog.ov-share .ov-share-aud, dialog.ov-share .ov-share-error').length"), 0, 'a 404 from the share API leaves the send half alone'));
++ await eventually(async () => assert.ok(await p.evaluate(`!!${q('copy')}`)));
await p.evaluate(`${q('close')}.click()`);
await wait(320);
@@@ -272,15 +273,15 @@ const server = http.createServer((req, res) => {
await wait(400);
await phone.evaluate("document.querySelector('dialog.ov-share [data-k=\"device\"]').click()");
await wait(100);
-- assert.deepStrictEqual(await phone.evaluate('window.__shared'), { title: 'A robot & a wave', url: 'https://video.test/v/1' }, 'the phone\'s sheet gets the title and the link');
-- assert.ok(await phone.evaluate("!!document.querySelector('dialog.ov-share [data-k=\"to-sms\"]') || !!document.querySelector('dialog.ov-share [data-k=\"more\"]')"), 'Messages is offered on a phone');
++ await eventually(async () => assert.deepStrictEqual(await phone.evaluate('window.__shared'), { title: 'A robot & a wave', url: 'https://video.test/v/1' }, 'the phone\'s sheet gets the title and the link'));
++ await eventually(async () => assert.ok(await phone.evaluate("!!document.querySelector('dialog.ov-share [data-k=\"to-sms\"]') || !!document.querySelector('dialog.ov-share [data-k=\"more\"]')"), 'Messages is offered on a phone'));
await phone.evaluate("document.querySelector('dialog.ov-share [data-k=\"close\"]').click()");
await wait(320);
await phone.evaluate("document.getElementById('sh').click()");
await wait(500);
const r = await phone.evaluate("(() => { const d = document.querySelector('dialog.ov-share').getBoundingClientRect(); return [Math.round(d.width), Math.round(innerHeight - d.bottom), document.documentElement.scrollWidth]; })()");
assert.deepStrictEqual(r, [390, 0, 390], 'the sheet sits on the bottom edge, the phone\'s width, and nothing is wider');
-- assert.ok(await phone.evaluate("document.querySelector('dialog.ov-share .ov-share-addfield').getBoundingClientRect().height") >= 44, 'the name field is a finger tall');
++ await eventually(async () => assert.ok(await phone.evaluate("document.querySelector('dialog.ov-share .ov-share-addfield').getBoundingClientRect().height") >= 44, 'the name field is a finger tall'));
await phone.close();
console.log('ov-share-chrome: send (copy, X, start at, embed, Mastodon, the phone sheet), not-the-owner, loading, @names (unknown, invalid, rate-limited, found), Only me → people, save by subject, leave-unsaved, transfer and cancel, the offer, the gate, the phone ok');
} finally {
ddiff --git a/test/ov-share-lock-chrome.test.js b/test/ov-share-lock-chrome.test.js
iindex d724121..53db580 100644
---- a/test/ov-share-lock-chrome.test.js
++++ b/test/ov-share-lock-chrome.test.js
@@@ -7,6 +7,7 @@
// removing @maticus seals the content again under a fresh key: the new bytes open for quinn, not for maticus;
// a link-locked paste's link keeps the key after #k=, and says so when the key is missing.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -147,30 +148,30 @@ const server = http.createServer((req, res) => {
// The sheet itself loads no Locker: a fresh tab proves it, and that the first press of the part does.
const fresh = await h.openPage(browser, await browser.newContext(), { width: 390 });
await fresh.goto(`${base}/`, { maxMs: 5000 });
-- assert.strictEqual(await fresh.evaluate('!!window.OVLocker'), false, 'ov-share.js alone loads no Locker');
++ await eventually(async () => assert.strictEqual(await fresh.evaluate('!!window.OVLocker'), false, 'ov-share.js alone loads no Locker'));
await fresh.evaluate("document.getElementById('sh').click()");
await wait(400);
-- assert.match(await fresh.evaluate(text('.ov-share-lock')), /Locked/, 'a fresh tab sees the item is locked');
++ await eventually(async () => assert.match(await fresh.evaluate(text('.ov-share-lock')), /Locked/, 'a fresh tab sees the item is locked'));
await fresh.evaluate(press('lock-open'));
await wait(600);
-- assert.ok(await fresh.evaluate('!!window.OVLocker'), 'pressing Locked loads ov-locker.js');
++ await eventually(async () => assert.ok(await fresh.evaluate('!!window.OVLocker'), 'pressing Locked loads ov-locker.js'));
await fresh.evaluate(press('close'));
// 1. The Locked part says what the lock means, in plain words, and whose keys it is wrapped for.
await page.evaluate("document.getElementById('sh').click()");
await wait(400);
-- assert.match(await page.evaluate(text('.ov-share-lock')), /Locked/, 'the sheet says the item is locked');
-- assert.match(await page.evaluate(text('.ov-share-lockshort')), /can’t read what’s inside/, 'and says it in plain words');
++ await eventually(async () => assert.match(await page.evaluate(text('.ov-share-lock')), /Locked/, 'the sheet says the item is locked'));
++ await eventually(async () => assert.match(await page.evaluate(text('.ov-share-lockshort')), /can’t read what’s inside/, 'and says it in plain words'));
await page.evaluate(press('lock-open'));
await wait(500);
-- assert.match(await page.evaluate(text('.ov-share-lockmeans')), /Only you and the people below can open it/, 'what a people lock means');
-- assert.ok((await page.evaluate(text('.ov-share-fp'))).includes(matPub.fingerprint.split(' · ')[0]), 'the member’s fingerprint is shown');
++ await eventually(async () => assert.match(await page.evaluate(text('.ov-share-lockmeans')), /Only you and the people below can open it/, 'what a people lock means'));
++ await eventually(async () => assert.ok((await page.evaluate(text('.ov-share-fp'))).includes(matPub.fingerprint.split(' · ')[0]), 'the member’s fingerprint is shown'));
// 2. Add @quinn: their public key is wrapped for them here.
await page.evaluate(type('add', 'quinn'));
await page.evaluate(press('add-btn'));
await wait(600);
-- assert.match(await page.evaluate(text('.ov-share-list')), /Quinn/, 'quinn is on the list');
++ await eventually(async () => assert.match(await page.evaluate(text('.ov-share-list')), /Quinn/, 'quinn is on the list'));
await page.evaluate(press('done'));
await wait(2500); // the re-wrap
assert.strictEqual(puts.length, 1, 'one save');
@@@ -189,7 +190,7 @@ const server = http.createServer((req, res) => {
await wait(400);
await page.evaluate(press('rm-user:usr_mat'));
await wait(200);
-- assert.match(await page.evaluate(text('.ov-share-note')), /They had the key before you removed them/, 'removal says they keep what they opened');
++ await eventually(async () => assert.match(await page.evaluate(text('.ov-share-note')), /They had the key before you removed them/, 'removal says they keep what they opened'));
await page.evaluate(press('done'));
await wait(3000);
assert.strictEqual(puts.length, 2, 'the removal saved');
@@@ -222,14 +223,14 @@ const server = http.createServer((req, res) => {
items['/p/locked/raw'] = linkSealed.bytes;
await linkPage.evaluate(`(() => { OVShare.open({ kind: 'paste', id: 'locked', title: 'Locked note' }); })()`);
await wait(500);
-- assert.match(await linkPage.evaluate(`${q('link')}.value`), /#k=[A-Za-z0-9_-]{43}/, 'the copied link keeps the key after #');
++ await eventually(async () => assert.match(await linkPage.evaluate(`${q('link')}.value`), /#k=[A-Za-z0-9_-]{43}/, 'the copied link keeps the key after #'));
await linkPage.evaluate(press('lock-open'));
await wait(400);
await linkPage.evaluate('history.replaceState(null, "", location.pathname)');
await linkPage.evaluate(press('lock-open'));
await linkPage.evaluate(press('lock-open'));
await wait(500);
-- assert.match(await linkPage.evaluate(text('.ov-share-lock')), /missing its key/i, 'a link with no key says so');
++ await eventually(async () => assert.match(await linkPage.evaluate(text('.ov-share-lock')), /missing its key/i, 'a link with no key says so'));
console.log('ov-share-lock-chrome: the Locked part with real crypto: lazy load, fingerprints, add wraps for them, removal re-seals, the link lock keeps its key');
} finally {
ddiff --git a/test/ov-tip-chrome.test.js b/test/ov-tip-chrome.test.js
iindex 341020f..dd58c88 100644
---- a/test/ov-tip-chrome.test.js
++++ b/test/ov-tip-chrome.test.js
@@@ -4,6 +4,7 @@
// shows it at once; a press, Escape or leaving hides it; an opened button shows none; it is hidden from assistive
// technology (the element keeps its own name).
const assert = require('node:assert/strict');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
@@@ -37,7 +38,7 @@ const server = http.createServer((req, res) => {
// A mouse resting on the button at the right edge: the tooltip, after a moment, under it and on the screen.
await move(782, 26);
-- assert.equal(await page.evaluate(ON), false, 'not at once: a pointer passing over shows nothing');
++ await eventually(async () => assert.equal(await page.evaluate(ON), false, 'not at once: a pointer passing over shows nothing'));
assert.ok(await until(ON), 'after a short rest it shows');
await wait(260);
const a = await page.evaluate(READ('a'));
@@@ -67,7 +68,7 @@ const server = http.createServer((req, res) => {
await move(400, 300); await wait(600);
// An opened button shows none.
await move(38, 26); await wait(700);
-- assert.equal(await page.evaluate(ON), false, 'a button whose panel is open shows no tooltip');
++ await eventually(async () => assert.equal(await page.evaluate(ON), false, 'a button whose panel is open shows no tooltip'));
await move(400, 300);
// The keyboard: focus shows it at once; Escape hides it.
await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
ddiff --git a/test/ov-window-chrome.test.js b/test/ov-window-chrome.test.js
iindex bc688ca..06fc2f0 100644
---- a/test/ov-window-chrome.test.js
++++ b/test/ov-window-chrome.test.js
@@@ -5,6 +5,7 @@
// opens again where it was left; on a phone every kind is a sheet that a swipe down closes; the window style is one
// attribute for every window; content given as text is set as text.
const assert = require('node:assert/strict');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
@@@ -42,22 +43,22 @@ window.mk = (o) => { const f = document.createElement('div'); const a = document
assert.ok(v.popX !== '', 'it grows out of its button');
// The focus stays inside: Tab from the last control goes to the first (×), Shift+Tab from the first to the last.
await page.evaluate(`document.getElementById('last').focus()`); await key(page, 'Tab');
-- assert.equal(await page.evaluate(`document.activeElement.className`), 'ovw-x', 'Tab wraps to the first control (×)');
++ await eventually(async () => assert.equal(await page.evaluate(`document.activeElement.className`), 'ovw-x', 'Tab wraps to the first control (×)'));
await key(page, 'Tab', true);
-- assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'Shift+Tab wraps to the last');
++ await eventually(async () => assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'Shift+Tab wraps to the last'));
// A second window on top: Escape closes the top one only.
await page.evaluate(`window.w2 = mk({ title: 'Send', from: document.getElementById('last') })`);
-- assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 2);
++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 2));
await key(page, 'Escape'); await wait(350);
-- assert.equal(await page.evaluate(`document.querySelectorAll('.ovw:not(.is-out)').length`), 1, 'Escape closed the top window');
-- assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'the focus went back to what opened it');
++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelectorAll('.ovw:not(.is-out)').length`), 1, 'Escape closed the top window'));
++ await eventually(async () => assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'the focus went back to what opened it'));
await key(page, 'Escape'); await wait(350);
v = await page.evaluate(`({ n: document.querySelectorAll('.ovw').length, scrims: document.querySelectorAll('.ovw-scrim').length, focus: document.activeElement.id, locked: document.documentElement.classList.contains('ovw-locked'), shut: window.shut })`);
assert.deepEqual(v, { n: 0, scrims: 0, focus: 'open', locked: false, shut: ['escape', 'escape'] }, 'both gone, the page free, the focus back on Open');
// A press outside a dialog closes it.
await page.evaluate(`mk({})`); await wait(80);
await page.evaluate(`document.querySelector('.ovw-scrim').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))`); await wait(350);
-- assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a press outside closes a dialog');
++ await eventually(async () => assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a press outside closes a dialog'));
// Text is text.
await page.evaluate(`OVWindow.open({ title: '<b>x</b>', body: '<img src=x onerror=alert(1)>' })`);
v = await page.evaluate(`({ t: document.querySelector('.ovw-t').textContent, imgs: document.querySelectorAll('.ovw img').length })`);
@@@ -75,18 +76,18 @@ window.mk = (o) => { const f = document.createElement('div'); const a = document
await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: hb.x + 200, y: hb.y + 150, button: 'left' });
await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: hb.x + 200, y: hb.y + 150, button: 'left', clickCount: 1 });
const moved = await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`);
-- assert.deepEqual(await page.evaluate(`JSON.parse(localStorage.getItem('ov:window:dock-test'))`), moved, 'where it was left is kept');
++ await eventually(async () => assert.deepEqual(await page.evaluate(`JSON.parse(localStorage.getItem('ov:window:dock-test'))`), moved, 'where it was left is kept'));
await page.evaluate('p1.close()'); await wait(300);
await page.evaluate(`mk({ kind: 'panel', id: 'dock-test', title: 'Dock', from: document.getElementById('pan') })`);
await wait(400);
-- assert.deepEqual(await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`), moved, 'and it opens there again');
++ await eventually(async () => assert.deepEqual(await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`), moved, 'and it opens there again'));
await page.evaluate('OVWindow.closeAll()'); await wait(300);
// The window style: one attribute, every window.
-- assert.equal(await page.evaluate(`OVWindow.setStyle('outline')`), 'outline');
-- assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), 'outline');
-- assert.equal(await page.evaluate(`OVWindow.setStyle('nonsense')`), 'glass', 'an unknown style is the default');
-- assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), null);
++ await eventually(async () => assert.equal(await page.evaluate(`OVWindow.setStyle('outline')`), 'outline'));
++ await eventually(async () => assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), 'outline'));
++ await eventually(async () => assert.equal(await page.evaluate(`OVWindow.setStyle('nonsense')`), 'glass', 'an unknown style is the default'));
++ await eventually(async () => assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), null));
await page.close();
// A phone: every kind is a sheet; a swipe down on its head closes it.
@@@ -101,8 +102,8 @@ window.mk = (o) => { const f = document.createElement('div'); const a = document
await ph.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: sh.x, y: sh.y + 260, button: 'left' });
await ph.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sh.x, y: sh.y + 260, button: 'left', clickCount: 1 });
await wait(400);
-- assert.equal(await ph.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a swipe down closes the sheet');
-- assert.deepEqual(await ph.evaluate('window.shut'), ['swipe']);
++ await eventually(async () => assert.equal(await ph.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a swipe down closes the sheet'));
++ await eventually(async () => assert.deepEqual(await ph.evaluate('window.shut'), ['swipe']));
await ph.close();
console.log('ov-window-chrome: a named modal dialog from its button, focus kept and given back, stacked Escape, outside press, text as text, a panel beside its button that keeps its place, one window style, a phone sheet closed by a swipe');
} finally {
ddiff --git a/test/overlay-chrome.test.js b/test/overlay-chrome.test.js
iindex a66b21a..3ff8595 100644
---- a/test/overlay-chrome.test.js
++++ b/test/overlay-chrome.test.js
@@@ -5,6 +5,7 @@
// another OpenVibe site carries it in #ovo=; the next page opens it again and takes the hash out of the address; a
// source that is not first-party is never opened; a reload brings it back. Skipped without Chrome.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -65,30 +66,30 @@ const RECT = `(() => { const c = document.querySelector('.ovo-card'); if (!c) re
assert.ok(await until(p, `getComputedStyle(document.querySelector('.ovo-side')).transform === 'none'`), 'the sheet has finished opening');
const side = await p.evaluate(`(() => { const s = document.querySelector('.ovo-side'); const r = s.getBoundingClientRect(); return { open: s.classList.contains('is-open'), bottom: Math.round(r.bottom), src: s.querySelector('iframe').getAttribute('src') }; })()`);
assert.deepStrictEqual(side, { open: true, bottom: 844 - 58 - 10, src: `${base}/frame-chat` }, 'the chat sheet, above the app bar');
-- assert.strictEqual(await p.evaluate('document.body.style.position'), 'fixed', 'the chat sheet locks the page');
++ await eventually(async () => assert.strictEqual(await p.evaluate('document.body.style.position'), 'fixed', 'the chat sheet locks the page'));
await p.evaluate(`document.querySelector('[data-ovo="side"]').click(), true`);
-- assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'closing the chat sheet unlocks the page');
++ await eventually(async () => assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'closing the chat sheet unlocks the page'));
// A link to another OpenVibe site carries it; one on this site does not.
const away = await p.evaluate(`OVOverlay.handoffUrl('https://openvibe.games/achievements')`);
assert.match(away, /^https:\/\/openvibe\.games\/achievements#ovo=[A-Za-z0-9_-]+$/);
const carried = JSON.parse(Buffer.from(away.split('#ovo=')[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
assert.deepStrictEqual(carried.map((d) => [d.id, d.title, d.live, d.src, d.side && d.side.src]), [['live', 'maticus', true, `${base}/frame-a`, `${base}/frame-chat`]]);
-- assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('${base}/b')`), `${base}/b`, 'the same site: no hash');
-- assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('https://example.com/x')`), 'https://example.com/x', 'not an OpenVibe site: no hash');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('${base}/b')`), `${base}/b`, 'the same site: no hash'));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('https://example.com/x')`), 'https://example.com/x', 'not an OpenVibe site: no hash'));
// Arriving with the hash: the card opens and the hash leaves the address.
const data = away.split('#ovo=')[1];
const q = await h.openPage(browser, await browser.newContext(), { width: 1280, height: 900 });
await q.goto(`${base}/b#ovo=${data}`, { maxMs: 5000 });
assert.ok(await until(q, `!!document.querySelector('.ovo-card')`), 'the carried card opens');
-- assert.strictEqual(await q.evaluate(`location.hash`), '', 'the hash is gone from the address');
-- assert.strictEqual(await q.evaluate(`document.querySelector('.ovo-card .ovo-name').textContent`), 'maticus');
++ await eventually(async () => assert.strictEqual(await q.evaluate(`location.hash`), '', 'the hash is gone from the address'));
++ await eventually(async () => assert.strictEqual(await q.evaluate(`document.querySelector('.ovo-card .ovo-name').textContent`), 'maticus'));
// Reload: it comes back (this site's session).
await q.goto(`${base}/b`, { maxMs: 5000 });
assert.ok(await until(q, `!!document.querySelector('.ovo-card')`), 'a reload brings it back');
await q.evaluate(`OVOverlay.close('live'), true`);
-- assert.strictEqual(await q.evaluate(`sessionStorage.getItem('ov-overlay')`), null, 'closed: nothing kept');
++ await eventually(async () => assert.strictEqual(await q.evaluate(`sessionStorage.getItem('ov-overlay')`), null, 'closed: nothing kept'));
// A source that is not first-party is never opened, from the hash or from a call.
const evil = Buffer.from(JSON.stringify([{ id: 'x', src: 'https://evil.example/steal', title: 'x' }])).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
@@@ -96,15 +97,15 @@ const RECT = `(() => { const c = document.querySelector('.ovo-card'); if (!c) re
await e.goto(`${base}/c#ovo=${evil}`, { maxMs: 5000 });
await until(e, `!!window.OVOverlay`);
await wait(200);
-- assert.strictEqual(await e.evaluate(`document.querySelectorAll('.ovo-card').length`), 0, 'a foreign source is refused');
-- assert.strictEqual(await e.evaluate(`OVOverlay.open({ src: 'javascript:alert(1)' })`), null);
++ await eventually(async () => assert.strictEqual(await e.evaluate(`document.querySelectorAll('.ovo-card').length`), 0, 'a foreign source is refused'));
++ await eventually(async () => assert.strictEqual(await e.evaluate(`OVOverlay.open({ src: 'javascript:alert(1)' })`), null));
for (const host of ['portal--alex.openvibe.link', 'portal--alex.openvibe.website', 'nested.portal--alex.openvibe.link', 'unknown.openvibe.link']) {
-- assert.strictEqual(await e.evaluate(`OVOverlay.safeSrc('https://${host}/')`), null, host);
-- assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('https://${host}/')`), `https://${host}/`, 'no card carried to a share');
++ await eventually(async () => assert.strictEqual(await e.evaluate(`OVOverlay.safeSrc('https://${host}/')`), null, host));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('https://${host}/')`), `https://${host}/`, 'no card carried to a share'));
}
for (const host of ['openvibe.link', 'connect.openvibe.link']) assert.ok(await e.evaluate(`OVOverlay.safeSrc('https://${host}/')`), host);
-- assert.strictEqual(await e.evaluate(`OVOverlay.open({ src: 'http://openvibe.live/embed/x' })`), null, 'plain http to another site is refused');
-- assert.ok(await e.evaluate(`!!OVOverlay.safeSrc('https://openvibe.live/embed/maticus')`), "Live's embed is first-party");
++ await eventually(async () => assert.strictEqual(await e.evaluate(`OVOverlay.open({ src: 'http://openvibe.live/embed/x' })`), null, 'plain http to another site is refused'));
++ await eventually(async () => assert.ok(await e.evaluate(`!!OVOverlay.safeSrc('https://openvibe.live/embed/maticus')`), "Live's embed is first-party"));
console.log('overlay-chrome: ok');
} finally {
await browser.close();
ddiff --git a/test/player-startup.test.js b/test/player-startup.test.js
nnew file mode 100644
iindex 0000000..fe05f4d
---- /dev/null
++++ b/test/player-startup.test.js
@@@ -0,0 +1,72 @@
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const vm = require('vm');
++const { parseHTML } = require('linkedom');
++const { window, document } = parseHTML('<html><body><a data-ov-player-hls="https://media.test/master.m3u8">Watch</a></body></html>');
++const calls = [], connection = { effectiveType: '4g', addEventListener() {}, removeEventListener() {} };
++const bodies = {
++ 'https://media.test/master.m3u8': '#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=2000000\nhigh.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=300000\nlow.m3u8',
++ 'https://media.test/low.m3u8': '#EXTM3U\n#EXT-X-MAP:URI="init.mp4"\n#EXTINF:4,\nfirst.m4s\n#EXTINF:4,\nsecond.m4s\n#EXT-X-ENDLIST',
++ 'https://media.test/init.mp4': 'init', 'https://media.test/first.m4s': 'segment',
++};
++const context = { window, document, navigator: { connection }, URL, TextDecoder, Uint8Array, AbortController,
++ console, setTimeout, clearTimeout, fetch: async (url) => { calls.push(url); return new Response(bodies[url] || '', {status:bodies[url] ? 200 : 404}); } };
++vm.runInNewContext(fs.readFileSync(require.resolve('../player.js'), 'utf8'), context);
++const P = window.OpenVibePlayer;
++(async () => {
++ for (const [type, expected] of [['slow-2g','none'],['2g','none'],['3g','metadata'],['4g','auto'],[undefined,'auto']])
++ assert.strictEqual(P.playbackPolicy({effectiveType:type}).preload, expected);
++ assert.strictEqual(P.playbackPolicy({saveData:true,effectiveType:'4g'}).preload,'none');
++ const low = P.playbackPolicy({}, {level:0.1,charging:false});
++ assert.strictEqual(low.hls.maxBufferLength,15); assert.strictEqual(low.hls.backBufferLength,0); assert.strictEqual(low.warm,false);
++ assert.strictEqual(P.playbackPolicy({}, {level:0.1,charging:true}).hls.maxBufferLength,60);
++ const video = document.createElement('video'); video.paused=true;
++ class Hls {
++ static isSupported() { return true; }
++ static Events = { DESTROYING: 'destroy' };
++ constructor(config) { this.config=config; this.events={}; this.starts=0; }
++ on(e, fn) { this.events[e]=fn; }
++ startLoad() { this.starts++; }
++ loadSource(url) { this.url=url; }
++ attachMedia(v) { this.video=v; }
++ }
++ connection.effectiveType='3g';
++ const hls=P.attachHls(video,Hls,'https://media.test/master.m3u8',{enableWorker:true});
++ assert.strictEqual(hls.config.autoStartLoad,false); assert.strictEqual(hls.config.startLevel,0);
++ assert.strictEqual(hls.config.enableWorker,true); assert.strictEqual(hls.starts,0);
++ video.dispatchEvent(new window.Event('play')); assert.strictEqual(hls.starts,1);
++ hls.events.destroy(); video.dispatchEvent(new window.Event('play')); assert.strictEqual(hls.starts,1,'destroy removes play listener');
++ assert.strictEqual(await P.warmHls('https://media.test/master.m3u8'),false); assert.strictEqual(calls.length,0);
++ connection.effectiveType='4g';
++ assert.strictEqual(await P.warmHls('https://media.test/master.m3u8'),true);
++ assert.deepStrictEqual(calls,Object.keys(bodies).filter(u=>!u.endsWith('high.m3u8')));
++ assert.strictEqual(await P.warmHls('https://media.test/master.m3u8'),false,'deduplicated');
++ assert.strictEqual(await P.warmHls('https://media.test/private.m3u8?sig=private'),false);
++ assert.strictEqual(await P.warmHls('file:///x.m3u8'),false);
++ bodies['https://media.test/live.m3u8']='#EXTM3U\n#EXTINF:4,\nfirst.m4s';
++ assert.strictEqual(await P.warmHls('https://media.test/live.m3u8'),false,'live segments not prefetched');
++ bodies['https://media.test/big.m3u8']='#EXTM3U\n#EXTINF:4,\nbig.m4s\n#EXT-X-ENDLIST';
++ bodies['https://media.test/big.m4s']='x'.repeat(2*1024*1024+1);
++ assert.strictEqual(await P.warmHls('https://media.test/big.m3u8'),false,'byte cap');
++ bodies['https://media.test/range.m3u8']='#EXTM3U\n#EXT-X-MAP:URI="whole.mp4",BYTERANGE="100@0"\n#EXTINF:4,\nfirst.m4s\n#EXT-X-ENDLIST';
++ assert.strictEqual(await P.warmHls('https://media.test/range.m3u8'),false,'ranged init never fetches a whole file');
++ assert(!calls.includes('https://media.test/whole.mp4'));
++ bodies['https://media.test/encrypted.m3u8']='#EXTM3U\n#EXT-X-SESSION-KEY:METHOD=AES-128,URI="key"\n#EXT-X-STREAM-INF:BANDWIDTH=300000\nlow.m3u8';
++ const beforeEncrypted=calls.length;
++ assert.strictEqual(await P.warmHls('https://media.test/encrypted.m3u8'),false);
++ assert.strictEqual(calls.length,beforeEncrypted+1,'session encryption stops at the manifest');
++ const card = document.querySelector('[data-ov-player-hls]');
++ card.setAttribute('data-ov-player-hls','https://media.test/focus.m3u8');
++ bodies['https://media.test/focus.m3u8']=bodies['https://media.test/low.m3u8'];
++ const beforeFocus=calls.length;
++ card.dispatchEvent(new window.Event('focusin'));
++ card.dispatchEvent(new window.Event('focusout'));
++ await new Promise(resolve=>setTimeout(resolve,150));
++ assert.strictEqual(calls.length,beforeFocus,'leaving before intent delay cancels warming');
++ card.dispatchEvent(new window.Event('focusin'));
++ await new Promise(resolve=>setTimeout(resolve,150));
++ assert(calls.includes('https://media.test/focus.m3u8'),'keyboard focus warms a card');
++ card.dispatchEvent(new window.Event('focusout'));
++ console.log('player startup: preload, battery, deferred HLS, low rung, public bounded warming and teardown passed');
++})().catch(e=>{console.error(e);process.exitCode=1});
ddiff --git a/test/presence-chrome.test.js b/test/presence-chrome.test.js
iindex e1a8e03..419952e 100644
---- a/test/presence-chrome.test.js
++++ b/test/presence-chrome.test.js
@@@ -4,6 +4,7 @@
// { state: 'dnd', show_status: true }) and the dot follows; a status line saves; Escape closes the picker; a second
// tab within the minute does not beat again; presence: false loads nothing. Skipped without Chrome.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -65,7 +66,7 @@ const server = http.createServer((req, res) => {
assert.strictEqual(state, 'online', `${width}px: the dot says online`);
const beats = await p.evaluate(`window.__calls.filter((c) => /heartbeat/.test(c.url)).length`);
assert.strictEqual(beats, 1, 'one heartbeat on load');
-- assert.strictEqual(await p.evaluate(`document.getElementById('openvibe-avatar-btn').title`), 'Online');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.getElementById('openvibe-avatar-btn').title`), 'Online'));
await p.evaluate(`OpenVibePresence.open()`);
assert.ok(await until(p, `!!document.querySelector('.ovp-pop')`), `${width}px: the picker opens`);
@@@ -77,7 +78,7 @@ const server = http.createServer((req, res) => {
assert.strictEqual(await until(p, `(() => { const d = document.querySelector('.ovp-wrap .ovp-dot'); return d && d.dataset.state === 'dnd' && d.dataset.state; })()`), 'dnd', `${width}px: Do not disturb shows`);
const put = await p.evaluate(`window.__calls.find((c) => c.method === 'PUT')`);
assert.deepStrictEqual(put.body, { state: 'dnd', show_status: true });
-- assert.strictEqual(await p.evaluate(`document.querySelector('.ovp-opt[aria-checked="true"]').dataset.value`), 'dnd', 'the picker marks it');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.querySelector('.ovp-opt[aria-checked="true"]').dataset.value`), 'dnd', 'the picker marks it'));
await p.evaluate(`(() => { const i = document.querySelector('.ovp-text input'); i.value = 'Building a world'; i.form.requestSubmit(); })()`);
assert.ok(await until(p, `/Building a world/.test(document.getElementById('openvibe-avatar-btn').title)`), 'the status line saves and shows on hover');
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`);
@@@ -88,12 +89,12 @@ const server = http.createServer((req, res) => {
const again = await h.openPage(browser, context, { width: 1280 });
await again.goto(`${base}/on`, { maxMs: 5000 });
assert.strictEqual(await until(again, `(() => { const d = document.querySelector('.ovp-wrap .ovp-dot'); return d && d.dataset.state; })()`), 'dnd');
-- assert.strictEqual(await again.evaluate(`window.__calls.filter((c) => /heartbeat/.test(c.url)).length`), 0, 'a second tab within the minute does not beat again');
++ await eventually(async () => assert.strictEqual(await again.evaluate(`window.__calls.filter((c) => /heartbeat/.test(c.url)).length`), 0, 'a second tab within the minute does not beat again'));
await again.close();
const off = await h.openPage(browser, context, { width: 1280 });
await off.goto(`${base}/off`, { maxMs: 5000 });
await wait(400);
-- assert.strictEqual(await off.evaluate(`!!window.OpenVibePresence || !!document.getElementById('ov-presence-loader')`), false, 'presence: false loads nothing');
++ await eventually(async () => assert.strictEqual(await off.evaluate(`!!window.OpenVibePresence || !!document.getElementById('ov-presence-loader')`), false, 'presence: false loads nothing'));
await off.close();
console.log('presence-chrome: the dot, one heartbeat a minute across tabs, the picker (sheet on a phone), Do not disturb, a status line, Escape; presence: false ok');
} finally {
ddiff --git a/test/rails-scroll-chrome.test.js b/test/rails-scroll-chrome.test.js
iindex fe08db1..de611dc 100644
---- a/test/rails-scroll-chrome.test.js
++++ b/test/rails-scroll-chrome.test.js
@@@ -4,6 +4,7 @@
// where more is hidden and a position bar under them; data-ovk-scroll="drift" moves on its own while in view and stops for
// good at the first touch; a strip with nothing hidden shows neither. Skipped without Chrome.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -62,7 +63,7 @@ const server = http.createServer((req, res) => {
await p.evaluate("document.getElementById('drift').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))");
const c = await p.evaluate("document.getElementById('drift').scrollLeft");
await wait(700);
-- assert.strictEqual(await p.evaluate("document.getElementById('drift').scrollLeft"), c, 'and stops at the first touch');
++ await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('drift').scrollLeft"), c, 'and stops at the first touch'));
console.log('rails-scroll-chrome: edge fades, the position bar, drift that stops at a touch, nothing on a short strip');
} finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/search-loading-chrome.test.js b/test/search-loading-chrome.test.js
iindex c7da782..75673a0 100644
---- a/test/search-loading-chrome.test.js
++++ b/test/search-loading-chrome.test.js
@@@ -5,6 +5,7 @@
// "all of OpenVibe" and "Ask Vibe"; arrows and Escape work; titles are text, never markup. Loading: a poster-backed
// overlay that steps, slows with actions, fails with Retry and fades out; skeleton rows. Skipped without Chrome.
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -80,8 +81,8 @@ const server = http.createServer((req, res) => {
await p.goto(`${base}/`, { maxMs: 5000 });
await wait(200);
// ── Search ──
-- assert.strictEqual(await p.evaluate('!!document.getElementById("openvibe-search-btn")'), true, 'the magnifier is in the bar');
-- assert.strictEqual(await p.evaluate('typeof window.OpenVibeSearch'), 'undefined', 'search.js is not loaded until asked');
++ await eventually(async () => assert.strictEqual(await p.evaluate('!!document.getElementById("openvibe-search-btn")'), true, 'the magnifier is in the bar'));
++ await eventually(async () => assert.strictEqual(await p.evaluate('typeof window.OpenVibeSearch'), 'undefined', 'search.js is not loaded until asked'));
await p.evaluate('document.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true })); 1');
// search.js loads on first use: wait for its box, not a fixed time (macOS runners took longer than 400 ms, 4.28.0's CI).
for (let i = 0; i < 50 && !(await p.evaluate('!!document.querySelector(".ovs.is-open .ovs-input")')); i++) await wait(100);
@@@ -100,12 +101,12 @@ const server = http.createServer((req, res) => {
assert.strictEqual(v.placeholder, 'Search Chat, or ask Vibe', 'the site by its short name (4.48.0)');
// The box completes from this site's pages (and the person's searches, history and sites); Tab takes it.
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'Roo'; i.dispatchEvent(new Event('input')); return 1; })()`);
-- assert.strictEqual(await p.evaluate('document.querySelector(".ovs-ghost").textContent'), 'Rooms', 'the completion shows behind the text');
++ await eventually(async () => assert.strictEqual(await p.evaluate('document.querySelector(".ovs-ghost").textContent'), 'Rooms', 'the completion shows behind the text'));
await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); 1');
-- assert.strictEqual(await p.evaluate('document.querySelector(".ovs-input").value'), 'Rooms', 'Tab takes it');
++ await eventually(async () => assert.strictEqual(await p.evaluate('document.querySelector(".ovs-input").value'), 'Rooms', 'Tab takes it'));
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = ''; i.dispatchEvent(new Event('input')); return 1; })()`);
await wait(100);
-- assert.ok(await p.evaluate('!!document.querySelector(".ovs-grid.is-mini .ovs-tile") && !document.querySelector("[data-ovs-fold=all]")'), 'every site is in the tab itself, compact, with no fold');
++ await eventually(async () => assert.ok(await p.evaluate('!!document.querySelector(".ovs-grid.is-mini .ovs-tile") && !document.querySelector("[data-ovs-fold=all]")'), 'every site is in the tab itself, compact, with no fold'));
// Typing: one request for the latest text (the earlier one cancelled), this site's results first.
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'slow'; i.dispatchEvent(new Event('input')); setTimeout(() => { i.value = 'night'; i.dispatchEvent(new Event('input')); }, 250); })(); 1`);
await wait(1200);
@@@ -138,22 +139,22 @@ const server = http.createServer((req, res) => {
assert.ok(/^Searched OpenVibe in \d+ ms · the web through Wikipedia in /.test(v.route), `the route in words: ${v.route}`);
assert.ok(asked.includes('query:network') && asked.includes('query:web'), 'two questions: OpenVibe quickly, the web on its own');
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'nigh'; i.dispatchEvent(new Event('input')); return 1; })()`);
-- assert.strictEqual(await p.evaluate('document.querySelector(".ovs-rtabs").hidden'), true, 'typing goes back to suggestions');
++ await eventually(async () => assert.strictEqual(await p.evaluate('document.querySelector(".ovs-rtabs").hidden'), true, 'typing goes back to suggestions'));
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'night'; i.dispatchEvent(new Event('input')); return 1; })()`);
await wait(400);
await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); 1');
-- assert.notStrictEqual(await p.evaluate('document.querySelector(".ovs-opt.is-active .ovs-title").textContent'), 'night room', 'arrows move');
++ await eventually(async () => assert.notStrictEqual(await p.evaluate('document.querySelector(".ovs-opt.is-active .ovs-title").textContent'), 'night room', 'arrows move'));
await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); 1');
-- assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Escape closes');
++ await eventually(async () => assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Escape closes'));
// Back closes the menu and stays on the page (4.128.0, the owner 11-07: Back on a phone left the page).
const before = await p.evaluate('location.href');
await p.evaluate('document.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true })); 1');
for (let i = 0; i < 30 && !(await p.evaluate('!!document.querySelector(".ovs.is-open")')); i++) await wait(100);
-- assert.strictEqual(await p.evaluate('!!(history.state && history.state.ovsMenu)'), true, 'opening adds one history entry');
++ await eventually(async () => assert.strictEqual(await p.evaluate('!!(history.state && history.state.ovsMenu)'), true, 'opening adds one history entry'));
await p.evaluate('history.back(); 1');
await wait(400);
-- assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Back closes the menu');
-- assert.strictEqual(await p.evaluate('location.href'), before, 'and stays on the page');
++ await eventually(async () => assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Back closes the menu'));
++ await eventually(async () => assert.strictEqual(await p.evaluate('location.href'), before, 'and stays on the page'));
// ── The network's cards: a picture before the title, exactly as the entry sent it (Contracts 0.320.0) ──
// The cards are the Feed tab's (4.149.0).
ddiff --git a/test/wallet-chrome.test.js b/test/wallet-chrome.test.js
iindex e540d33..9668dae 100644
---- a/test/wallet-chrome.test.js
++++ b/test/wallet-chrome.test.js
@@@ -1,5 +1,6 @@
'use strict';
const assert = require('assert');
++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const fs = require('fs');
const os = require('os');
const path = require('path');
@@@ -34,15 +35,15 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
}
const wasWorn = await p.evaluate(`document.querySelector('.ov-wallet').innerText.includes('Unequip')`);
await p.evaluate(`[...document.querySelectorAll('.ov-wallet button')].find(b=>/^(Equip|Unequip)$/.test(b.textContent)).click()`); await wait(150);
-- assert.strictEqual(await p.evaluate(`document.querySelector('.ov-wallet').innerText.includes('Unequip')`), !wasWorn);
++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.querySelector('.ov-wallet').innerText.includes('Unequip')`), !wasWorn));
await p.evaluate(`[...document.querySelectorAll('.ov-wallet button')].find(b=>b.textContent==='Buy Vibes').click()`);
assert(await p.evaluate(`document.querySelector('.ov-wallet-checkout').innerText.includes('Continue to checkout')`));
await p.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }); await wait(50);
-- assert.strictEqual(await p.evaluate(`!!document.querySelector('.ov-wallet')`), false);
-- assert.strictEqual(await p.evaluate(`document.documentElement.style.overflow`), '');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`!!document.querySelector('.ov-wallet')`), false));
++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.documentElement.style.overflow`), ''));
await p.evaluate(`OpenVibeNavbar.openWallet()`); await wait(150);
await p.evaluate(`OpenVibeNavbar.setUser(null,null)`);
-- assert.strictEqual(await p.evaluate(`!!document.querySelector('.ov-wallet')`), false, 'signout closes private content');
++ await eventually(async () => assert.strictEqual(await p.evaluate(`!!document.querySelector('.ov-wallet')`), false, 'signout closes private content'));
const exceptions = p.state.errors || [];
assert.strictEqual(exceptions.length, 0, JSON.stringify(exceptions));
await p.close(); await context.dispose();
ddiff --git a/thread.css b/thread.css
iindex c0986f0..72872ea 100644
---- a/thread.css
++++ b/thread.css
@@@ -309,65 +309,3 @@ html.ovt-sheet-open, html.ovt-sheet-open body { overflow: hidden; }
.ovt-composer > .ovt-c-note { position: absolute; left: 14px; right: 14px; top: calc(100% + 6px); margin: 0; text-align: center; font-size: 12px; line-height: 1.4; }
.ovt-dock:has(.ovt-c-note:not(:empty)) { padding-bottom: calc(30px + env(safe-area-inset-bottom, 0px)); }
.ovt.is-start .ovt-dock:has(.ovt-c-note:not(:empty)) { padding-bottom: max(40px, env(safe-area-inset-bottom, 0px)); }
--
--/* 4.201.0, the one composer (ov-composer.js; ask 349): files as chips above the box, a drop target, the slash and
-- mention list, the microphone, the count, a compact box, and your settings sheet. */
--.ovc-files { display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 8px; margin: 0 0 2px -4px; scrollbar-width: thin; }
--.ovc-files[hidden] { display: none; }
--.ovc-file { position: relative; flex: none; display: flex; align-items: center; gap: 9px; max-width: 230px; height: 52px; padding: 5px 30px 5px 5px; box-sizing: border-box; border-radius: 14px;
-- border: 1px solid var(--ovt-line); background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); overflow: hidden; animation: ovc-in .2s cubic-bezier(.2, .9, .3, 1) both; }
--.ovc-file.is-bad { border-color: color-mix(in srgb, var(--live-red, #ef4444) 70%, transparent); }
--.ovc-file.is-up::after { content: ""; position: absolute; left: 0; bottom: 0; height: 3px; width: calc(var(--p, 0) * 100%); background: var(--ovt-accent); transition: width .2s; }
--.ovc-file-th { flex: none; width: 40px; height: 40px; border-radius: 10px; object-fit: cover; background: #0b1220; }
--.ovc-file-ic { display: grid; place-items: center; background: color-mix(in srgb, var(--ovt-accent) 16%, transparent); color: var(--ovt-accent); font-size: 11px; font-weight: 750; letter-spacing: .02em; }
--.ovc-file-t { min-width: 0; display: grid; line-height: 1.25; }
--.ovc-file-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; font-weight: 600; color: var(--text-primary, #e6edf7); }
--.ovc-file-s { font-size: 12px; color: var(--text-muted, #7386a3); white-space: nowrap; }
--.ovc-file.is-bad .ovc-file-s { color: var(--live-red, #ef4444); }
--.ovc-file-x, .ovc-file-b { position: absolute; top: 4px; right: 4px; display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; }
--.ovc-file-b { top: auto; bottom: 4px; }
--.ovc-file-x:hover, .ovc-file-b:hover, .ovc-file-x:focus-visible, .ovc-file-b:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 10%, transparent)); color: var(--text-primary, #e6edf7); }
--.ovc-file-x svg, .ovc-file-b svg { width: 15px; height: 15px; }
--.ovc-dropping { border-color: var(--ovt-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovt-accent) 30%, transparent), 0 8px 30px rgba(0, 0, 0, .25); }
--.ovc-dropping::before { content: "Drop to attach"; position: absolute; inset: 0; z-index: 5; display: grid; place-items: center; border-radius: inherit; background: color-mix(in srgb, var(--bg-card, #131c2e) 86%, var(--ovt-accent)); color: var(--text-primary, #e6edf7); font-weight: 650; pointer-events: none; }
--.ovc-pop { position: absolute; z-index: 40; left: 6px; right: 6px; bottom: calc(100% + 8px); max-height: min(320px, 50vh); overflow: auto; padding: 6px; border-radius: 16px; border: 1px solid var(--ovt-line);
-- background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); animation: ovc-in .16s cubic-bezier(.2, .9, .3, 1) both; }
--.ovt.is-start .ovc-pop { bottom: auto; top: calc(100% + 8px); }
--.ovc-pop[hidden] { display: none; }
--.ovc-pop-i { display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 6px 10px; box-sizing: border-box; border: 0; border-radius: 11px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
--.ovc-pop-i[aria-selected="true"], .ovc-pop-i:hover { background: color-mix(in srgb, var(--ovt-accent) 14%, transparent); }
--.ovc-pop-ic, .ovc-pop-av { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; color: var(--ovt-accent); background: color-mix(in srgb, var(--ovt-accent) 12%, transparent); object-fit: cover; }
--.ovc-pop-av { border-radius: 50%; }
--.ovc-pop-ic svg { width: 17px; height: 17px; }
--.ovc-pop-t { min-width: 0; display: grid; line-height: 1.3; }
--.ovc-pop-t b { font-weight: 650; font-size: 14px; color: var(--text-primary, #e6edf7); }
--.ovc-pop-t span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text-muted, #7386a3); }
--.ovc-pop-tag { font-size: 11px; font-weight: 650; padding: 2px 7px; border-radius: 999px; color: var(--ovt-accent); background: color-mix(in srgb, var(--ovt-accent) 12%, transparent); }
--.ovc-mic[aria-pressed="true"] { color: var(--live-red, #ef4444); background: color-mix(in srgb, var(--live-red, #ef4444) 14%, transparent); animation: ovc-listen 1.4s ease-in-out infinite; }
--.ovc-count { flex: none; font-size: 12px; font-variant-numeric: tabular-nums; color: var(--text-muted, #7386a3); }
--.ovc-count.is-low { color: var(--live-red, #ef4444); }
--.ovc-count[hidden] { display: none; }
--.ovt-composer.ovc-compact { padding: 6px 8px 6px 12px; border-radius: 18px; }
--.ovt-composer.ovc-compact .ovt-c-row { margin-top: 0; }
--.ovt-composer.ovc-compact .ovt-btn, .ovt-composer.ovc-compact .ovt-send { width: 36px; min-width: 36px; height: 36px; }
--@keyframes ovc-in { from { opacity: 0; transform: translateY(4px) scale(.98); } }
--@keyframes ovc-listen { 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--live-red, #ef4444) 18%, transparent); } }
--html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data-ov-motion=off] .ovc-mic { animation: none; }
--.ovc-set { width: min(460px, calc(100vw - 24px)); max-height: min(640px, calc(100vh - 32px)); padding: 0; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .2)); border-radius: 20px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); box-shadow: 0 30px 80px rgba(0, 0, 0, .5); }
--.ovc-set::backdrop { background: rgba(2, 6, 14, .55); }
--.ovc-set.ovc-set-in { width: auto; max-height: none; border: 0; border-radius: 0; background: none; box-shadow: none; }
--.ovc-set-in .ovc-set-f { padding: 0; }
--.ovc-set-f { display: grid; gap: 14px; padding: 6px 18px 18px; }
--.ovc-set-h { display: flex; align-items: center; justify-content: space-between; margin: 0 -8px 0 0; font-size: 17px; }
--.ovc-set-g { margin: 0; padding: 0; border: 0; display: grid; gap: 6px; }
--.ovc-set-g legend { padding: 0 0 6px; font-weight: 650; font-size: 14px; }
--.ovc-set label { display: flex; align-items: center; gap: 10px; min-height: 40px; font-size: 14.5px; cursor: pointer; }
--.ovc-set small, .ovc-set-p { color: var(--text-muted, #7386a3); font-size: 12.5px; margin: 0; }
--.ovc-set input[type=radio], .ovc-set input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--accent, #3b82f6); }
--.ovc-set-l { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
--.ovc-set-l li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; padding: 4px 4px 4px 10px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); }
--.ovc-set-l span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary, #b4c0d4); font-size: 13px; }
--.ovc-set-add { display: grid; grid-template-columns: 110px 1fr auto; gap: 8px; align-items: start; }
--.ovc-set-add input, .ovc-set-add textarea { box-sizing: border-box; width: 100%; padding: 9px 10px; border-radius: 10px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .25)); background: var(--bg-input, rgba(2, 6, 14, .35)); color: inherit; font: inherit; font-size: 14px; resize: vertical; }
--.ovc-set-btn { height: 40px; padding: 0 14px; border: 0; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: inherit; font-weight: 650; cursor: pointer; }
--@media (max-width: 480px) { .ovc-set-add { grid-template-columns: 1fr; } .ovc-file { max-width: 200px; } }