openvibers/openvibe.shared Commits

4.145.0: ov-canvas.js, an agent's live panel drawn the same on every site (#334)

OP OpenVibers committed 5 days ago · 1106e8db5e · 7 files, +402 −3

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b9c28ff..321fc49 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,16 @@
# Changelog
++## 4.145.0 — 2026-11-08
++
++- **ov-canvas.js: an agent's live panel, drawn the same on every site** (`actor.canvas@1`, Contracts 0.340.0, ADR-059,
++ plan HO9). `OVCanvas.render(host, doc, { onAsk, onCheck })` draws headings, text, lists, checklists, tables,
++ metrics, bar and line charts (inline SVG, their numbers also a table for screen readers), pictures and links.
++ Every block is built node by node and text is plain. A picture is drawn only from openvibe.media (one that fails to
++ load is removed), a link only when it is https, with its host shown. A button only calls `onAsk` with its label (the
++ page sends it to the agent as the person's message); a tick calls `onCheck`. Without the callbacks the canvas only
++ shows. An older version never replaces a newer one. A wide table scrolls inside its own box, so a phone's page never
++ scrolls sideways. A bar chart stands on zero; a line spans its own range.
++
## 4.144.0 — 2026-11-08
- **Send it on OpenVibe.** The share sheet (ov-share.js) offers **OpenVibe Chat** first to a signed-in person (the Dock
ddiff --git a/README.md b/README.md
iindex 109418d..9970c5b 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.144.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.145.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 d7c7fd7..4ad18b0 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,13 +4,14 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.144.0",
++ "version": "4.145.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.338.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.94.0",
"features": [
++ "an agent's live panel: ov-canvas.js draws actor.canvas@1 (headings, text, lists, checklists, tables, metrics, bar and line charts, pictures from openvibe.media, https links, buttons) the same on every site; built node by node, a button only asks, a tick tells, versions only grow (4.145.0)",
"send it on OpenVibe: the share sheet's first place for a signed-in person is OpenVibe Chat; the Dock opens on Send to… (a conversation, or someone new by their name) and the message waits in the box until the person sends it, OpenVibeDock.share({ text, title }) (4.144.0)",
"one timeline for many players: ov-sync.js, OVSync.attach(mediaEl, { room, socket }), follows Chat's common.sync-clock@1 state over the room socket, measures the server clock with five time samples and holds the element within ±80 ms, nudging 0.95–1.05 or seeking, and respecting the control lease (4.143.0)",
"the menu on a phone has a round close button at the end of the search pill: it covers the whole screen, burger too (4.142.0)",
ddiff --git a/files.js b/files.js
iindex 53ea25d..f9a2701 100644
---- a/files.js
++++ b/files.js
@@@ -26,6 +26,7 @@ const BROWSER = Object.freeze([
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent
++ 'ov-canvas.js', // an agent's live panel (actor.canvas@1, 4.145.0); a page that shows canvases loads it
'dock-agent.js', // the Dock's agent view (K-D3); dock.js loads it the first time the Agents tab opens
'dock-timeline.js', // the Dock's one timeline (A6); dock.js loads it with the Agent panel
'account-switcher.js',
ddiff --git a/ov-canvas.js b/ov-canvas.js
nnew file mode 100644
iindex 0000000..14b2bb4
---- /dev/null
++++ b/ov-canvas.js
@@@ -0,0 +1,275 @@
++/**
++ * openvibe-shared/ov-canvas.js — an agent's live panel (actor.canvas@1, ADR-059, plan HO9), drawn the same on every
++ * site that shows it: Chat, Surf, the Dock and Actor.
++ *
++ * OVCanvas.render(host, doc, { onAsk(label, block), onCheck(blockId, itemId, done) }) → the version drawn
++ * OVCanvas.valid(doc) → true when doc is an actor.canvas@1 document this renderer can draw
++ *
++ * A canvas is DATA. Every block is built node by node (never innerHTML), text is plain, a picture is drawn only from
++ * openvibe.media and a link only when it is https — the contract says so, and this file checks it again, because what
++ * it draws was written by a model. A button never acts: it calls onAsk with its label, and the page sends that to the
++ * agent as the person's own message. A checklist tick calls onCheck. A page without the callback gets a canvas that
++ * only shows. An older version than the one on screen is ignored, so a late update never rolls the panel back.
++ * Charts are inline SVG with their numbers in a table for screen readers; a table scrolls inside its own box, so the
++ * page never scrolls sideways.
++ */
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OVCanvas) return;
++
++ const MEDIA = /^https:\/\/openvibe\.media\/[A-Za-z0-9._~/%-]{1,1024}(\?[A-Za-z0-9._~=&%-]{0,200})?$/;
++ const HTTPS = /^https:\/\/[^\s"'<>]{1,2048}$/;
++ const ID = /^[a-z0-9][a-z0-9_-]{0,39}$/;
++ const MAX_BLOCKS = 60;
++ const CSS = `
++.ovc { --ovc-bg: var(--bg-secondary, #111a2c); --ovc-panel: var(--bg-tertiary, #16213a); --ovc-text: var(--text-primary, #e6edf7);
++ --ovc-muted: var(--text-secondary, #9fb0c8); --ovc-line: var(--border-color, rgba(255, 255, 255, .12)); --ovc-accent: var(--accent, #60a5fa);
++ --ovc-on-accent: var(--on-accent-strong, var(--on-accent, #0a0f1c)); --ovc-c1: var(--ovc-accent); --ovc-c2: #22d3ee; --ovc-c3: #f59e0b; --ovc-c4: #a78bfa;
++ display: grid; gap: 12px; min-width: 0; padding: 14px; border-radius: 14px; background: var(--ovc-bg); border: 1px solid var(--ovc-line);
++ color: var(--ovc-text); font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; }
++:root[data-theme-mode="light"] .ovc { --ovc-c2: #0e7490; --ovc-c3: #b45309; --ovc-c4: #6d28d9; }
++.ovc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
++.ovc-title { font-size: 15px; font-weight: 800; letter-spacing: -.01em; min-width: 0; }
++.ovc-when { flex: none; font-size: 12px; color: var(--ovc-muted); }
++.ovc h3, .ovc h4 { margin: 4px 0 0; line-height: 1.25; text-wrap: balance; }
++.ovc h3 { font-size: 17px; font-weight: 800; } .ovc h4 { font-size: 13px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; color: var(--ovc-muted); }
++.ovc-text { margin: 0; white-space: pre-wrap; }
++.ovc-list { margin: 0; padding-left: 20px; display: grid; gap: 4px; }
++.ovc-check { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
++.ovc-check label { display: flex; align-items: flex-start; gap: 10px; min-height: 40px; padding: 8px 10px; border-radius: 10px; cursor: pointer; }
++.ovc-check label:hover { background: var(--ovc-panel); }
++.ovc-check input { flex: none; width: 18px; height: 18px; margin: 1px 0 0; accent-color: var(--ovc-accent); }
++.ovc-check input:checked + span { color: var(--ovc-muted); text-decoration: line-through; }
++.ovc-check input:disabled { cursor: default; }
++.ovc-table { max-width: 100%; overflow-x: auto; border-radius: 10px; border: 1px solid var(--ovc-line); }
++.ovc-table table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
++.ovc-table th, .ovc-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--ovc-line); white-space: nowrap; }
++.ovc-table th { font-size: 12px; font-weight: 800; color: var(--ovc-muted); background: var(--ovc-panel); }
++.ovc-table tr:last-child td { border-bottom: 0; }
++.ovc-table .is-num { text-align: right; font-variant-numeric: tabular-nums; }
++.ovc-metric { display: grid; gap: 2px; padding: 12px 14px; border-radius: 12px; background: var(--ovc-panel); }
++.ovc-metric-l { font-size: 12px; font-weight: 700; color: var(--ovc-muted); }
++.ovc-metric-v { font-size: 26px; font-weight: 850; letter-spacing: -.02em; line-height: 1.1; font-variant-numeric: tabular-nums; }
++.ovc-metric-n { font-size: 12.5px; color: var(--ovc-muted); }
++.ovc-chart { margin: 0; display: grid; gap: 6px; }
++.ovc-chart svg { width: 100%; max-width: 100%; height: auto; display: block; }
++.ovc-chart .ax { stroke: var(--ovc-line); stroke-width: 1; }
++.ovc-chart text { fill: var(--ovc-muted); font-size: 10px; font-family: inherit; }
++.ovc-chart figcaption { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 12px; color: var(--ovc-muted); }
++.ovc-chart figcaption i { display: inline-block; width: 10px; height: 10px; margin-right: 5px; border-radius: 3px; vertical-align: -1px; }
++.ovc-s1 { --c: var(--ovc-c1); } .ovc-s2 { --c: var(--ovc-c2); } .ovc-s3 { --c: var(--ovc-c3); } .ovc-s4 { --c: var(--ovc-c4); }
++.ovc-chart rect.v { fill: var(--c); } .ovc-chart polyline.v { fill: none; stroke: var(--c); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; }
++.ovc-chart circle.v { fill: var(--c); } .ovc-chart figcaption i { background: var(--c); }
++.ovc-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++.ovc-img { display: block; width: 100%; max-width: 100%; height: auto; border-radius: 12px; border: 1px solid var(--ovc-line); background: var(--ovc-panel); }
++.ovc-link { display: grid; gap: 2px; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--ovc-line); color: var(--ovc-text); text-decoration: none; }
++.ovc-link:hover, .ovc-link:focus-visible { border-color: var(--ovc-accent); }
++.ovc-link b { font-weight: 700; color: var(--ovc-accent); } .ovc-link span { font-size: 12px; color: var(--ovc-muted); }
++.ovc-btn { justify-self: start; min-height: 40px; padding: 0 16px; border: 0; border-radius: 999px; background: var(--ovc-accent); color: var(--ovc-on-accent);
++ font: inherit; font-weight: 750; cursor: pointer; }
++.ovc-btn:disabled { opacity: .55; cursor: default; }
++.ovc-btn:focus-visible, .ovc-check input:focus-visible, .ovc-table:focus-visible { outline: 2px solid var(--ovc-accent); outline-offset: 2px; }
++`;
++ function styles(doc) {
++ if (doc.getElementById('ov-canvas-css')) return;
++ const st = doc.createElement('style');
++ st.id = 'ov-canvas-css';
++ st.textContent = CSS;
++ (doc.head || doc.documentElement).appendChild(st);
++ }
++
++ const str = (v, n) => String(v == null ? '' : v).slice(0, n);
++ function make(doc, tag, cls, text) {
++ const n = doc.createElement(tag);
++ if (cls) n.className = cls;
++ if (text != null) n.textContent = text;
++ return n;
++ }
++ function ago(iso) {
++ const t = Date.parse(iso || '');
++ if (!Number.isFinite(t)) return '';
++ const m = Math.round((Date.now() - t) / 60000);
++ if (m < 1) return 'just now';
++ if (m < 60) return `${m} min ago`;
++ if (m < 1440) return `${Math.floor(m / 60)} h ago`;
++ return new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
++ }
++ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v.toLocaleString() : str(v, 200));
++
++ function valid(doc) {
++ return !!(doc && typeof doc === 'object' && doc.canvas === 'actor.canvas@1' && Array.isArray(doc.blocks)
++ && Number.isInteger(doc.version) && doc.version >= 1);
++ }
++
++ /** A small chart: bars grouped by x, or one line per series. Nothing is fetched; the numbers are also a table. */
++ function chart(doc, b) {
++ const series = (Array.isArray(b.series) ? b.series : []).slice(0, 4)
++ .map((s) => ({ name: str(s && s.name, 40), points: (Array.isArray(s && s.points) ? s.points : []).slice(0, 60).filter((p) => p && Number.isFinite(Number(p.y))) }))
++ .filter((s) => s.points.length);
++ if (!series.length) return null;
++ const xs = [];
++ series.forEach((s) => s.points.forEach((p) => { const x = str(p.x, 20); if (!xs.includes(x)) xs.push(x); }));
++ const ys = series.flatMap((s) => s.points.map((p) => Number(p.y)));
++ // Bars stand on zero; a line spans its own range (a price moving from 240 to 199 must not look flat).
++ let lo = b.kind === 'line' ? Math.min(...ys) : Math.min(0, ...ys);
++ let hi = b.kind === 'line' ? Math.max(...ys) : (Math.max(0, ...ys) || 1);
++ if (b.kind === 'line') { const pad = (hi - lo) * 0.12 || Math.abs(hi) * 0.1 || 1; lo -= pad; hi += pad; }
++ const W = 320, H = 150, L = 34, R = 8, T = 8, B = 22;
++ const y = (v) => T + (H - T - B) * (1 - (v - lo) / (hi - lo || 1));
++ const step = (W - L - R) / xs.length;
++ const NS = 'http://www.w3.org/2000/svg';
++ const svg = doc.createElementNS(NS, 'svg');
++ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
++ svg.setAttribute('role', 'img');
++ svg.setAttribute('aria-label', `${b.kind === 'line' ? 'Line' : 'Bar'} chart of ${series.map((s) => s.name).join(', ')}`);
++ const add = (tag, attrs, text) => { const e = doc.createElementNS(NS, tag); Object.entries(attrs).forEach(([k, v]) => e.setAttribute(k, String(v))); if (text != null) e.textContent = text; svg.appendChild(e); return e; };
++ const base = b.kind === 'line' ? lo : 0;
++ add('line', { class: 'ax', x1: L, x2: W - R, y1: y(base), y2: y(base) });
++ const unit = str(b.unit, 12);
++ const top = Math.max(...ys), bottom = Math.min(...ys);
++ add('text', { x: L - 4, y: y(top) + 4, 'text-anchor': 'end' }, `${top.toLocaleString()}${unit}`);
++ if (b.kind === 'line' ? bottom !== top : bottom < 0) add('text', { x: L - 4, y: y(bottom) + 4, 'text-anchor': 'end' }, `${bottom.toLocaleString()}${unit}`);
++ const every = Math.max(1, Math.ceil(xs.length / 8));
++ xs.forEach((x, i) => { if (i % every === 0) add('text', { x: L + step * (i + .5), y: H - 6, 'text-anchor': 'middle' }, x.slice(0, 8)); });
++ series.forEach((s, k) => {
++ const cls = `v ovc-s${k + 1}`;
++ if (b.kind === 'line') {
++ const pts = s.points.map((p) => `${(L + step * (xs.indexOf(str(p.x, 20)) + .5)).toFixed(1)},${y(Number(p.y)).toFixed(1)}`);
++ add('polyline', { class: cls, points: pts.join(' ') });
++ if (s.points.length === 1) add('circle', { class: cls, cx: pts[0].split(',')[0], cy: pts[0].split(',')[1], r: 3.5 });
++ } else {
++ const bw = Math.max(2, (step * .7) / series.length);
++ s.points.forEach((p) => {
++ const i = xs.indexOf(str(p.x, 20)), v = Number(p.y);
++ const top = Math.min(y(v), y(0)), h = Math.max(1, Math.abs(y(v) - y(0)));
++ add('rect', { class: cls, x: (L + step * i + step * .15 + bw * k).toFixed(1), y: top.toFixed(1), width: bw.toFixed(1), height: h.toFixed(1), rx: 2 });
++ });
++ }
++ });
++ const fig = make(doc, 'figure', 'ovc-chart');
++ fig.appendChild(svg);
++ const cap = make(doc, 'figcaption');
++ series.forEach((s, k) => { const it = make(doc, 'span', `ovc-s${k + 1}`); it.appendChild(make(doc, 'i')); it.appendChild(doc.createTextNode(s.name)); cap.appendChild(it); });
++ fig.appendChild(cap);
++ // The numbers, for a screen reader (the picture says the same thing).
++ const t = make(doc, 'table', 'ovc-sr');
++ const hr = make(doc, 'tr'); hr.appendChild(make(doc, 'th', null, '')); series.forEach((s) => hr.appendChild(make(doc, 'th', null, s.name)));
++ t.appendChild(hr);
++ xs.forEach((x) => {
++ const r = make(doc, 'tr'); r.appendChild(make(doc, 'th', null, x));
++ series.forEach((s) => { const p = s.points.find((q) => str(q.x, 20) === x); r.appendChild(make(doc, 'td', null, p ? `${Number(p.y).toLocaleString()}${unit}` : '')); });
++ t.appendChild(r);
++ });
++ fig.appendChild(t);
++ return fig;
++ }
++
++ function block(doc, b, opts) {
++ if (!b || typeof b !== 'object') return null;
++ switch (b.type) {
++ case 'heading': return make(doc, b.level === 2 ? 'h4' : 'h3', null, str(b.text, 120));
++ case 'text': return make(doc, 'p', 'ovc-text', str(b.text, 4000));
++ case 'list': {
++ const l = make(doc, b.ordered ? 'ol' : 'ul', 'ovc-list');
++ (Array.isArray(b.items) ? b.items : []).slice(0, 50).forEach((x) => l.appendChild(make(doc, 'li', null, str(x, 280))));
++ return l;
++ }
++ case 'checklist': {
++ const l = make(doc, 'ul', 'ovc-check');
++ (Array.isArray(b.items) ? b.items : []).slice(0, 50).forEach((it) => {
++ if (!it || !ID.test(String(it.id || ''))) return;
++ const li = make(doc, 'li'), lab = make(doc, 'label'), box = make(doc, 'input');
++ box.type = 'checkbox'; box.checked = !!it.done;
++ if (typeof opts.onCheck === 'function' && ID.test(String(b.id || ''))) box.addEventListener('change', () => opts.onCheck(String(b.id), String(it.id), box.checked));
++ else box.disabled = true;
++ lab.appendChild(box); lab.appendChild(make(doc, 'span', null, str(it.text, 280)));
++ li.appendChild(lab); l.appendChild(li);
++ });
++ return l;
++ }
++ case 'table': {
++ const cols = (Array.isArray(b.columns) ? b.columns : []).slice(0, 8).map((c) => str(c, 40));
++ if (!cols.length) return null;
++ const wrap = make(doc, 'div', 'ovc-table');
++ wrap.tabIndex = 0;
++ wrap.setAttribute('role', 'region');
++ wrap.setAttribute('aria-label', cols.join(', '));
++ const t = make(doc, 'table'), head = make(doc, 'thead'), hr = make(doc, 'tr');
++ cols.forEach((c) => hr.appendChild(make(doc, 'th', null, c)));
++ head.appendChild(hr); t.appendChild(head);
++ const body = make(doc, 'tbody');
++ (Array.isArray(b.rows) ? b.rows : []).slice(0, 100).forEach((row) => {
++ const r = make(doc, 'tr');
++ cols.forEach((_, i) => { const v = Array.isArray(row) ? row[i] : ''; r.appendChild(make(doc, 'td', typeof v === 'number' ? 'is-num' : null, num(v))); });
++ body.appendChild(r);
++ });
++ t.appendChild(body); wrap.appendChild(t);
++ return wrap;
++ }
++ case 'metric': {
++ const m = make(doc, 'div', 'ovc-metric');
++ m.appendChild(make(doc, 'span', 'ovc-metric-l', str(b.label, 60)));
++ m.appendChild(make(doc, 'b', 'ovc-metric-v', str(b.value, 40)));
++ if (b.note) m.appendChild(make(doc, 'span', 'ovc-metric-n', str(b.note, 120)));
++ return m;
++ }
++ case 'chart': return chart(doc, b);
++ case 'image': {
++ if (!MEDIA.test(String(b.src || ''))) return null;
++ const img = make(doc, 'img', 'ovc-img');
++ img.alt = str(b.alt, 200); img.loading = 'lazy'; img.decoding = 'async'; img.referrerPolicy = 'no-referrer';
++ img.addEventListener('error', () => img.remove()); // a picture that is gone leaves no empty frame
++ img.src = b.src;
++ return img;
++ }
++ case 'link': {
++ const href = String(b.href || '');
++ if (!HTTPS.test(href)) return null;
++ let host = '';
++ try { host = new URL(href).host; } catch (e) { return null; }
++ const a = make(doc, 'a', 'ovc-link');
++ a.href = href; a.target = '_blank'; a.rel = 'noopener noreferrer nofollow';
++ a.appendChild(make(doc, 'b', null, str(b.text, 120)));
++ a.appendChild(make(doc, 'span', null, host));
++ return a;
++ }
++ case 'button': {
++ if (b.action !== 'ask') return null;
++ const label = str(b.label, 40);
++ const btn = make(doc, 'button', 'ovc-btn', label);
++ btn.type = 'button';
++ if (typeof opts.onAsk === 'function') {
++ btn.addEventListener('click', () => {
++ opts.onAsk(label, b);
++ btn.disabled = true; btn.textContent = 'Sent';
++ setTimeout(() => { btn.disabled = false; btn.textContent = label; }, 2500);
++ });
++ } else btn.disabled = true;
++ return btn;
++ }
++ default: return null; // a block this renderer does not know is left out, never guessed at
++ }
++ }
++
++ const drawn = new WeakMap(); // host → the version on screen
++ function render(host, doc, opts) {
++ if (!host || !valid(doc)) return null;
++ const o = opts || {};
++ const had = drawn.get(host);
++ if (had != null && doc.version < had) return had; // a late, older update never rolls the panel back
++ const d = host.ownerDocument || document;
++ styles(d);
++ const box = make(d, 'section', 'ovc');
++ box.setAttribute('aria-label', str(doc.title, 120) || 'Canvas');
++ const head = make(d, 'div', 'ovc-head');
++ head.appendChild(make(d, 'span', 'ovc-title', str(doc.title, 120)));
++ if (doc.updated_at) { const t = make(d, 'time', 'ovc-when', ago(doc.updated_at)); t.dateTime = String(doc.updated_at); head.appendChild(t); }
++ box.appendChild(head);
++ doc.blocks.slice(0, MAX_BLOCKS).forEach((b) => { const n = block(d, b, o); if (n) box.appendChild(n); });
++ host.replaceChildren(box);
++ drawn.set(host, doc.version);
++ return doc.version;
++ }
++
++ root.OVCanvas = { render, valid };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/package.json b/package.json
iindex e718411..2311dc9 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.144.0",
++ "version": "4.145.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -51,6 +51,7 @@
"./dock-vibe": "./dock-vibe.js",
"./dock-timer": "./dock-timer.js",
"./dock-share": "./dock-share.js",
++ "./ov-canvas": "./ov-canvas.js",
"./dock-agent": "./dock-agent.js",
"./dock-timeline": "./dock-timeline.js",
"./notification-ui": "./notification-ui.js",
ddiff --git a/test/ov-canvas-chrome.test.js b/test/ov-canvas-chrome.test.js
nnew file mode 100644
iindex 0000000..8aae76b
---- /dev/null
++++ b/test/ov-canvas-chrome.test.js
@@@ -0,0 +1,110 @@
++'use strict';
++// ov-canvas.js in real Chrome (actor.canvas@1, ADR-059): every block is drawn, nothing is markup, a picture comes from
++// openvibe.media only, a link only when it is https, a button only asks, a tick tells, an older version never rolls the
++// 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 fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('ov-canvas-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const ROOT = path.join(__dirname, '..');
++const DOC = {
++ canvas: 'actor.canvas@1', id: 'cnv_01JAB2C3D4E5F6G7H8J9K0MNPQ', title: 'Lisbon, 14–17 November', version: 3,
++ updated_at: new Date(Date.now() - 5 * 60000).toISOString(),
++ blocks: [
++ { type: 'heading', text: 'Lisbon, 14–17 November', level: 1 },
++ { type: 'metric', label: 'Flights and hotel', value: '€612', note: 'two people' },
++ { type: 'checklist', id: 'todo', items: [{ id: 'flights', text: 'Pick the flights', done: true }, { id: 'hotel', text: 'Choose a hotel', done: false }] },
++ { type: 'table', id: 'hotels', columns: ['Hotel', 'Night', 'Walk to the river', 'Breakfast', 'Cancellation', 'Rating'], rows: [['Casa A with a very long name indeed', 118, '6 min', 'included', 'free until Friday', 4.6], ['Hotel B', 96, '14 min', 'extra', 'none', 4.1]] },
++ { type: 'chart', id: 'prices', kind: 'line', unit: '€', series: [{ name: 'Flights', points: [{ x: 'Mon', y: 240 }, { x: 'Tue', y: 212 }, { x: 'Wed', y: 199 }] }] },
++ { type: 'chart', id: 'bars', kind: 'bar', series: [{ name: 'A', points: [{ x: 'Mon', y: 3 }] }, { name: 'B', points: [{ x: 'Mon', y: 5 }] }] },
++ { type: 'image', src: 'https://openvibe.media/t/lisbon.jpg', alt: 'The river at dusk' },
++ { type: 'image', src: 'https://tracker.example/p.png', alt: 'a tracking pixel' },
++ { type: 'link', href: 'https://openvibe.video/watch?v=AbCdEfGhIjK', text: 'The walking tour' },
++ { type: 'link', href: 'javascript:alert(1)', text: 'never drawn' },
++ { type: 'text', text: '<script>window.__xss = 1</script><img src=x onerror="window.__xss = 2">\nSecond line.' },
++ { type: 'html', html: '<b>never</b>' },
++ { type: 'button', label: 'Book Casa A', action: 'ask' },
++ { type: 'button', label: 'Delete everything', action: 'run' },
++ ],
++};
++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Canvas</title><link rel="icon" href="data:,"></head>
++<body style="margin:0;padding:16px;font:15px system-ui;background:#0a0f1c;color:#e6edf7"><div id="c"></div><div id="ro"></div>
++<script src="/ov-canvas.js"></script>
++<script>window.__asked = []; window.__checked = []; window.__DOC = ${JSON.stringify(DOC).replace(/</g, '\\u003c')};
++OVCanvas.render(document.getElementById('c'), window.__DOC, { onAsk: (l) => window.__asked.push(l), onCheck: (b, i, d) => window.__checked.push([b, i, d]) });
++OVCanvas.render(document.getElementById('ro'), window.__DOC);</script></body></html>`;
++
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }
++ if (u.pathname === '/ov-canvas.js') { res.writeHead(200, { 'content-type': 'application/javascript; charset=utf-8' }); return res.end(fs.readFileSync(path.join(ROOT, 'ov-canvas.js'))); }
++ res.writeHead(404); res.end();
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-canvas-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ try {
++ const p = await h.openPage(browser, await browser.newContext(), { width: 390 });
++ 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");
++ // 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)');
++ 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');
++ // 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 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 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');
++ // 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);
++ // 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');
++ 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'));
++ }
++ console.log('ov-canvas-chrome: every block, pictures from openvibe.media only, https links, text is text, a button asks, a tick tells, versions only grow, 390 px clean');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });