openvibers/openvibe.shared Commits
4.145.0: ov-canvas.js, an agent's live panel drawn the same on every site (#334)
Changes
@@@ -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@@@ -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 deployedconsumer 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` namesthe tag it pins.@@@ -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)",@@@ -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',nnew file mode 100644@@@ -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);@@@ -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",nnew file mode 100644@@@ -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); });
@@ -1,5 +1,16 @@
@@ -1,5 +1,16 @@
# Changelog
# 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
## 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
- **Send it on OpenVibe.** The share sheet (ov-share.js) offers **OpenVibe Chat** first to a signed-in person (the Dock
@@ -2,7 +2,7 @@
@@ -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.
> 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
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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,13 +4,14 @@
@@ -4,13 +4,14 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.144.0",
+ "version": "4.145.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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)",
"contracts": "openvibe-contracts v0.338.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
"features": [
"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)",
"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)",
"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)",
"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)",
@@ -26,6 +26,7 @@ const BROWSER = Object.freeze([
@@ -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-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-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
'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-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
'dock-timeline.js', // the Dock's one timeline (A6); dock.js loads it with the Agent panel
'account-switcher.js',
'account-switcher.js',
new file mode 100644
new file mode 100644
@@ -0,0 +1,275 @@
@@ -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);
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -51,6 +51,7 @@
@@ -51,6 +51,7 @@
"./dock-vibe": "./dock-vibe.js",
"./dock-vibe": "./dock-vibe.js",
"./dock-timer": "./dock-timer.js",
"./dock-timer": "./dock-timer.js",
"./dock-share": "./dock-share.js",
"./dock-share": "./dock-share.js",
+ "./ov-canvas": "./ov-canvas.js",
"./dock-agent": "./dock-agent.js",
"./dock-agent": "./dock-agent.js",
"./dock-timeline": "./dock-timeline.js",
"./dock-timeline": "./dock-timeline.js",
"./notification-ui": "./notification-ui.js",
"./notification-ui": "./notification-ui.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -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); });