openvibers/openvibe.shared ov-canvas.js
ov-canvas.js
| 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 | ; |
| 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 |