openvibers/openvibe.shared ov-canvas.js

ov-canvas.js

main 17.3 KB · 276 lines · javascript

1/**
2 * openvibe-shared/ov-canvas.js — an agent's live panel (actor.canvas@1, ADR-059, plan HO9), drawn the same on every
3 * site that shows it: Chat, Surf, the Dock and Actor.
4 *
5 * OVCanvas.render(host, doc, { onAsk(label, block), onCheck(blockId, itemId, done) }) → the version drawn
6 * OVCanvas.valid(doc) → true when doc is an actor.canvas@1 document this renderer can draw
7 *
8 * A canvas is DATA. Every block is built node by node (never innerHTML), text is plain, a picture is drawn only from
9 * openvibe.media and a link only when it is https — the contract says so, and this file checks it again, because what
10 * it draws was written by a model. A button never acts: it calls onAsk with its label, and the page sends that to the
11 * agent as the person's own message. A checklist tick calls onCheck. A page without the callback gets a canvas that
12 * only shows. An older version than the one on screen is ignored, so a late update never rolls the panel back.
13 * Charts are inline SVG with their numbers in a table for screen readers; a table scrolls inside its own box, so the
14 * page never scrolls sideways.
15 */
16(function (root) {
17 'use strict';
18 if (typeof document === 'undefined' || root.OVCanvas) return;
19
20 const MEDIA = /^https:\/\/openvibe\.media\/[A-Za-z0-9._~/%-]{1,1024}(\?[A-Za-z0-9._~=&%-]{0,200})?$/;
21 const HTTPS = /^https:\/\/[^\s"'<>]{1,2048}$/;
22 const ID = /^[a-z0-9][a-z0-9_-]{0,39}$/;
23 const MAX_BLOCKS = 60;
24 const CSS = `
25.ovc { --ovc-bg: var(--bg-secondary, #111a2c); --ovc-panel: var(--bg-tertiary, #16213a); --ovc-text: var(--text-primary, #e6edf7);
26 --ovc-muted: var(--text-secondary, #9fb0c8); --ovc-line: var(--border-color, rgba(255, 255, 255, .12)); --ovc-accent: var(--accent, #60a5fa);
27 --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;
28 display: grid; gap: 12px; min-width: 0; padding: 14px; border-radius: 14px; background: var(--ovc-bg); border: 1px solid var(--ovc-line);
29 color: var(--ovc-text); font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; }
30:root[data-theme-mode="light"] .ovc { --ovc-c2: #0e7490; --ovc-c3: #b45309; --ovc-c4: #6d28d9; }
31.ovc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
32.ovc-title { font-size: 15px; font-weight: 800; letter-spacing: -.01em; min-width: 0; }
33.ovc-when { flex: none; font-size: 12px; color: var(--ovc-muted); }
34.ovc h3, .ovc h4 { margin: 4px 0 0; line-height: 1.25; text-wrap: balance; }
35.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); }
36.ovc-text { margin: 0; white-space: pre-wrap; }
37.ovc-list { margin: 0; padding-left: 20px; display: grid; gap: 4px; }
38.ovc-check { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
39.ovc-check label { display: flex; align-items: flex-start; gap: 10px; min-height: 40px; padding: 8px 10px; border-radius: 10px; cursor: pointer; }
40.ovc-check label:hover { background: var(--ovc-panel); }
41.ovc-check input { flex: none; width: 18px; height: 18px; margin: 1px 0 0; accent-color: var(--ovc-accent); }
42.ovc-check input:checked + span { color: var(--ovc-muted); text-decoration: line-through; }
43.ovc-check input:disabled { cursor: default; }
44.ovc-table { max-width: 100%; overflow-x: auto; border-radius: 10px; border: 1px solid var(--ovc-line); }
45.ovc-table table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
46.ovc-table th, .ovc-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--ovc-line); white-space: nowrap; }
47.ovc-table th { font-size: 12px; font-weight: 800; color: var(--ovc-muted); background: var(--ovc-panel); }
48.ovc-table tr:last-child td { border-bottom: 0; }
49.ovc-table .is-num { text-align: right; font-variant-numeric: tabular-nums; }
50.ovc-metric { display: grid; gap: 2px; padding: 12px 14px; border-radius: 12px; background: var(--ovc-panel); }
51.ovc-metric-l { font-size: 12px; font-weight: 700; color: var(--ovc-muted); }
52.ovc-metric-v { font-size: 26px; font-weight: 850; letter-spacing: -.02em; line-height: 1.1; font-variant-numeric: tabular-nums; }
53.ovc-metric-n { font-size: 12.5px; color: var(--ovc-muted); }
54.ovc-chart { margin: 0; display: grid; gap: 6px; }
55.ovc-chart svg { width: 100%; max-width: 100%; height: auto; display: block; }
56.ovc-chart .ax { stroke: var(--ovc-line); stroke-width: 1; }
57.ovc-chart text { fill: var(--ovc-muted); font-size: 10px; font-family: inherit; }
58.ovc-chart figcaption { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 12px; color: var(--ovc-muted); }
59.ovc-chart figcaption i { display: inline-block; width: 10px; height: 10px; margin-right: 5px; border-radius: 3px; vertical-align: -1px; }
60.ovc-s1 { --c: var(--ovc-c1); } .ovc-s2 { --c: var(--ovc-c2); } .ovc-s3 { --c: var(--ovc-c3); } .ovc-s4 { --c: var(--ovc-c4); }
61.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; }
62.ovc-chart circle.v { fill: var(--c); } .ovc-chart figcaption i { background: var(--c); }
63.ovc-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
64.ovc-img { display: block; width: 100%; max-width: 100%; height: auto; border-radius: 12px; border: 1px solid var(--ovc-line); background: var(--ovc-panel); }
65.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; }
66.ovc-link:hover, .ovc-link:focus-visible { border-color: var(--ovc-accent); }
67.ovc-link b { font-weight: 700; color: var(--ovc-accent); } .ovc-link span { font-size: 12px; color: var(--ovc-muted); }
68.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);
69 font: inherit; font-weight: 750; cursor: pointer; }
70.ovc-btn:disabled { opacity: .55; cursor: default; }
71.ovc-btn:focus-visible, .ovc-check input:focus-visible, .ovc-table:focus-visible { outline: 2px solid var(--ovc-accent); outline-offset: 2px; }
72`;
73 function styles(doc) {
74 if (doc.getElementById('ov-canvas-css')) return;
75 const st = doc.createElement('style');
76 st.id = 'ov-canvas-css';
77 st.textContent = CSS;
78 (doc.head || doc.documentElement).appendChild(st);
79 }
80
81 const str = (v, n) => String(v == null ? '' : v).slice(0, n);
82 function make(doc, tag, cls, text) {
83 const n = doc.createElement(tag);
84 if (cls) n.className = cls;
85 if (text != null) n.textContent = text;
86 return n;
87 }
88 function ago(iso) {
89 const t = Date.parse(iso || '');
90 if (!Number.isFinite(t)) return '';
91 const m = Math.round((Date.now() - t) / 60000);
92 if (m < 1) return 'just now';
93 if (m < 60) return `${m} min ago`;
94 if (m < 1440) return `${Math.floor(m / 60)} h ago`;
95 return new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
96 }
97 const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v.toLocaleString() : str(v, 200));
98
99 function valid(doc) {
100 return !!(doc && typeof doc === 'object' && doc.canvas === 'actor.canvas@1' && Array.isArray(doc.blocks)
101 && Number.isInteger(doc.version) && doc.version >= 1);
102 }
103
104 /** A small chart: bars grouped by x, or one line per series. Nothing is fetched; the numbers are also a table. */
105 function chart(doc, b) {
106 const series = (Array.isArray(b.series) ? b.series : []).slice(0, 4)
107 .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))) }))
108 .filter((s) => s.points.length);
109 if (!series.length) return null;
110 const xs = [];
111 series.forEach((s) => s.points.forEach((p) => { const x = str(p.x, 20); if (!xs.includes(x)) xs.push(x); }));
112 const ys = series.flatMap((s) => s.points.map((p) => Number(p.y)));
113 // Bars stand on zero; a line spans its own range (a price moving from 240 to 199 must not look flat).
114 let lo = b.kind === 'line' ? Math.min(...ys) : Math.min(0, ...ys);
115 let hi = b.kind === 'line' ? Math.max(...ys) : (Math.max(0, ...ys) || 1);
116 if (b.kind === 'line') { const pad = (hi - lo) * 0.12 || Math.abs(hi) * 0.1 || 1; lo -= pad; hi += pad; }
117 const W = 320, H = 150, L = 34, R = 8, T = 8, B = 22;
118 const y = (v) => T + (H - T - B) * (1 - (v - lo) / (hi - lo || 1));
119 const step = (W - L - R) / xs.length;
120 const NS = 'http://www.w3.org/2000/svg';
121 const svg = doc.createElementNS(NS, 'svg');
122 svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
123 svg.setAttribute('role', 'img');
124 svg.setAttribute('aria-label', `${b.kind === 'line' ? 'Line' : 'Bar'} chart of ${series.map((s) => s.name).join(', ')}`);
125 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; };
126 const base = b.kind === 'line' ? lo : 0;
127 add('line', { class: 'ax', x1: L, x2: W - R, y1: y(base), y2: y(base) });
128 const unit = str(b.unit, 12);
129 const top = Math.max(...ys), bottom = Math.min(...ys);
130 add('text', { x: L - 4, y: y(top) + 4, 'text-anchor': 'end' }, `${top.toLocaleString()}${unit}`);
131 if (b.kind === 'line' ? bottom !== top : bottom < 0) add('text', { x: L - 4, y: y(bottom) + 4, 'text-anchor': 'end' }, `${bottom.toLocaleString()}${unit}`);
132 const every = Math.max(1, Math.ceil(xs.length / 8));
133 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)); });
134 series.forEach((s, k) => {
135 const cls = `v ovc-s${k + 1}`;
136 if (b.kind === 'line') {
137 const pts = s.points.map((p) => `${(L + step * (xs.indexOf(str(p.x, 20)) + .5)).toFixed(1)},${y(Number(p.y)).toFixed(1)}`);
138 add('polyline', { class: cls, points: pts.join(' ') });
139 if (s.points.length === 1) add('circle', { class: cls, cx: pts[0].split(',')[0], cy: pts[0].split(',')[1], r: 3.5 });
140 } else {
141 const bw = Math.max(2, (step * .7) / series.length);
142 s.points.forEach((p) => {
143 const i = xs.indexOf(str(p.x, 20)), v = Number(p.y);
144 const top = Math.min(y(v), y(0)), h = Math.max(1, Math.abs(y(v) - y(0)));
145 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 });
146 });
147 }
148 });
149 const fig = make(doc, 'figure', 'ovc-chart');
150 fig.appendChild(svg);
151 const cap = make(doc, 'figcaption');
152 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); });
153 fig.appendChild(cap);
154 // The numbers, for a screen reader (the picture says the same thing).
155 const t = make(doc, 'table', 'ovc-sr');
156 const hr = make(doc, 'tr'); hr.appendChild(make(doc, 'th', null, '')); series.forEach((s) => hr.appendChild(make(doc, 'th', null, s.name)));
157 t.appendChild(hr);
158 xs.forEach((x) => {
159 const r = make(doc, 'tr'); r.appendChild(make(doc, 'th', null, x));
160 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}` : '')); });
161 t.appendChild(r);
162 });
163 fig.appendChild(t);
164 return fig;
165 }
166
167 function block(doc, b, opts) {
168 if (!b || typeof b !== 'object') return null;
169 switch (b.type) {
170 case 'heading': return make(doc, b.level === 2 ? 'h4' : 'h3', null, str(b.text, 120));
171 case 'text': return make(doc, 'p', 'ovc-text', str(b.text, 4000));
172 case 'list': {
173 const l = make(doc, b.ordered ? 'ol' : 'ul', 'ovc-list');
174 (Array.isArray(b.items) ? b.items : []).slice(0, 50).forEach((x) => l.appendChild(make(doc, 'li', null, str(x, 280))));
175 return l;
176 }
177 case 'checklist': {
178 const l = make(doc, 'ul', 'ovc-check');
179 (Array.isArray(b.items) ? b.items : []).slice(0, 50).forEach((it) => {
180 if (!it || !ID.test(String(it.id || ''))) return;
181 const li = make(doc, 'li'), lab = make(doc, 'label'), box = make(doc, 'input');
182 box.type = 'checkbox'; box.checked = !!it.done;
183 if (typeof opts.onCheck === 'function' && ID.test(String(b.id || ''))) box.addEventListener('change', () => opts.onCheck(String(b.id), String(it.id), box.checked));
184 else box.disabled = true;
185 lab.appendChild(box); lab.appendChild(make(doc, 'span', null, str(it.text, 280)));
186 li.appendChild(lab); l.appendChild(li);
187 });
188 return l;
189 }
190 case 'table': {
191 const cols = (Array.isArray(b.columns) ? b.columns : []).slice(0, 8).map((c) => str(c, 40));
192 if (!cols.length) return null;
193 const wrap = make(doc, 'div', 'ovc-table');
194 wrap.tabIndex = 0;
195 wrap.setAttribute('role', 'region');
196 wrap.setAttribute('aria-label', cols.join(', '));
197 const t = make(doc, 'table'), head = make(doc, 'thead'), hr = make(doc, 'tr');
198 cols.forEach((c) => hr.appendChild(make(doc, 'th', null, c)));
199 head.appendChild(hr); t.appendChild(head);
200 const body = make(doc, 'tbody');
201 (Array.isArray(b.rows) ? b.rows : []).slice(0, 100).forEach((row) => {
202 const r = make(doc, 'tr');
203 cols.forEach((_, i) => { const v = Array.isArray(row) ? row[i] : ''; r.appendChild(make(doc, 'td', typeof v === 'number' ? 'is-num' : null, num(v))); });
204 body.appendChild(r);
205 });
206 t.appendChild(body); wrap.appendChild(t);
207 return wrap;
208 }
209 case 'metric': {
210 const m = make(doc, 'div', 'ovc-metric');
211 m.appendChild(make(doc, 'span', 'ovc-metric-l', str(b.label, 60)));
212 m.appendChild(make(doc, 'b', 'ovc-metric-v', str(b.value, 40)));
213 if (b.note) m.appendChild(make(doc, 'span', 'ovc-metric-n', str(b.note, 120)));
214 return m;
215 }
216 case 'chart': return chart(doc, b);
217 case 'image': {
218 if (!MEDIA.test(String(b.src || ''))) return null;
219 const img = make(doc, 'img', 'ovc-img');
220 img.alt = str(b.alt, 200); img.loading = 'lazy'; img.decoding = 'async'; img.referrerPolicy = 'no-referrer';
221 img.addEventListener('error', () => img.remove()); // a picture that is gone leaves no empty frame
222 img.src = b.src;
223 return img;
224 }
225 case 'link': {
226 const href = String(b.href || '');
227 if (!HTTPS.test(href)) return null;
228 let host = '';
229 try { host = new URL(href).host; } catch (e) { return null; }
230 const a = make(doc, 'a', 'ovc-link');
231 a.href = href; a.target = '_blank'; a.rel = 'noopener noreferrer nofollow';
232 a.appendChild(make(doc, 'b', null, str(b.text, 120)));
233 a.appendChild(make(doc, 'span', null, host));
234 return a;
235 }
236 case 'button': {
237 if (b.action !== 'ask') return null;
238 const label = str(b.label, 40);
239 const btn = make(doc, 'button', 'ovc-btn', label);
240 btn.type = 'button';
241 if (typeof opts.onAsk === 'function') {
242 btn.addEventListener('click', () => {
243 opts.onAsk(label, b);
244 btn.disabled = true; btn.textContent = 'Sent';
245 setTimeout(() => { btn.disabled = false; btn.textContent = label; }, 2500);
246 });
247 } else btn.disabled = true;
248 return btn;
249 }
250 default: return null; // a block this renderer does not know is left out, never guessed at
251 }
252 }
253
254 const drawn = new WeakMap(); // host → the version on screen
255 function render(host, doc, opts) {
256 if (!host || !valid(doc)) return null;
257 const o = opts || {};
258 const had = drawn.get(host);
259 if (had != null && doc.version < had) return had; // a late, older update never rolls the panel back
260 const d = host.ownerDocument || document;
261 styles(d);
262 const box = make(d, 'section', 'ovc');
263 box.setAttribute('aria-label', str(doc.title, 120) || 'Canvas');
264 const head = make(d, 'div', 'ovc-head');
265 head.appendChild(make(d, 'span', 'ovc-title', str(doc.title, 120)));
266 if (doc.updated_at) { const t = make(d, 'time', 'ovc-when', ago(doc.updated_at)); t.dateTime = String(doc.updated_at); head.appendChild(t); }
267 box.appendChild(head);
268 doc.blocks.slice(0, MAX_BLOCKS).forEach((b) => { const n = block(d, b, o); if (n) box.appendChild(n); });
269 host.replaceChildren(box);
270 drawn.set(host, doc.version);
271 return doc.version;
272 }
273
274 root.OVCanvas = { render, valid };
275})(typeof window !== 'undefined' ? window : globalThis);
276