openvibers/openvibe.shared Commits
4.96.0: one conversation UI for the network (openvibe-shared/thread) (#255)
Changes
@@@ -4,6 +4,23 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 4.96.0 — 2026-11-02++++- **One conversation UI for the network** (plan L55 C4). Actor's chat proved the shape; Vibe, Codes and the Dock use++ the same parts:++ - `openvibe-shared/thread` builds the markup: the chats in a sidebar (grouped Today / Yesterday / This week /++ Earlier), the sticky head, turns, the work folded under an answer, the answer with its sources and a footer, cards,++ and the composer. Complete without script, every string escaped.++ - `/shared/thread.css` draws it with theme tokens: a page that scrolls as a whole, a composer at the bottom above a++ phone's app bar, and the chats as a column or a drawer.++ - `/shared/ov-thread.js` adds the drawer, Find a chat, the growing composer, Enter to send, Copy, staying at the++ bottom, and `OVThread.add / replace / live / work` for streaming turns. A turn that finishes types its answer in++ word by word (`OVThread.reveal`, from Actor #227); code, tables and pictures come in whole.++ - The composer takes the site's markup above the box (`top`: picked files, a problem line) and a `maxlength`; Copy++ leaves out anything marked `data-ovt-nocopy` (a citation's number).++ - Nothing spins: a working turn breathes, and reduced motion stills it.++ - `docs/thread.md`; `test/thread.test.js`.++## 4.95.0 — 2026-11-02- **A box that scrolls can be scrolled from a keyboard** (WCAG 2.1.1). A code block, a wide table or a list held to a@@@ -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.95.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.96.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,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.95.0",++ "version": "4.96.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)",nnew file mode 100644@@@ -0,0 +1,49 @@++# One conversation UI: `openvibe-shared/thread`++++Plan L55 C4. Actor's chat proved the shape (L55 C1–C3). Vibe (openvibe.chat), Codes and the Dock use the same parts,++so a conversation looks and behaves the same on every OpenVibe site.++++- **Server:** `require('openvibe-shared/thread')` builds the markup. It is complete without script and every string is++ escaped. A field named `html` is markup the caller already made safe (an answer's rendered Markdown).++- **Look:** `/shared/thread.css`, theme tokens only.++- **Behaviour:** `/shared/ov-thread.js` adds the drawer, the search, the growing composer, Enter to send, Copy, staying++ at the bottom, and the helpers a site streams turns with.++++## The parts++++| Function | What it draws |++|---|---|++| `page({ sidebar, head, turns, empty, composer })` | The screen: the chats beside the conversation; the composer stays at the bottom |++| `sidebar({ newHref, groups, foot })` | New chat, Find a chat, the chats grouped Today / Yesterday / This week / Earlier, footer links. A column on a wide screen, a drawer on a phone |++| `group(items, whenOf)` | Items, newest first, into those groups |++| `head({ title, newHref, actions })` | The sticky head: the drawer's button (phones), the title, New chat |++| `turn({ id, state, open, you, reply })` | One turn: the person's bubble (with files) and the reply |++| `work({ open, live, took, rows })` | The work folded under the answer: "Worked for 12 s · 3 tools", or the live line while it works |++| `answer({ html, files, sources, meta, actions })` | The answer, its files, its sources as quiet links, and a footer with Copy |++| `card({ tone, title, text, html })` | A question waiting for the person (`wait`), a failure (`bad`), or a note (`info`) |++| `composer({ action, busy, stopAction, attach, tools, starters, note })` | The composer: grows as you type, the site's chips, Send, and Stop while a turn works |++++A `rows` entry for `work()` is one of:++- `{ kind: 'step', text }`;++- `{ kind: 'note', text, level }`;++- `{ kind: 'you', text }`, something the person said while it worked;++- `{ kind: 'tool', name, input, output, ok, running }`.++++## Streaming++++```js++OVThread.add(html); // append a new turn (a string from thread.turn, or an element)++OVThread.live(id, 'Reading'); // the working turn's live line++OVThread.work(id, rowHtml); // a row in its work list++OVThread.replace(id, html); // the finished turn; its answer types in, word by word++OVThread.reveal(answerEl); // the same reveal, for an answer a site draws itself++```++++A site renders those strings on the server (`GET /chat/turn/:id`, as Actor does) or in the page. The helpers keep the++thread at the bottom while the reader is there, and add Copy to new answers and code blocks.++++## Rules++++- **Nothing spins.** A working turn's avatar and its live line breathe, and `prefers-reduced-motion` stills them.++- **The page scrolls as a whole.** There is no inner scroll box to trap a thumb.++- **The composer works as a plain form.** Without script, Send posts and Stop posts to `stopAction`.@@@ -32,6 +32,7 @@ const BROWSER = Object.freeze(['ov-look.js', // the Look panel: themes, the season, more on openvibe.design (navbar.js loads it on first open)'ov-fx.js', // the network's effects (data-fx): glint, glow, lift, press, ripple, tilt, reveal, count, confetti'ov-labels.js', // content labels (L1): each viewer's show / blur / hide; navbar.js loads it when a page has [data-ov-labels]++ 'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)'history.js', // cross-site history'sso-client.js', // link hand-off, FedCM@@@ -69,6 +70,7 @@ const STYLES = Object.freeze(['player.css', // player.js's controls'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)'room.css', // kit.card, kit.rail and a room's accent (4.29.1; load after kit.css where cards, rails or sc.room() are)++ 'thread.css', // the conversation UI's look (openvibe-shared/thread, ov-thread.js; plan L55 C4)'kit-layout.css', // the fluid scale, the layout primitives (.ov-stack, .ov-switcher…) and the opt-in .ov-base (4.78.0; after kit.css)'kit-data.css', // Kit 2's look: steps, tables, charts, rankings, meters, drop zones, receipts (4.37.0; after kit.css)'agent-session.css', // agent-session.js's look (4.32.0; the script links it from beside itself unless the page does)nnew file mode 100644@@@ -0,0 +1,252 @@++/* openvibe-shared/ov-thread.js — the conversation UI's behaviour (plan L55 C4), for markup from openvibe-shared/thread++ * drawn with thread.css. The page works without it; with it:++ * - the chats open as a drawer on a phone (Esc or the scrim closes it), and the search box filters them;++ * - the composer grows as you type, Enter sends on a keyboard (Shift+Enter is a new line), Send waits for words;++ * - a starter fills the composer; Copy on each answer and on each code block;++ * - the thread opens at its bottom and stays there while you are there;++ * - the sticky head and the chats column sit under the navbar, and follow it when it steps away.++ * A site that streams turns uses window.OVThread:++ * OVThread.add(html) append a turn (string or element) to the page's thread++ * OVThread.replace(id, html) swap the turn data-turn="id" (its answer fades in)++ * OVThread.live(id, text) the working turn's live line ("Reading the page")++ * OVThread.work(id, html) append a row to the turn's work list++ * OVThread.enhance(el) Copy buttons for answers and code under el (add/replace call it)++ * OVThread.reveal(answerEl) type an answer in, word by word (replace does it when a working turn finishes)++ * Booted on load and after an in-place page swap (openvibe-shared/boost's ov:boost:load).++ */++(function (root) {++ 'use strict';++ if (root.OVThread) return;++ var fine = root.matchMedia && root.matchMedia('(hover: hover) and (pointer: fine)').matches;++ var reduce = root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches;++ var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);++++ function q(el, sel) { return el ? el.querySelector(sel) : null; }++ function qa(el, sel) { return el ? Array.prototype.slice.call(el.querySelectorAll(sel)) : []; }++ function frag(html) {++ if (typeof html !== 'string') return html;++ var t = document.createElement('template');++ t.innerHTML = html.trim();++ return t.content.firstElementChild;++ }++ function nearBottom() { return (root.innerHeight || 0) + (root.scrollY || 0) >= (document.documentElement.scrollHeight || 0) - 220; }++ function toBottom(smooth) { if (typeof root.scrollTo === 'function') root.scrollTo({ top: document.documentElement.scrollHeight, behavior: smooth && !reduce ? 'smooth' : 'auto' }); }++++ function copyText(text, btn) {++ if (!canCopy) return;++ navigator.clipboard.writeText(text).then(function () {++ var label = btn.querySelector('span') || btn;++ var was = label.textContent;++ label.textContent = 'Copied';++ setTimeout(function () { label.textContent = was; }, 1400);++ }).catch(function () { /* a refused clipboard changes nothing */ });++ }++++ /**++ * What a reader means by an answer's text: without the Copy buttons and anything marked data-ovt-nocopy (a citation's++ * number: "visits 12" was "visits 1 2"); the space before a removed mark goes when punctuation follows it. innerText++ * keeps line breaks only for laid-out nodes, so the copy is read while it sits, invisible, in the page (Actor's, 11-01).++ */++ function plainText(el) {++ var c = el.cloneNode(true);++ qa(c, '[data-ovt-nocopy], .ovt-code-copy').forEach(function (x) {++ var before = x.previousSibling, after = x.nextSibling;++ if (before && before.nodeType === 3 && (!after || (after.nodeType === 3 && /^[\s,.;:!?)]/.test(after.nodeValue)))) before.nodeValue = before.nodeValue.replace(/\s+$/, '');++ x.remove();++ });++ if (c.style) c.style.cssText = 'position:fixed;left:-10000px;top:0;width:' + (el.clientWidth || 600) + 'px;opacity:0;pointer-events:none';++ c.setAttribute('aria-hidden', 'true');++ document.body.appendChild(c);++ var t = String(c.innerText || c.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();++ c.remove();++ return t;++ }++++ /** Copy on answers and code blocks under el (once each). */++ function enhance(el) {++ if (!el) return el;++ qa(el, '[data-ovt-copy]').forEach(function (b) {++ if (b.dataset.ovtReady) return;++ b.dataset.ovtReady = '1';++ if (!canCopy) return;++ b.hidden = false;++ b.addEventListener('click', function () {++ var turn = b.closest('.ovt-turn');++ var ans = q(turn, '[data-ovt-answer]');++ if (!ans) return;++ // What a reader means to copy: not the Copy buttons, nor anything the site marked data-ovt-nocopy (a citation's number).++ copyText(plainText(ans), b);++ });++ });++ if (canCopy) qa(el, '.ovt-answer pre').forEach(function (pre) {++ if (pre.querySelector('.ovt-code-copy')) return;++ var b = document.createElement('button');++ b.type = 'button';++ b.className = 'ovt-code-copy';++ b.textContent = 'Copy';++ b.addEventListener('click', function () { var code = pre.querySelector('code') || pre; copyText(code.innerText.replace(/\nCopy$/, ''), b); });++ pre.appendChild(b);++ });++ return el;++ }++++ /**++ * The answer types in (the owner, 2026-11-01: "text streams in, with nice effects"). An answer that arrives whole++ * is revealed word by word, fast (about 45 frames, whatever its length); code, tables, pictures and anything marked++ * data-ovt-whole come in whole as the words reach them. Only opacity changes, so a screen reader reads it all at once,++ * and reduced motion shows it at once.++ */++ function reveal(ans) {++ if (!ans || reduce || !document.createTreeWalker) return;++ var WHOLE = 'pre, table, img, figure, [data-ovt-whole]';++ var words = [];++ var walker = document.createTreeWalker(ans, 4, { acceptNode: function (n) { return n.parentNode.closest(WHOLE) ? 2 : (/\S/.test(n.nodeValue) ? 1 : 2); } });++ var nodes = [];++ while (walker.nextNode()) nodes.push(walker.currentNode);++ nodes.forEach(function (n) {++ var f = document.createDocumentFragment();++ n.nodeValue.split(/(\s+)/).forEach(function (part) {++ if (!part) return;++ if (/^\s+$/.test(part)) { f.appendChild(document.createTextNode(part)); return; }++ var w = document.createElement('span'); w.className = 'ovt-sw'; w.textContent = part; f.appendChild(w); words.push(w);++ });++ n.parentNode.replaceChild(f, n);++ });++ var blocks = qa(ans, WHOLE).filter(function (b) { return !b.parentNode.closest(WHOLE); });++ blocks.forEach(function (b) { b.classList.add('ovt-sw'); });++ var all = words.concat(blocks);++ if (!all.length) return;++ ans.classList.add('is-revealing');++ var per = Math.max(2, Math.ceil(words.length / 45)), i = 0;++ var raf = root.requestAnimationFrame || function (fn) { return setTimeout(fn, 16); };++ (function step() {++ for (var k = 0; k < per && i < words.length; k++, i++) words[i].classList.add('on');++ var last = words[Math.max(0, i - 1)];++ blocks.forEach(function (b) { if (!b.classList.contains('on') && last && (b.compareDocumentPosition(last) & 4)) b.classList.add('on'); });++ if (i < words.length) raf(step);++ else { all.forEach(function (w) { w.classList.add('on'); }); setTimeout(function () { ans.classList.remove('is-revealing'); }, 300); }++ })();++ }++++ function threadEl() { return document.querySelector('[data-ovt] [data-ovt-thread]'); }++ function turnEl(id) { return document.querySelector('[data-ovt] [data-turn="' + String(id).replace(/"/g, '') + '"]'); }++++ var api = {++ enhance: enhance,++ reveal: reveal,++ add: function (html) {++ var list = threadEl();++ var el = frag(html);++ if (!list || !el) return null;++ var stay = nearBottom();++ el.classList.add('is-new');++ list.appendChild(el);++ var empty = q(list.closest('[data-ovt]'), '.ovt-empty-state');++ if (empty) empty.hidden = true;++ enhance(el);++ if (stay) toBottom(true);++ return el;++ },++ replace: function (id, html) {++ var old = turnEl(id);++ var el = frag(html);++ if (!old || !el) return null;++ var stay = nearBottom();++ var finished = !el.hasAttribute('data-open') && old.hasAttribute('data-open');++ old.replaceWith(el);++ enhance(el);++ // A turn that just finished: its answer types in (or, where motion is reduced or words cannot be split, fades).++ if (finished) { var ans = q(el, '[data-ovt-answer]'); if (ans && !reduce && document.createTreeWalker) reveal(ans); else el.classList.add('is-fresh'); }++ if (stay) toBottom(false);++ return el;++ },++ live: function (id, text) {++ var line = q(turnEl(id), '[data-ovt-live]');++ if (line) line.textContent = String(text) + '…';++ },++ work: function (id, html) {++ var list = q(turnEl(id), '[data-ovt-work]');++ var el = frag(html);++ if (!list || !el) return null;++ var stay = nearBottom();++ list.appendChild(el);++ if (stay) toBottom(false);++ return el;++ },++ };++ root.OVThread = api;++++ function start() {++ qa(document, '[data-ovt]').forEach(function (box) {++ if (box.dataset.ovtReady) return;++ box.dataset.ovtReady = '1';++ var head = q(box, '[data-ovt-head]');++++ // Under the navbar: the sticky head and the chats column follow its bottom edge.++ var nav = document.querySelector('.openvibe-navbar') || document.getElementById('navbar-mount');++ var topWas = -1, ticking = false;++ function measure() {++ ticking = false;++ var b = nav && nav.getBoundingClientRect ? Math.max(0, Math.round(nav.getBoundingClientRect().bottom || 0)) : 0;++ if (b !== topWas) { topWas = b; box.style.setProperty('--ovt-top', b + 'px'); }++ if (head) head.classList.toggle('is-stuck', root.scrollY > 8);++ }++ function onScroll() { if (!ticking) { ticking = true; (root.requestAnimationFrame || setTimeout)(measure); } }++ measure();++ root.addEventListener('scroll', onScroll, { passive: true });++ root.addEventListener('resize', onScroll);++ setTimeout(measure, 400);++++ // The drawer on a phone.++ var opener = q(box, '[data-ovt-open]');++ var scrim = q(box, '.ovt-scrim');++ var closeBtn = q(box, '.ovt-side-close');++ function drawer(on) {++ box.classList.toggle('is-drawer', on);++ if (scrim) scrim.hidden = !on;++ if (closeBtn) closeBtn.hidden = !on;++ if (opener) opener.setAttribute('aria-expanded', on ? 'true' : 'false');++ if (on) { var f = q(box, '[data-ovt-find]'); if (f && fine) f.focus(); }++ else if (opener && document.activeElement && box.querySelector('.ovt-side').contains(document.activeElement)) opener.focus();++ }++ if (opener) opener.addEventListener('click', function () { drawer(true); });++ qa(box, '[data-ovt-close]').forEach(function (el) { el.addEventListener('click', function () { drawer(false); }); });++ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && box.classList.contains('is-drawer')) drawer(false); });++++ // Find a chat.++ var find = q(box, '[data-ovt-find]');++ if (find) find.addEventListener('input', function () {++ var term = find.value.trim().toLowerCase();++ qa(box, '.ovt-list li').forEach(function (li) { var a = li.querySelector('a'); li.hidden = !!term && (!a || (a.getAttribute('data-title') || '').indexOf(term) === -1); });++ qa(box, '.ovt-group').forEach(function (g) { var ul = g.nextElementSibling; g.hidden = !!ul && !ul.querySelector('li:not([hidden])'); });++ });++++ // The composer.++ var form = q(box, '[data-ovt-composer]');++ var text = q(box, '[data-ovt-text]');++ var send = q(box, '[data-ovt-send]');++ function grow() { if (!text) return; text.style.height = 'auto'; text.style.height = Math.min(text.scrollHeight, root.innerHeight * 0.4) + 'px'; }++ function syncSend() { if (send && text) send.disabled = !text.value.trim(); }++ if (text) {++ text.addEventListener('input', function () { grow(); syncSend(); });++ text.addEventListener('keydown', function (e) {++ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && fine && text.value.trim()) {++ e.preventDefault();++ if (form.requestSubmit) form.requestSubmit(send || undefined); else form.submit();++ }++ });++ grow();++ syncSend();++ }++ qa(box, '[data-ovt-starter]').forEach(function (b) {++ b.addEventListener('click', function () { if (!text) return; text.value = b.getAttribute('data-ovt-starter'); grow(); syncSend(); text.focus(); });++ });++++ enhance(box);++ if (!location.hash && q(box, '[data-ovt-thread] > li')) toBottom(false);++ });++ }++++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true }); else start();++ document.addEventListener('ov:boost:load', start);++})(typeof window !== 'undefined' ? window : globalThis);@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.95.0",++ "version": "4.96.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -98,7 +98,9 @@"./boost": "./boost.js","./rooms": "./rooms.js","./motion": "./motion.js",-- "./agent-layer": "./agent-layer.js"++ "./agent-layer": "./agent-layer.js",++ "./thread": "./thread.js",++ "./ov-thread": "./ov-thread.js"},"files": ["*.js",nnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++// openvibe-shared/thread and ov-thread.js (plan L55 C4): the conversation UI's markup is complete and escaped without++// script, and the script adds the drawer, the search, the composer's Send that waits for words, starters, Copy, and++// the helpers a site streams turns with.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { parseHTML } = require('linkedom');++const t = require('../thread');++++// ── The groups ─────────────────────────────────────────────────────────────────────────────────────────────────++const now = Date.parse('2026-11-02T12:00:00Z');++assert.strictEqual(t.groupOf('2026-11-02T08:00:00Z', now), 'Today');++assert.strictEqual(t.groupOf('2026-11-01T23:00:00Z', now), 'Yesterday');++assert.strictEqual(t.groupOf('2026-10-29T10:00:00Z', now), 'This week');++assert.strictEqual(t.groupOf('2026-10-01T10:00:00Z', now), 'Earlier');++assert.strictEqual(t.groupOf('not a date', now), 'Earlier');++const groups = t.group([{ at: '2026-11-02T09:00:00Z' }, { at: '2026-11-02T07:00:00Z' }, { at: '2026-10-20T00:00:00Z' }], (c) => c.at, now);++assert.deepStrictEqual(groups.map((g) => [g.name, g.items.length]), [['Today', 2], ['Earlier', 1]]);++++// ── The markup ─────────────────────────────────────────────────────────────────────────────────────────────────++const side = t.sidebar({ newHref: '/chat', groups: [{ name: 'Today', items: [{ href: '/chat/1', title: 'Plan <b>a</b> weekend', current: true, live: true }, { href: '/chat/2', title: 'Lisbon' }] }], foot: [{ href: '/memory', label: 'Memory' }] });++assert.ok(side.includes('<a href="/chat/1" aria-current="page" data-title="plan <b>a</b> weekend"><span class="ovt-dot" aria-hidden="true"></span><span>Plan <b>a</b> weekend</span></a>'), 'escaped, current, live');++assert.ok(side.includes('class="ovt-new" href="/chat"') && side.includes('data-ovt-find') && side.includes('<a href="/memory">Memory</a>'));++assert.ok(t.sidebar({ groups: [] }).includes('Your chats show here.'), 'the empty line');++assert.ok(t.head({ title: 'A <title>', newHref: '/chat' }).includes('<h1 class="ovt-title">A <title></h1>'));++++const turn = t.turn({ id: 'tsk_1', state: 'running', open: true, you: { text: 'What <is> it?', files: [{ href: '/f/1', name: 'a.png', image: true }, { href: '/f/2', name: 'notes.txt', size: 2048 }] }, reply: t.work({ open: true, live: 'Reading the page', rows: [{ kind: 'step', text: 'Plan' }, { kind: 'tool', name: 'Page reader', input: 'https://x', running: true }] }) });++assert.ok(turn.startsWith('<li class="ovt-turn state-running" id="t-tsk_1" data-turn="tsk_1" data-state="running" data-open>'), turn.slice(0, 120));++assert.ok(turn.includes('<p class="ovt-bubble">What <is> it?</p>'), 'the person\'s words, escaped');++assert.ok(turn.includes('<img src="/f/1" alt="a.png" loading="lazy">') && turn.includes('<small>2 KB</small>'), 'a picture and a file');++assert.ok(turn.includes('<span class="ovt-w-live" data-ovt-live>Reading the page…</span>'), 'the live line while it works');++assert.ok(turn.includes('<details class="ovt-work" open data-open>'), 'open while it works');++assert.ok(turn.includes('class="ovt-w-tool is-run"'), 'a running tool');++++const done = t.work({ took: '12 s', rows: [{ kind: 'tool', name: 'MX', output: 'ok', ok: true }, { kind: 'tool', name: 'DMARC', output: 'no', ok: false }, { kind: 'note', text: 'Checked', level: 'warn' }] });++assert.ok(done.includes('<span>Worked for 12 s · 2 tools</span>'), 'the folded line');++assert.ok(done.includes('ovt-w-tool is-ok') && done.includes('ovt-w-tool is-bad') && done.includes('ovt-w-note is-warn'));++assert.strictEqual(t.work({ rows: [] }), '', 'nothing to fold, nothing drawn');++++const ans = t.answer({ html: '<p>The <b>answer</b></p>', sources: ['https://www.example.com/a', 'not a url'], meta: ['Balanced · DeepSeek', '$0.002'], actions: [{ label: 'Details', href: '/tasks/1?view=details' }] });++assert.ok(ans.includes('<div class="ovt-answer" data-ovt-answer><p>The <b>answer</b></p></div>'), 'the answer is the caller\'s safe markup');++assert.ok(ans.includes('>example.com</a>') && ans.includes('>not a url</a>'), 'sources by host');++assert.ok(ans.includes('<button type="button" class="ovt-act" data-ovt-copy hidden>'), 'Copy waits for the script');++assert.ok(ans.includes('<span>Balanced · DeepSeek</span><span>$0.002</span>') && ans.includes('href="/tasks/1?view=details"'));++assert.ok(t.card({ tone: 'bad', title: 'Could not finish', text: 'It <ran> too long.' }).includes('<div class="ovt-card is-bad"><p><b>Could not finish</b> It <ran> too long.</p>'));++++const comp = t.composer({ action: '/chat', placeholder: 'Ask anything', hidden: { parent: 'tsk_1', empty: '' }, starters: ['Plan a weekend'], busy: true, stopAction: '/chat/tsk_1/stop', attach: { name: 'files', accept: 'image/*' }, note: 'Steers the turn' });++assert.ok(comp.includes('<input type="hidden" name="parent" value="tsk_1">') && !comp.includes('name="empty"'), 'hidden fields, empty ones left out');++assert.ok(comp.includes('data-ovt-starter="Plan a weekend"'));++assert.ok(comp.includes('formaction="/chat/tsk_1/stop"') && comp.includes('class="ovt-composer is-busy"'), 'Stop while it works');++assert.ok(comp.includes('enctype="multipart/form-data"') && comp.includes('accept="image/*"'), 'attach');++assert.ok(!/data-ovt-text required/.test(comp), 'a busy composer may send nothing (it steers)');++assert.ok(/<button type="submit" class="ovt-stop" hidden data-ovt-stop>/.test(t.composer({ action: '/chat', stopAction: '' })), 'Stop drawn hidden for a script to show');++assert.ok(!t.composer({ action: '/chat' }).includes('ovt-stop'), 'no Stop when the site has none');++assert.ok(/data-ovt-text required/.test(t.composer({ action: '/chat' })), 'otherwise it needs words');++const topped = t.composer({ action: '/chat', top: '<ul class="picked"></ul>', maxlength: 20000 });++assert.ok(topped.includes('<ul class="picked"></ul><label class="ovt-sr" for="ovt-text">') && topped.includes('maxlength="20000"'), 'a slot above the box, and its limit');++++const page = t.page({ sidebar: side, head: t.head({ title: 'Chat' }), turns: [], empty: '<h2>What can I do for you?</h2>', composer: t.composer({ action: '/chat', starters: ['One'] }) });++assert.ok(page.startsWith('<div class="ovt" data-ovt>') && page.includes('<div class="ovt-empty-state"><h2>What can I do for you?</h2></div><ol class="ovt-thread" data-ovt-thread></ol>'));++++// ── The script ─────────────────────────────────────────────────────────────────────────────────────────────────++const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-thread.js'), 'utf8');++const html = `<!doctype html><html><body>${t.page({ sidebar: side, head: t.head({ title: 'Chat' }), turns: [turn, t.turn({ id: 'tsk_0', state: 'succeeded', you: { text: 'Hi' }, reply: ans })], composer: t.composer({ action: '/chat', starters: ['Plan a weekend'] }) })}</body></html>`;++const { window, document } = parseHTML(html);++delete globalThis.OVThread;++const copied = [];++// Node has a global navigator of its own (no clipboard): the script gets one with a clipboard to write to.++const nav = { clipboard: { writeText: (x) => { copied.push(x); return Promise.resolve(); } } };++new Function('window', 'document', 'navigator', 'location', 'setTimeout', SRC)(window, document, nav, { hash: '' }, setTimeout);++const OV = window.OVThread;++assert.ok(OV && typeof OV.add === 'function', 'window.OVThread');++const box = document.querySelector('[data-ovt]');++assert.strictEqual(box.dataset.ovtReady, '1');++++// The drawer.++document.querySelector('[data-ovt-open]').dispatchEvent(new window.Event('click'));++assert.ok(box.classList.contains('is-drawer') && document.querySelector('.ovt-scrim').hidden === false, 'opens');++assert.strictEqual(document.querySelector('[data-ovt-open]').getAttribute('aria-expanded'), 'true');++const esc = new window.Event('keydown'); esc.key = 'Escape'; document.dispatchEvent(esc);++assert.ok(!box.classList.contains('is-drawer'), 'Esc closes');++++// Find a chat.++const find = document.querySelector('[data-ovt-find]');++find.value = 'lisbon'; find.dispatchEvent(new window.Event('input'));++const items = [...document.querySelectorAll('.ovt-list li')];++assert.deepStrictEqual(items.map((li) => li.hidden), [true, false], 'only the matching chat shows');++++// The composer: Send waits for words; a starter fills it.++const send = document.querySelector('[data-ovt-send]');++assert.strictEqual(send.disabled, true, 'nothing to send');++document.querySelector('[data-ovt-starter]').dispatchEvent(new window.Event('click'));++assert.strictEqual(document.querySelector('[data-ovt-text]').value, 'Plan a weekend');++assert.strictEqual(send.disabled, false);++++// Copy shows where the clipboard can be written, and copies the answer.++const copyBtn = document.querySelector('[data-turn="tsk_0"] [data-ovt-copy]');++assert.strictEqual(copyBtn.hidden, false, 'Copy shows');++copyBtn.dispatchEvent(new window.Event('click'));++assert.strictEqual(copied.length, 1);++assert.ok(!/Copy$/.test(copied[0]), 'not the Copy buttons\' own words');++++// Streaming helpers.++OV.live('tsk_1', 'Checking the answer');++assert.strictEqual(document.querySelector('[data-turn="tsk_1"] [data-ovt-live]').textContent, 'Checking the answer…');++OV.work('tsk_1', '<li class="ovt-w-step"><span>Read two pages</span></li>');++assert.strictEqual(document.querySelectorAll('[data-turn="tsk_1"] [data-ovt-work] > li').length, 3);++const swapped = OV.replace('tsk_1', t.turn({ id: 'tsk_1', state: 'succeeded', you: { text: 'What <is> it?' }, reply: t.answer({ html: '<p>Done</p>' }) }));++const typed = swapped.querySelector('[data-ovt-answer]');++assert.ok(typed.classList.contains('is-revealing') || swapped.classList.contains('is-fresh'), 'a finished turn\'s answer types in (or fades in)');++if (typed.classList.contains('is-revealing')) assert.ok(typed.querySelectorAll('.ovt-sw').length >= 1 && typed.textContent.includes('Done'), 'word by word, every word still in the text');++assert.strictEqual(document.querySelector('[data-turn="tsk_1"] [data-ovt-copy]').hidden, false, 'the new turn has Copy');++const added = OV.add(t.turn({ id: 'tsk_2', open: true, you: { text: 'Next' }, reply: t.work({ open: true }) }));++assert.ok(added.classList.contains('is-new') && document.querySelectorAll('[data-ovt-thread] > li').length === 3, 'a new turn joins the thread');++++console.log('thread: all passed');nnew file mode 100644@@@ -0,0 +1,168 @@++/* openvibe-shared/thread.css — one conversation UI for the network (plan L55 C4), drawn by openvibe-shared/thread and++ ov-thread.js. The page scrolls as a whole (no inner box to trap a thumb); the composer stays at the bottom of the++ screen, above a phone's app bar; the chats are a column on a wide screen and a drawer on a phone. Theme tokens only,++ so every OpenVibe look applies. Nothing spins: a working turn breathes. */++.ovt { --ovt-top: 56px; --ovt-rail: 272px; --ovt-line: color-mix(in srgb, var(--border, #1f2d47) 80%, transparent);++ --ovt-accent: var(--accent, #3b82f6);++ display: grid; grid-template-columns: minmax(0, 1fr); min-height: calc(100dvh - var(--ovt-top)); color: var(--text-primary, #e6edf7); }++@media (min-width: 900px) { .ovt { grid-template-columns: var(--ovt-rail) minmax(0, 1fr); } }++/* The hidden attribute always wins inside the kit (a rule's display never shows what a page hid). */++.ovt [hidden] { display: none !important; }++.ovt-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++.ovt-muted { color: var(--text-muted, #7386a3); }++.ovt-ic { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }++++/* ── The chats ──────────────────────────────────────────────────────────────────────────────────────────────── */++.ovt-side { display: flex; flex-direction: column; gap: 10px; padding: 12px 10px 14px; background: var(--bg-secondary, #0f1729); border-right: 1px solid var(--ovt-line); }++@media (min-width: 900px) { .ovt-side { position: sticky; top: var(--ovt-top); height: calc(100dvh - var(--ovt-top)); overflow-y: auto; overscroll-behavior: contain; } }++@media (max-width: 899px) {++ .ovt-side { position: fixed; z-index: 60; inset: 0 auto 0 0; width: min(86vw, 320px); padding-top: calc(12px + env(safe-area-inset-top, 0px)); padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));++ overflow-y: auto; overscroll-behavior: contain; transform: translateX(-104%); visibility: hidden; transition: transform .26s cubic-bezier(.2, .8, .2, 1), visibility 0s .26s;++ box-shadow: 0 0 40px rgba(0, 0, 0, .45); }++ .ovt.is-drawer .ovt-side { transform: none; visibility: visible; transition: transform .26s cubic-bezier(.2, .8, .2, 1); }++ .ovt-scrim { position: fixed; inset: 0; z-index: 59; background: rgba(0, 0, 0, .5); }++ .ovt-side-close { display: grid !important; }++}++.ovt-scrim[hidden] { display: none; }++.ovt-side-head { display: flex; align-items: center; gap: 8px; }++.ovt-new { flex: 1; display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--ovt-line);++ background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); font-weight: 650; text-decoration: none; }++.ovt-new:hover, .ovt-new:focus-visible { border-color: var(--ovt-accent); }++.ovt-side-close { display: none; place-items: center; width: 42px; height: 42px; border-radius: 12px; border: 1px solid var(--ovt-line); background: none; color: var(--text-secondary, #96a7c2); cursor: pointer; }++.ovt-find input { width: 100%; box-sizing: border-box; min-height: 40px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--ovt-line); background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: inherit; font-size: 14px; }++.ovt-list { flex: 1; min-height: 0; }++.ovt-list ul { list-style: none; margin: 0 0 6px; padding: 0; }++.ovt-group { margin: 12px 8px 4px; font-size: 11.5px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.ovt-list a { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 10px; border-radius: 10px; color: var(--text-secondary, #b4c0d4); text-decoration: none; font-size: 14.5px; }++.ovt-list a span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovt-list a:hover, .ovt-list a:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 5%, transparent)); color: var(--text-primary, #e6edf7); }++.ovt-list a[aria-current=page] { background: color-mix(in srgb, var(--ovt-accent) 16%, transparent); color: var(--text-primary, #e6edf7); font-weight: 600; }++.ovt-list li[hidden], .ovt-group[hidden] { display: none; }++.ovt-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--ovt-accent); animation: ovt-breathe 1.6s ease-in-out infinite; }++.ovt-empty { margin: 14px 10px; font-size: 14px; color: var(--text-muted, #7386a3); }++.ovt-side-foot { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 0; padding: 10px 8px 0; border-top: 1px solid var(--ovt-line); font-size: 13px; }++.ovt-side-foot a { color: var(--text-secondary, #96a7c2); text-decoration: none; }++.ovt-side-foot a:hover { color: var(--text-primary, #e6edf7); }++++/* ── The conversation ───────────────────────────────────────────────────────────────────────────────────────── */++.ovt-main { display: flex; flex-direction: column; min-width: 0; min-height: calc(100dvh - var(--ovt-top) - var(--ovnav-appbar-h, 0px)); }++.ovt-head { position: sticky; top: var(--ovt-top); z-index: 20; display: flex; align-items: center; gap: 8px; min-height: 52px;++ padding: 6px max(12px, env(safe-area-inset-right, 0px)) 6px max(12px, env(safe-area-inset-left, 0px));++ background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 88%, transparent); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-bottom: 1px solid transparent; }++.ovt-head.is-stuck { border-bottom-color: var(--ovt-line); }++.ovt-title { flex: 1; min-width: 0; margin: 0; font-size: 16px; font-weight: 650; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovt-btn { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; border: 0; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; text-decoration: none; }++.ovt-btn:hover, .ovt-btn:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 6%, transparent)); color: var(--text-primary, #e6edf7); }++.ovt-btn .ovt-ic { width: 22px; height: 22px; }++@media (min-width: 900px) { .ovt-menu { display: none; } }++.ovt-scroll { flex: 1; padding: 8px max(16px, env(safe-area-inset-right, 0px)) 16px max(16px, env(safe-area-inset-left, 0px)); }++.ovt-thread { list-style: none; max-width: 780px; margin: 0 auto; padding: 0; display: grid; gap: 30px; }++.ovt-empty-state { max-width: 780px; margin: 8vh auto 0; text-align: center; }++.ovt-empty-state:empty { display: none; }++++.ovt-turn { display: grid; gap: 14px; scroll-margin-top: calc(var(--ovt-top) + 60px); }++.ovt-you { display: grid; justify-items: end; gap: 8px; }++.ovt-bubble { max-width: min(85%, 620px); margin: 0; padding: 10px 15px; border-radius: 20px 20px 6px 20px; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.5;++ background: color-mix(in srgb, var(--ovt-accent) 14%, var(--bg-card, #131c2e)); color: var(--text-primary, #e6edf7); }++.ovt-turn.is-new .ovt-bubble { animation: ovt-rise .32s cubic-bezier(.2, .8, .2, 1) both; }++.ovt-bot { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 12px; align-items: start; }++.ovt-bot:not(:has(.ovt-avatar)) { grid-template-columns: minmax(0, 1fr); }++.ovt-avatar { width: 28px; height: 28px; margin-top: 2px; border-radius: 9px;++ background: radial-gradient(circle at 30% 30%, color-mix(in srgb, var(--ovt-accent) 70%, #fff), var(--ovt-accent) 55%, color-mix(in srgb, var(--ovt-accent) 60%, #000));++ box-shadow: 0 0 0 1px color-mix(in srgb, var(--ovt-accent) 35%, transparent); }++.ovt-turn[data-open] .ovt-avatar { animation: ovt-breathe 1.6s ease-in-out infinite; }++.ovt-reply { min-width: 0; display: grid; gap: 10px; }++/* Grid items size to their content by default: a tool row's long input would widen the page. */++.ovt-reply > *, .ovt-work, .ovt-w-tool > details { min-width: 0; max-width: 100%; }++.ovt-w-tool > details > summary { min-width: 0; }++++/* The work, folded: "Worked for 12 s · 3 tools". Open while the turn works, so its live line shows. */++.ovt-work > summary { list-style: none; display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-height: 32px; padding: 0 10px 0 2px; border-radius: 10px; cursor: pointer;++ color: var(--text-secondary, #96a7c2); font-size: 14px; }++.ovt-work > summary::-webkit-details-marker { display: none; }++.ovt-work > summary:hover { color: var(--text-primary, #e6edf7); }++.ovt-chev { width: 16px; height: 16px; transition: transform .2s; }++.ovt-work[open] > summary .ovt-chev { transform: rotate(180deg); }++.ovt-w-live { background: linear-gradient(90deg, var(--text-secondary, #96a7c2) 40%, var(--text-primary, #e6edf7) 50%, var(--text-secondary, #96a7c2) 60%) 0 0 / 300% 100%;++ -webkit-background-clip: text; background-clip: text; color: transparent; animation: ovt-shimmer 2.4s linear infinite; }++.ovt-w-list { list-style: none; margin: 6px 0 0; padding: 0 0 0 12px; border-left: 2px solid var(--ovt-line); display: grid; gap: 6px; font-size: 14px; color: var(--text-secondary, #96a7c2); }++.ovt-w-list > li { display: flex; gap: 8px; align-items: flex-start; min-width: 0; }++.ovt-w-list > li > .ovt-ic { width: 16px; height: 16px; margin-top: 2px; }++.ovt-w-list p { margin: 0; min-width: 0; overflow-wrap: anywhere; }++.ovt-w-note.is-warn { color: var(--warning-text, #fbbf24); }++.ovt-w-tool { display: block !important; }++.ovt-w-tool > details > summary { list-style: none; display: flex; align-items: center; gap: 8px; cursor: pointer; min-height: 28px; }++.ovt-w-tool > details > summary::-webkit-details-marker { display: none; }++.ovt-w-tool b { flex: none; font-weight: 600; color: var(--text-primary, #e6edf7); white-space: nowrap; }++.ovt-w-in { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #7386a3); }++.ovt-w-st { margin-left: auto; display: inline-flex; }++.ovt-w-tool.is-ok .ovt-w-st { color: var(--success-text, #4ade80); }++.ovt-w-tool.is-bad .ovt-w-st { color: var(--danger-text, #f87171); }++.ovt-w-out { margin: 6px 0 4px 24px; max-height: 260px; overflow: auto; padding: 8px 10px; border-radius: 8px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--ovt-line);++ font: 12.5px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }++++/* The answer: comfortable reading, code with Copy, tables that scroll inside themselves. */++.ovt-answer { line-height: 1.65; font-size: 16px; overflow-wrap: anywhere; }++.ovt-answer > :first-child { margin-top: 0; }++.ovt-answer > :last-child { margin-bottom: 0; }++.ovt-answer pre { position: relative; overflow-x: auto; padding: 12px 14px; border-radius: 12px; background: var(--bg-secondary, #0f1729); border: 1px solid var(--ovt-line); font-size: 13.5px; }++.ovt-answer table { display: block; overflow-x: auto; border-collapse: collapse; max-width: 100%; }++.ovt-answer th, .ovt-answer td { padding: 6px 10px; border-bottom: 1px solid var(--ovt-line); text-align: left; }++.ovt-answer a { color: var(--accent-light, var(--ovt-accent)); }++.ovt-turn.is-fresh .ovt-answer { animation: ovt-fade .4s ease-out both; }++/* An answer that types in (ov-thread.js reveal): only opacity, so a screen reader reads it all at once. */++.ovt-answer.is-revealing .ovt-sw { opacity: 0; transition: opacity .28s ease-out; }++.ovt-answer.is-revealing .ovt-sw.on { opacity: 1; }++html[data-ov-motion=reduced] .ovt-answer.is-revealing .ovt-sw { opacity: 1 !important; transition: none !important; }++.ovt-answer pre:has(.ovt-code-copy) { padding-right: 68px; }++.ovt-code-copy { position: absolute; top: 6px; right: 6px; padding: 2px 8px; border-radius: 8px; border: 1px solid var(--ovt-line); background: var(--bg-card, #131c2e); color: var(--text-secondary, #96a7c2); font-size: 12px; cursor: pointer; }++.ovt-files { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }++.ovt-files img { display: block; max-width: 220px; max-height: 180px; border-radius: 12px; border: 1px solid var(--ovt-line); object-fit: cover; }++.ovt-file { display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 12px; border: 1px solid var(--ovt-line); background: var(--bg-card, #131c2e); color: inherit; text-decoration: none; }++.ovt-file span { display: grid; }++.ovt-file small { color: var(--text-muted, #7386a3); }++.ovt-sources { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; }++.ovt-sources a { padding: 2px 10px; border-radius: 999px; border: 1px solid var(--ovt-line); color: var(--text-secondary, #96a7c2); font-size: 13px; text-decoration: none; }++.ovt-sources a:hover { color: var(--text-primary, #e6edf7); border-color: var(--ovt-accent); }++.ovt-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; font-size: 13px; color: var(--text-muted, #7386a3); }++.ovt-acts { display: inline-flex; gap: 4px; margin-left: auto; }++.ovt-act { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 10px; border-radius: 9px; border: 0; background: none; color: var(--text-secondary, #96a7c2); font: inherit; font-size: 13px; cursor: pointer; text-decoration: none; }++.ovt-act:hover, .ovt-act:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 6%, transparent)); color: var(--text-primary, #e6edf7); }++.ovt-act .ovt-ic { width: 16px; height: 16px; }++.ovt-card { padding: 12px 14px; border-radius: 14px; border: 1px solid var(--ovt-line); background: var(--bg-card, #131c2e); }++.ovt-card p { margin: 0 0 6px; }++.ovt-card > :last-child { margin-bottom: 0; }++.ovt-card.is-wait { border-color: color-mix(in srgb, var(--ovt-accent) 50%, var(--ovt-line)); }++.ovt-card.is-bad { border-color: color-mix(in srgb, var(--danger-text, #f87171) 50%, var(--ovt-line)); }++++/* ── The composer ───────────────────────────────────────────────────────────────────────────────────────────── */++.ovt-dock { position: sticky; bottom: var(--ovnav-appbar-h, 0px); z-index: 15; padding: 8px max(12px, env(safe-area-inset-right, 0px)) calc(10px + env(safe-area-inset-bottom, 0px)) max(12px, env(safe-area-inset-left, 0px));++ background: linear-gradient(to top, var(--bg-primary, #0a0f1c) 70%, transparent); }++.ovt-starters { display: flex; flex-wrap: wrap; gap: 8px; max-width: 780px; margin: 0 auto 10px; }++.ovt-starter { padding: 8px 12px; border-radius: 12px; border: 1px solid var(--ovt-line); background: var(--bg-card, #131c2e); color: var(--text-secondary, #b4c0d4); font: inherit; font-size: 14px; text-align: left; cursor: pointer; }++.ovt-starter:hover, .ovt-starter:focus-visible { border-color: var(--ovt-accent); color: var(--text-primary, #e6edf7); }++.ovt-composer { max-width: 780px; margin: 0 auto; padding: 10px 10px 8px 14px; border-radius: 22px; border: 1px solid var(--ovt-line); background: var(--bg-card, #131c2e);++ box-shadow: 0 8px 30px rgba(0, 0, 0, .25); }++.ovt-composer:focus-within { border-color: color-mix(in srgb, var(--ovt-accent) 60%, var(--ovt-line)); }++.ovt-composer textarea { display: block; width: 100%; box-sizing: border-box; min-height: 26px; max-height: 40vh; padding: 4px 0; border: 0; outline: 0; resize: none; background: none;++ color: var(--text-primary, #e6edf7); font: inherit; font-size: 16px; line-height: 1.5; }++.ovt-c-row { display: flex; align-items: center; gap: 6px; margin-top: 4px; }++.ovt-c-tools { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }++.ovt-send, .ovt-stop { flex: none; display: inline-grid; place-items: center; min-width: 40px; height: 40px; padding: 0 10px; border-radius: 14px; border: 0; cursor: pointer; font: inherit; font-weight: 650; }++.ovt-send { background: var(--ovt-accent); color: var(--on-accent, #fff); }++.ovt-send:disabled { opacity: .45; cursor: default; }++.ovt-stop { display: inline-flex; gap: 6px; background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 8%, transparent)); color: var(--text-primary, #e6edf7); }++.ovt-send .ovt-ic, .ovt-stop .ovt-ic { width: 18px; height: 18px; }++.ovt-c-note { margin: 6px 4px 0; font-size: 12.5px; color: var(--text-muted, #7386a3); }++.ovt-c-note:empty { display: none; }++++@keyframes ovt-breathe { 50% { opacity: .45; } }++@keyframes ovt-rise { from { opacity: 0; transform: translateY(8px); } }++@keyframes ovt-fade { from { opacity: 0; } }++@keyframes ovt-shimmer { to { background-position: -300% 0; } }++@media (prefers-reduced-motion: reduce) {++ .ovt-dot, .ovt-turn[data-open] .ovt-avatar, .ovt-w-live, .ovt-turn.is-new .ovt-bubble, .ovt-turn.is-fresh .ovt-answer { animation: none; }++ .ovt-answer.is-revealing .ovt-sw { opacity: 1 !important; transition: none !important; }++ .ovt-w-live { color: var(--text-secondary, #96a7c2); background: none; }++ .ovt-side, .ovt-chev { transition: none; }++}nnew file mode 100644@@@ -0,0 +1,183 @@++'use strict';++// ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════++// openvibe-shared/thread — one conversation UI for the network (plan L55 C4): the chats in a sidebar, the thread of++// turns, the work an agent did folded under its answer, and the composer. Actor's chat proved the shape (L55 C1–C3);++// Vibe (openvibe.chat), Codes and the Dock use the same parts, so a conversation looks and behaves the same on every++// site. Server-rendered and complete without script; ov-thread.js adds the drawer, the search, the growing composer,++// Enter to send, copy, staying at the bottom, and helpers to add and update turns as they stream. Draw with++// thread.css. Theme tokens only.++//++// const thread = require('openvibe-shared/thread');++// thread.page({++// sidebar: thread.sidebar({ newHref: '/chat', groups: thread.group(chats, (c) => c.at) .map(...) }),++// head: thread.head({ title: 'Plan a weekend' }),++// turns: [thread.turn({ id, you: { text }, reply: thread.work({...}) + thread.answer({...}) })],++// composer: thread.composer({ action: '/chat', placeholder: 'Ask anything' }),++// });++//++// Every string is escaped; a field named html is markup the caller already made safe (an answer's rendered Markdown).++// ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++const attr = (name, v) => (v == null || v === false ? '' : v === true ? ` ${name}` : ` ${name}="${esc(v)}"`);++const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };++++// Stroke icons, 24-unit grid, drawn in currentColor (thread.css sizes them).++const P = {++ plus: 'M12 5v14M5 12h14',++ close: 'M6 6l12 12M18 6L6 18',++ menu: 'M4 7h16M4 12h16M4 17h16',++ chev: 'M8 10l4 4 4-4',++ copy: 'M9 9h10v10H9zM5 15V5h10',++ more: 'M5 12h.01M12 12h.01M19 12h.01',++ tool: 'M14.7 6.3a4 4 0 0 0-5.4 5.4L4 17l3 3 5.3-5.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2.4-.6-.6-2.4z',++ ok: 'M5 12l5 5 9-10',++ bad: 'M6 6l12 12M18 6L6 18',++ step: 'M9 6l6 6-6 6',++ note: 'M5 5h14v10H9l-4 4z',++ you: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 20a8 8 0 0 1 16 0',++ file: 'M7 3h7l5 5v13H7zM14 3v5h5',++ send: 'M5 12l14-7-5 14-2.5-5.5z',++ stop: 'M7 7h10v10H7z',++ attach: 'M16.5 6.5l-8 8a2.5 2.5 0 0 0 3.5 3.5l8-8a4.5 4.5 0 0 0-6.4-6.4l-8 8',++};++const icon = (name, cls = '') => `<svg class="ovt-ic${cls ? ` ${cls}` : ''}" viewBox="0 0 24 24" aria-hidden="true"><path d="${P[name] || P.note}"/></svg>`;++++/** Today / Yesterday / This week / Earlier, from an ISO time (or ms) and now. */++function groupOf(when, now = Date.now()) {++ const t = typeof when === 'number' ? when : Date.parse(when);++ if (!Number.isFinite(t)) return 'Earlier';++ const day = (x) => { const d = new Date(x); return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); };++ const days = Math.round((day(now) - day(t)) / 864e5);++ return days <= 0 ? 'Today' : days === 1 ? 'Yesterday' : days < 7 ? 'This week' : 'Earlier';++}++++/** Items (newest first) into the sidebar's groups: [{ name, items }]. `whenOf(item)` gives each one's time. */++function group(items, whenOf, now = Date.now()) {++ const out = [];++ for (const it of items || []) {++ const name = groupOf(whenOf(it), now);++ if (!out.length || out[out.length - 1].name !== name) out.push({ name, items: [] });++ out[out.length - 1].items.push(it);++ }++ return out;++}++++/**++ * The chats: New chat, a search box, the groups of links, and a footer of links. A column on a wide screen, a drawer++ * on a phone (opened by the head's menu button).++ * groups: [{ name, items: [{ href, title, current, live }] }]++ */++function sidebar({ label = 'Your chats', newHref = null, newLabel = 'New chat', find = true, groups = [], empty = 'Your chats show here.', foot = [] } = {}) {++ const list = groups.filter((g) => g && g.items && g.items.length);++ return `<aside class="ovt-side" id="ovt-chats" aria-label="${esc(label)}" data-ovt-side>++<div class="ovt-side-head">${newHref ? `<a class="ovt-new" href="${esc(newHref)}">${icon('plus')}<span>${esc(newLabel)}</span></a>` : ''}<button type="button" class="ovt-side-close" data-ovt-close aria-label="Close the chats" hidden>${icon('close')}</button></div>++${find ? `<label class="ovt-find"><span class="ovt-sr">Find a chat</span><input type="search" placeholder="Find a chat" data-ovt-find autocomplete="off"></label>` : ''}++<nav class="ovt-list">${list.length ? list.map((g) => `<p class="ovt-group">${esc(g.name)}</p><ul>${g.items.map((c) => `<li><a href="${esc(c.href)}"${c.current ? ' aria-current="page"' : ''} data-title="${esc(String(c.title || '').toLowerCase())}">${c.live ? '<span class="ovt-dot" aria-hidden="true"></span>' : ''}<span>${esc(clip(c.title, 80))}</span></a></li>`).join('')}</ul>`).join('') : `<p class="ovt-empty">${esc(empty)}</p>`}</nav>++${foot.length ? `<p class="ovt-side-foot">${foot.map((f) => `<a href="${esc(f.href)}">${esc(f.label)}</a>`).join('')}</p>` : ''}++</aside>++<div class="ovt-scrim" data-ovt-close hidden></div>`;++}++++/** The sticky head over the thread: the drawer's button (phones), the title, New chat, and the caller's actions. */++function head({ title = '', newHref = null, menu = true, actions = '' } = {}) {++ return `<header class="ovt-head" data-ovt-head>${menu ? `<button type="button" class="ovt-btn ovt-menu" data-ovt-open aria-controls="ovt-chats" aria-expanded="false" aria-label="Your chats">${icon('menu')}</button>` : ''}<h1 class="ovt-title">${esc(title)}</h1>${actions || ''}${newHref ? `<a class="ovt-btn" href="${esc(newHref)}" aria-label="New chat">${icon('plus')}</a>` : ''}</header>`;++}++++const bytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${Number(n) || 0} B`);++/** Files beside a message: a picture as a thumbnail, anything else as a named chip. files: [{ href, name, size, image }] */++function files(list = []) {++ if (!list || !list.length) return '';++ return `<ul class="ovt-files">${list.map((f) => `<li>${f.image++ ? `<a href="${esc(f.href)}" target="_blank" rel="noopener"><img src="${esc(f.href)}" alt="${esc(f.name || '')}" loading="lazy"></a>`++ : `<a class="ovt-file" href="${esc(f.href)}" target="_blank" rel="noopener">${icon('file')}<span><b>${esc(f.name || 'File')}</b>${f.size != null ? `<small>${bytes(f.size)}</small>` : ''}</span></a>`}</li>`).join('')}</ul>`;++}++++/**++ * One turn: what the person wrote (a bubble, with any files), and the reply (the caller's markup: work(), answer(),++ * card()…). open marks a turn still working (its avatar breathes; ov-thread.js follows it).++ */++function turn({ id = null, state = null, open = false, you = {}, reply = '', avatar = true, attrs = {} } = {}) {++ const extra = Object.entries(attrs || {}).map(([k, v]) => attr(k, v)).join('');++ const mine = you && (you.html != null || you.text != null || (you.files && you.files.length))++ ? `<div class="ovt-you">${files(you.files)}${you.html != null ? `<div class="ovt-bubble">${you.html}</div>` : you.text != null ? `<p class="ovt-bubble">${esc(you.text)}</p>` : ''}</div>` : '';++ return `<li class="ovt-turn${state ? ` state-${esc(state)}` : ''}"${attr('id', id ? `t-${id}` : null)}${attr('data-turn', id)}${attr('data-state', state)}${open ? ' data-open' : ''}${extra}>++${mine}<div class="ovt-bot">${avatar ? '<span class="ovt-avatar" aria-hidden="true"></span>' : ''}<div class="ovt-reply">${reply}</div></div>++</li>`;++}++++/**++ * The work, folded under the answer: "Worked for 12 s · 3 tools", or, while it works, the live line ("Reading the++ * page…"), open. rows: [{ kind: 'step' | 'note' | 'you' | 'tool', text, level, name, input, output, ok, running }].++ */++function work({ open = false, live = 'Working', took = null, rows = [] } = {}) {++ const tools = rows.filter((r) => r.kind === 'tool').length;++ if (!open && !rows.length) return '';++ const items = rows.map((r) => {++ if (r.kind === 'tool') {++ const st = r.running || r.ok == null ? 'is-run' : r.ok ? 'is-ok' : 'is-bad';++ return `<li class="ovt-w-tool ${st}"><details><summary>${icon('tool')}<b>${esc(r.name || 'Tool')}</b>${r.input ? `<span class="ovt-w-in">${esc(clip(r.input, 90))}</span>` : ''}<span class="ovt-w-st">${st === 'is-run' ? '<span class="ovt-dot" aria-hidden="true"></span>' : icon(st === 'is-ok' ? 'ok' : 'bad')}</span></summary>++${r.output != null ? `<pre class="ovt-w-out">${esc(clip(r.output, 1500))}</pre>` : '<p class="ovt-w-out ovt-muted">Running…</p>'}</details></li>`;++ }++ if (r.kind === 'you') return `<li class="ovt-w-you">${icon('you')}<p>${esc(clip(r.text, 300))}</p></li>`;++ if (r.kind === 'note') return `<li class="ovt-w-note${r.level ? ` is-${esc(r.level)}` : ''}">${icon('note')}<p>${esc(clip(r.text, 700))}</p></li>`;++ return `<li class="ovt-w-step">${icon('step')}<span>${esc(clip(r.text, 300))}</span></li>`;++ });++ const line = open++ ? `<span class="ovt-w-live" data-ovt-live>${esc(live)}…</span>`++ : `<span>${took ? `Worked for ${esc(took)}` : 'Worked'}${tools ? ` · ${tools} tool${tools === 1 ? '' : 's'}` : ''}</span>`;++ return `<details class="ovt-work"${open ? ' open data-open' : ''}><summary>${line}${icon('chev', 'ovt-chev')}</summary><ol class="ovt-w-list" data-ovt-work>${items.join('')}</ol></details>`;++}++++/**++ * The answer: rendered Markdown (html, already safe), files and charts, sources as quiet links, and a footer with what++ * it ran on and the actions (Copy is drawn hidden and shown by ov-thread.js, which can copy).++ * meta: ['Balanced · DeepSeek', '$0.002', '14 s']; actions: [{ label, href, icon }]++ */++function answer({ html = '', files: att = [], sources = [], meta = [], actions = [], copy = true } = {}) {++ const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return clip(u, 40); } };++ const acts = [++ copy ? `<button type="button" class="ovt-act" data-ovt-copy hidden>${icon('copy')}<span>Copy</span></button>` : '',++ ...actions.map((a) => `<a class="ovt-act" href="${esc(a.href)}">${icon(a.icon || 'more')}<span>${esc(a.label)}</span></a>`),++ ].join('');++ return `<div class="ovt-answer" data-ovt-answer>${html}</div>++${files(att)}++${sources.length ? `<p class="ovt-sources">${sources.slice(0, 8).map((u) => `<a href="${esc(u)}" rel="nofollow ugc noopener" target="_blank">${esc(host(u))}</a>`).join('')}</p>` : ''}++${meta.length || acts ? `<div class="ovt-meta">${meta.filter(Boolean).map((m) => `<span>${esc(m)}</span>`).join('')}<span class="ovt-acts">${acts}</span></div>` : ''}`;++}++++/** A card in the reply: a question waiting for the person (wait), a failure (bad), or a note (info). html is safe markup. */++function card({ tone = 'info', title = '', text = null, html = '' } = {}) {++ return `<div class="ovt-card is-${esc(tone)}">${title ? `<p><b>${esc(title)}</b>${text != null ? ` ${esc(text)}` : ''}</p>` : text != null ? `<p>${esc(text)}</p>` : ''}${html}</div>`;++}++++/**++ * The composer, at the bottom of the screen: a box that grows as you type, the caller's tools (a mode chip, a model++ * chip, attach), and Send, which becomes Stop while a turn works (busy). Works as a plain form without script.++ * hidden: { parent: 'tsk_…' }; starters: ['Plan a weekend in Lisbon', …] (shown on an empty chat)++ * top: the site's safe markup above the box (picked files, a problem line); maxlength: the box's limit++ * stopAction: where Stop posts while a turn works; '' draws Stop hidden, for a page whose script starts turns itself++ */++function composer({ action = '', method = 'post', name = 'text', placeholder = 'Message', value = '', busy = false, sendLabel = 'Send', stopAction = null, hidden = {}, tools = '', starters = [], note = '', attach = null, label = 'Message', top = '', maxlength = null } = {}) {++ const hid = Object.entries(hidden || {}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');++ return `${starters.length ? `<div class="ovt-starters">${starters.map((s) => `<button type="button" class="ovt-starter" data-ovt-starter="${esc(s)}">${esc(s)}</button>`).join('')}</div>` : ''}++<form class="ovt-composer${busy ? ' is-busy' : ''}" method="${esc(method)}" action="${esc(action)}" data-ovt-composer${attach ? ' enctype="multipart/form-data"' : ''}>${hid}++${top || ''}<label class="ovt-sr" for="ovt-text">${esc(label)}</label>++<textarea id="ovt-text" name="${esc(name)}" rows="1" placeholder="${esc(placeholder)}"${maxlength ? ` maxlength="${Number(maxlength) || ''}"` : ''} data-ovt-text${busy ? '' : ' required'}>${esc(value)}</textarea>++<div class="ovt-c-row"><div class="ovt-c-tools">${attach ? `<label class="ovt-btn ovt-attach" title="Attach files">${icon('attach')}<span class="ovt-sr">Attach files</span><input type="file" name="${esc(attach.name || 'files')}"${attach.accept ? ` accept="${esc(attach.accept)}"` : ''} multiple data-ovt-files hidden></label>` : ''}${tools || ''}</div>++${stopAction != null ? `<button type="submit" class="ovt-stop"${stopAction ? ` formaction="${esc(stopAction)}" formnovalidate` : ''}${busy && stopAction ? '' : ' hidden'} data-ovt-stop>${icon('stop')}<span>Stop</span></button>` : ''}<button type="submit" class="ovt-send" data-ovt-send aria-label="${esc(sendLabel)}">${icon('send')}<span class="ovt-sr">${esc(sendLabel)}</span></button></div>++${note ? `<p class="ovt-c-note" data-ovt-note>${esc(note)}</p>` : ''}++</form>`;++}++++/**++ * The whole screen: the sidebar and the conversation (head, the turns or the empty state, the composer). Wrap it in++ * the site's page; on a phone the composer stays at the bottom above the app bar.++ */++function page({ sidebar: side = '', head: top = '', turns = [], empty = '', composer: comp = '', label = 'Conversation' } = {}) {++ return `<div class="ovt" data-ovt>${side}<section class="ovt-main" aria-label="${esc(label)}">${top}++<div class="ovt-scroll">${turns.length ? `<ol class="ovt-thread" data-ovt-thread>${turns.join('')}</ol>` : `<div class="ovt-empty-state">${empty}</div><ol class="ovt-thread" data-ovt-thread></ol>`}</div>++<div class="ovt-dock">${comp}</div></section></div>`;++}++++module.exports = { page, sidebar, head, turn, work, answer, card, composer, files, group, groupOf, icon, esc };
@@ -4,6 +4,23 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,23 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 4.96.0 — 2026-11-02
+
+- **One conversation UI for the network** (plan L55 C4). Actor's chat proved the shape; Vibe, Codes and the Dock use
+ the same parts:
+ - `openvibe-shared/thread` builds the markup: the chats in a sidebar (grouped Today / Yesterday / This week /
+ Earlier), the sticky head, turns, the work folded under an answer, the answer with its sources and a footer, cards,
+ and the composer. Complete without script, every string escaped.
+ - `/shared/thread.css` draws it with theme tokens: a page that scrolls as a whole, a composer at the bottom above a
+ phone's app bar, and the chats as a column or a drawer.
+ - `/shared/ov-thread.js` adds the drawer, Find a chat, the growing composer, Enter to send, Copy, staying at the
+ bottom, and `OVThread.add / replace / live / work` for streaming turns. A turn that finishes types its answer in
+ word by word (`OVThread.reveal`, from Actor #227); code, tables and pictures come in whole.
+ - The composer takes the site's markup above the box (`top`: picked files, a problem line) and a `maxlength`; Copy
+ leaves out anything marked `data-ovt-nocopy` (a citation's number).
+ - Nothing spins: a working turn breathes, and reduced motion stills it.
+ - `docs/thread.md`; `test/thread.test.js`.
+
## 4.95.0 — 2026-11-02
## 4.95.0 — 2026-11-02
- **A box that scrolls can be scrolled from a keyboard** (WCAG 2.1.1). A code block, a wide table or a list held to a
- **A box that scrolls can be scrolled from a keyboard** (WCAG 2.1.1). A code block, a wide table or a list held to a
@@ -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.95.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.96.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,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.95.0",
+ "version": "4.96.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)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+# One conversation UI: `openvibe-shared/thread`
+
+Plan L55 C4. Actor's chat proved the shape (L55 C1–C3). Vibe (openvibe.chat), Codes and the Dock use the same parts,
+so a conversation looks and behaves the same on every OpenVibe site.
+
+- **Server:** `require('openvibe-shared/thread')` builds the markup. It is complete without script and every string is
+ escaped. A field named `html` is markup the caller already made safe (an answer's rendered Markdown).
+- **Look:** `/shared/thread.css`, theme tokens only.
+- **Behaviour:** `/shared/ov-thread.js` adds the drawer, the search, the growing composer, Enter to send, Copy, staying
+ at the bottom, and the helpers a site streams turns with.
+
+## The parts
+
+| Function | What it draws |
+|---|---|
+| `page({ sidebar, head, turns, empty, composer })` | The screen: the chats beside the conversation; the composer stays at the bottom |
+| `sidebar({ newHref, groups, foot })` | New chat, Find a chat, the chats grouped Today / Yesterday / This week / Earlier, footer links. A column on a wide screen, a drawer on a phone |
+| `group(items, whenOf)` | Items, newest first, into those groups |
+| `head({ title, newHref, actions })` | The sticky head: the drawer's button (phones), the title, New chat |
+| `turn({ id, state, open, you, reply })` | One turn: the person's bubble (with files) and the reply |
+| `work({ open, live, took, rows })` | The work folded under the answer: "Worked for 12 s · 3 tools", or the live line while it works |
+| `answer({ html, files, sources, meta, actions })` | The answer, its files, its sources as quiet links, and a footer with Copy |
+| `card({ tone, title, text, html })` | A question waiting for the person (`wait`), a failure (`bad`), or a note (`info`) |
+| `composer({ action, busy, stopAction, attach, tools, starters, note })` | The composer: grows as you type, the site's chips, Send, and Stop while a turn works |
+
+A `rows` entry for `work()` is one of:
+- `{ kind: 'step', text }`;
+- `{ kind: 'note', text, level }`;
+- `{ kind: 'you', text }`, something the person said while it worked;
+- `{ kind: 'tool', name, input, output, ok, running }`.
+
+## Streaming
+
+```js
+OVThread.add(html); // append a new turn (a string from thread.turn, or an element)
+OVThread.live(id, 'Reading'); // the working turn's live line
+OVThread.work(id, rowHtml); // a row in its work list
+OVThread.replace(id, html); // the finished turn; its answer types in, word by word
+OVThread.reveal(answerEl); // the same reveal, for an answer a site draws itself
+```
+
+A site renders those strings on the server (`GET /chat/turn/:id`, as Actor does) or in the page. The helpers keep the
+thread at the bottom while the reader is there, and add Copy to new answers and code blocks.
+
+## Rules
+
+- **Nothing spins.** A working turn's avatar and its live line breathe, and `prefers-reduced-motion` stills them.
+- **The page scrolls as a whole.** There is no inner scroll box to trap a thumb.
+- **The composer works as a plain form.** Without script, Send posts and Stop posts to `stopAction`.
@@ -32,6 +32,7 @@ const BROWSER = Object.freeze([
@@ -32,6 +32,7 @@ const BROWSER = Object.freeze([
'ov-look.js', // the Look panel: themes, the season, more on openvibe.design (navbar.js loads it on first open)
'ov-look.js', // the Look panel: themes, the season, more on openvibe.design (navbar.js loads it on first open)
'ov-fx.js', // the network's effects (data-fx): glint, glow, lift, press, ripple, tilt, reveal, count, confetti
'ov-fx.js', // the network's effects (data-fx): glint, glow, lift, press, ripple, tilt, reveal, count, confetti
'ov-labels.js', // content labels (L1): each viewer's show / blur / hide; navbar.js loads it when a page has [data-ov-labels]
'ov-labels.js', // content labels (L1): each viewer's show / blur / hide; navbar.js loads it when a page has [data-ov-labels]
+ 'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'history.js', // cross-site history
'history.js', // cross-site history
'sso-client.js', // link hand-off, FedCM
'sso-client.js', // link hand-off, FedCM
@@ -69,6 +70,7 @@ const STYLES = Object.freeze([
@@ -69,6 +70,7 @@ const STYLES = Object.freeze([
'player.css', // player.js's controls
'player.css', // player.js's controls
'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)
'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)
'room.css', // kit.card, kit.rail and a room's accent (4.29.1; load after kit.css where cards, rails or sc.room() are)
'room.css', // kit.card, kit.rail and a room's accent (4.29.1; load after kit.css where cards, rails or sc.room() are)
+ 'thread.css', // the conversation UI's look (openvibe-shared/thread, ov-thread.js; plan L55 C4)
'kit-layout.css', // the fluid scale, the layout primitives (.ov-stack, .ov-switcher…) and the opt-in .ov-base (4.78.0; after kit.css)
'kit-layout.css', // the fluid scale, the layout primitives (.ov-stack, .ov-switcher…) and the opt-in .ov-base (4.78.0; after kit.css)
'kit-data.css', // Kit 2's look: steps, tables, charts, rankings, meters, drop zones, receipts (4.37.0; after kit.css)
'kit-data.css', // Kit 2's look: steps, tables, charts, rankings, meters, drop zones, receipts (4.37.0; after kit.css)
'agent-session.css', // agent-session.js's look (4.32.0; the script links it from beside itself unless the page does)
'agent-session.css', // agent-session.js's look (4.32.0; the script links it from beside itself unless the page does)
new file mode 100644
new file mode 100644
@@ -0,0 +1,252 @@
@@ -0,0 +1,252 @@
+/* openvibe-shared/ov-thread.js — the conversation UI's behaviour (plan L55 C4), for markup from openvibe-shared/thread
+ * drawn with thread.css. The page works without it; with it:
+ * - the chats open as a drawer on a phone (Esc or the scrim closes it), and the search box filters them;
+ * - the composer grows as you type, Enter sends on a keyboard (Shift+Enter is a new line), Send waits for words;
+ * - a starter fills the composer; Copy on each answer and on each code block;
+ * - the thread opens at its bottom and stays there while you are there;
+ * - the sticky head and the chats column sit under the navbar, and follow it when it steps away.
+ * A site that streams turns uses window.OVThread:
+ * OVThread.add(html) append a turn (string or element) to the page's thread
+ * OVThread.replace(id, html) swap the turn data-turn="id" (its answer fades in)
+ * OVThread.live(id, text) the working turn's live line ("Reading the page")
+ * OVThread.work(id, html) append a row to the turn's work list
+ * OVThread.enhance(el) Copy buttons for answers and code under el (add/replace call it)
+ * OVThread.reveal(answerEl) type an answer in, word by word (replace does it when a working turn finishes)
+ * Booted on load and after an in-place page swap (openvibe-shared/boost's ov:boost:load).
+ */
+(function (root) {
+ 'use strict';
+ if (root.OVThread) return;
+ var fine = root.matchMedia && root.matchMedia('(hover: hover) and (pointer: fine)').matches;
+ var reduce = root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
+
+ function q(el, sel) { return el ? el.querySelector(sel) : null; }
+ function qa(el, sel) { return el ? Array.prototype.slice.call(el.querySelectorAll(sel)) : []; }
+ function frag(html) {
+ if (typeof html !== 'string') return html;
+ var t = document.createElement('template');
+ t.innerHTML = html.trim();
+ return t.content.firstElementChild;
+ }
+ function nearBottom() { return (root.innerHeight || 0) + (root.scrollY || 0) >= (document.documentElement.scrollHeight || 0) - 220; }
+ function toBottom(smooth) { if (typeof root.scrollTo === 'function') root.scrollTo({ top: document.documentElement.scrollHeight, behavior: smooth && !reduce ? 'smooth' : 'auto' }); }
+
+ function copyText(text, btn) {
+ if (!canCopy) return;
+ navigator.clipboard.writeText(text).then(function () {
+ var label = btn.querySelector('span') || btn;
+ var was = label.textContent;
+ label.textContent = 'Copied';
+ setTimeout(function () { label.textContent = was; }, 1400);
+ }).catch(function () { /* a refused clipboard changes nothing */ });
+ }
+
+ /**
+ * What a reader means by an answer's text: without the Copy buttons and anything marked data-ovt-nocopy (a citation's
+ * number: "visits 12" was "visits 1 2"); the space before a removed mark goes when punctuation follows it. innerText
+ * keeps line breaks only for laid-out nodes, so the copy is read while it sits, invisible, in the page (Actor's, 11-01).
+ */
+ function plainText(el) {
+ var c = el.cloneNode(true);
+ qa(c, '[data-ovt-nocopy], .ovt-code-copy').forEach(function (x) {
+ var before = x.previousSibling, after = x.nextSibling;
+ if (before && before.nodeType === 3 && (!after || (after.nodeType === 3 && /^[\s,.;:!?)]/.test(after.nodeValue)))) before.nodeValue = before.nodeValue.replace(/\s+$/, '');
+ x.remove();
+ });
+ if (c.style) c.style.cssText = 'position:fixed;left:-10000px;top:0;width:' + (el.clientWidth || 600) + 'px;opacity:0;pointer-events:none';
+ c.setAttribute('aria-hidden', 'true');
+ document.body.appendChild(c);
+ var t = String(c.innerText || c.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
+ c.remove();
+ return t;
+ }
+
+ /** Copy on answers and code blocks under el (once each). */
+ function enhance(el) {
+ if (!el) return el;
+ qa(el, '[data-ovt-copy]').forEach(function (b) {
+ if (b.dataset.ovtReady) return;
+ b.dataset.ovtReady = '1';
+ if (!canCopy) return;
+ b.hidden = false;
+ b.addEventListener('click', function () {
+ var turn = b.closest('.ovt-turn');
+ var ans = q(turn, '[data-ovt-answer]');
+ if (!ans) return;
+ // What a reader means to copy: not the Copy buttons, nor anything the site marked data-ovt-nocopy (a citation's number).
+ copyText(plainText(ans), b);
+ });
+ });
+ if (canCopy) qa(el, '.ovt-answer pre').forEach(function (pre) {
+ if (pre.querySelector('.ovt-code-copy')) return;
+ var b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'ovt-code-copy';
+ b.textContent = 'Copy';
+ b.addEventListener('click', function () { var code = pre.querySelector('code') || pre; copyText(code.innerText.replace(/\nCopy$/, ''), b); });
+ pre.appendChild(b);
+ });
+ return el;
+ }
+
+ /**
+ * The answer types in (the owner, 2026-11-01: "text streams in, with nice effects"). An answer that arrives whole
+ * is revealed word by word, fast (about 45 frames, whatever its length); code, tables, pictures and anything marked
+ * data-ovt-whole come in whole as the words reach them. Only opacity changes, so a screen reader reads it all at once,
+ * and reduced motion shows it at once.
+ */
+ function reveal(ans) {
+ if (!ans || reduce || !document.createTreeWalker) return;
+ var WHOLE = 'pre, table, img, figure, [data-ovt-whole]';
+ var words = [];
+ var walker = document.createTreeWalker(ans, 4, { acceptNode: function (n) { return n.parentNode.closest(WHOLE) ? 2 : (/\S/.test(n.nodeValue) ? 1 : 2); } });
+ var nodes = [];
+ while (walker.nextNode()) nodes.push(walker.currentNode);
+ nodes.forEach(function (n) {
+ var f = document.createDocumentFragment();
+ n.nodeValue.split(/(\s+)/).forEach(function (part) {
+ if (!part) return;
+ if (/^\s+$/.test(part)) { f.appendChild(document.createTextNode(part)); return; }
+ var w = document.createElement('span'); w.className = 'ovt-sw'; w.textContent = part; f.appendChild(w); words.push(w);
+ });
+ n.parentNode.replaceChild(f, n);
+ });
+ var blocks = qa(ans, WHOLE).filter(function (b) { return !b.parentNode.closest(WHOLE); });
+ blocks.forEach(function (b) { b.classList.add('ovt-sw'); });
+ var all = words.concat(blocks);
+ if (!all.length) return;
+ ans.classList.add('is-revealing');
+ var per = Math.max(2, Math.ceil(words.length / 45)), i = 0;
+ var raf = root.requestAnimationFrame || function (fn) { return setTimeout(fn, 16); };
+ (function step() {
+ for (var k = 0; k < per && i < words.length; k++, i++) words[i].classList.add('on');
+ var last = words[Math.max(0, i - 1)];
+ blocks.forEach(function (b) { if (!b.classList.contains('on') && last && (b.compareDocumentPosition(last) & 4)) b.classList.add('on'); });
+ if (i < words.length) raf(step);
+ else { all.forEach(function (w) { w.classList.add('on'); }); setTimeout(function () { ans.classList.remove('is-revealing'); }, 300); }
+ })();
+ }
+
+ function threadEl() { return document.querySelector('[data-ovt] [data-ovt-thread]'); }
+ function turnEl(id) { return document.querySelector('[data-ovt] [data-turn="' + String(id).replace(/"/g, '') + '"]'); }
+
+ var api = {
+ enhance: enhance,
+ reveal: reveal,
+ add: function (html) {
+ var list = threadEl();
+ var el = frag(html);
+ if (!list || !el) return null;
+ var stay = nearBottom();
+ el.classList.add('is-new');
+ list.appendChild(el);
+ var empty = q(list.closest('[data-ovt]'), '.ovt-empty-state');
+ if (empty) empty.hidden = true;
+ enhance(el);
+ if (stay) toBottom(true);
+ return el;
+ },
+ replace: function (id, html) {
+ var old = turnEl(id);
+ var el = frag(html);
+ if (!old || !el) return null;
+ var stay = nearBottom();
+ var finished = !el.hasAttribute('data-open') && old.hasAttribute('data-open');
+ old.replaceWith(el);
+ enhance(el);
+ // A turn that just finished: its answer types in (or, where motion is reduced or words cannot be split, fades).
+ if (finished) { var ans = q(el, '[data-ovt-answer]'); if (ans && !reduce && document.createTreeWalker) reveal(ans); else el.classList.add('is-fresh'); }
+ if (stay) toBottom(false);
+ return el;
+ },
+ live: function (id, text) {
+ var line = q(turnEl(id), '[data-ovt-live]');
+ if (line) line.textContent = String(text) + '…';
+ },
+ work: function (id, html) {
+ var list = q(turnEl(id), '[data-ovt-work]');
+ var el = frag(html);
+ if (!list || !el) return null;
+ var stay = nearBottom();
+ list.appendChild(el);
+ if (stay) toBottom(false);
+ return el;
+ },
+ };
+ root.OVThread = api;
+
+ function start() {
+ qa(document, '[data-ovt]').forEach(function (box) {
+ if (box.dataset.ovtReady) return;
+ box.dataset.ovtReady = '1';
+ var head = q(box, '[data-ovt-head]');
+
+ // Under the navbar: the sticky head and the chats column follow its bottom edge.
+ var nav = document.querySelector('.openvibe-navbar') || document.getElementById('navbar-mount');
+ var topWas = -1, ticking = false;
+ function measure() {
+ ticking = false;
+ var b = nav && nav.getBoundingClientRect ? Math.max(0, Math.round(nav.getBoundingClientRect().bottom || 0)) : 0;
+ if (b !== topWas) { topWas = b; box.style.setProperty('--ovt-top', b + 'px'); }
+ if (head) head.classList.toggle('is-stuck', root.scrollY > 8);
+ }
+ function onScroll() { if (!ticking) { ticking = true; (root.requestAnimationFrame || setTimeout)(measure); } }
+ measure();
+ root.addEventListener('scroll', onScroll, { passive: true });
+ root.addEventListener('resize', onScroll);
+ setTimeout(measure, 400);
+
+ // The drawer on a phone.
+ var opener = q(box, '[data-ovt-open]');
+ var scrim = q(box, '.ovt-scrim');
+ var closeBtn = q(box, '.ovt-side-close');
+ function drawer(on) {
+ box.classList.toggle('is-drawer', on);
+ if (scrim) scrim.hidden = !on;
+ if (closeBtn) closeBtn.hidden = !on;
+ if (opener) opener.setAttribute('aria-expanded', on ? 'true' : 'false');
+ if (on) { var f = q(box, '[data-ovt-find]'); if (f && fine) f.focus(); }
+ else if (opener && document.activeElement && box.querySelector('.ovt-side').contains(document.activeElement)) opener.focus();
+ }
+ if (opener) opener.addEventListener('click', function () { drawer(true); });
+ qa(box, '[data-ovt-close]').forEach(function (el) { el.addEventListener('click', function () { drawer(false); }); });
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && box.classList.contains('is-drawer')) drawer(false); });
+
+ // Find a chat.
+ var find = q(box, '[data-ovt-find]');
+ if (find) find.addEventListener('input', function () {
+ var term = find.value.trim().toLowerCase();
+ qa(box, '.ovt-list li').forEach(function (li) { var a = li.querySelector('a'); li.hidden = !!term && (!a || (a.getAttribute('data-title') || '').indexOf(term) === -1); });
+ qa(box, '.ovt-group').forEach(function (g) { var ul = g.nextElementSibling; g.hidden = !!ul && !ul.querySelector('li:not([hidden])'); });
+ });
+
+ // The composer.
+ var form = q(box, '[data-ovt-composer]');
+ var text = q(box, '[data-ovt-text]');
+ var send = q(box, '[data-ovt-send]');
+ function grow() { if (!text) return; text.style.height = 'auto'; text.style.height = Math.min(text.scrollHeight, root.innerHeight * 0.4) + 'px'; }
+ function syncSend() { if (send && text) send.disabled = !text.value.trim(); }
+ if (text) {
+ text.addEventListener('input', function () { grow(); syncSend(); });
+ text.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && fine && text.value.trim()) {
+ e.preventDefault();
+ if (form.requestSubmit) form.requestSubmit(send || undefined); else form.submit();
+ }
+ });
+ grow();
+ syncSend();
+ }
+ qa(box, '[data-ovt-starter]').forEach(function (b) {
+ b.addEventListener('click', function () { if (!text) return; text.value = b.getAttribute('data-ovt-starter'); grow(); syncSend(); text.focus(); });
+ });
+
+ enhance(box);
+ if (!location.hash && q(box, '[data-ovt-thread] > li')) toBottom(false);
+ });
+ }
+
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true }); else start();
+ document.addEventListener('ov:boost:load', start);
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.95.0",
+ "version": "4.96.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": {
@@ -98,7 +98,9 @@
@@ -98,7 +98,9 @@
"./boost": "./boost.js",
"./boost": "./boost.js",
"./rooms": "./rooms.js",
"./rooms": "./rooms.js",
"./motion": "./motion.js",
"./motion": "./motion.js",
- "./agent-layer": "./agent-layer.js"
+ "./agent-layer": "./agent-layer.js",
+ "./thread": "./thread.js",
+ "./ov-thread": "./ov-thread.js"
},
},
"files": [
"files": [
"*.js",
"*.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+// openvibe-shared/thread and ov-thread.js (plan L55 C4): the conversation UI's markup is complete and escaped without
+// script, and the script adds the drawer, the search, the composer's Send that waits for words, starters, Copy, and
+// the helpers a site streams turns with.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { parseHTML } = require('linkedom');
+const t = require('../thread');
+
+// ── The groups ─────────────────────────────────────────────────────────────────────────────────────────────────
+const now = Date.parse('2026-11-02T12:00:00Z');
+assert.strictEqual(t.groupOf('2026-11-02T08:00:00Z', now), 'Today');
+assert.strictEqual(t.groupOf('2026-11-01T23:00:00Z', now), 'Yesterday');
+assert.strictEqual(t.groupOf('2026-10-29T10:00:00Z', now), 'This week');
+assert.strictEqual(t.groupOf('2026-10-01T10:00:00Z', now), 'Earlier');
+assert.strictEqual(t.groupOf('not a date', now), 'Earlier');
+const groups = t.group([{ at: '2026-11-02T09:00:00Z' }, { at: '2026-11-02T07:00:00Z' }, { at: '2026-10-20T00:00:00Z' }], (c) => c.at, now);
+assert.deepStrictEqual(groups.map((g) => [g.name, g.items.length]), [['Today', 2], ['Earlier', 1]]);
+
+// ── The markup ─────────────────────────────────────────────────────────────────────────────────────────────────
+const side = t.sidebar({ newHref: '/chat', groups: [{ name: 'Today', items: [{ href: '/chat/1', title: 'Plan <b>a</b> weekend', current: true, live: true }, { href: '/chat/2', title: 'Lisbon' }] }], foot: [{ href: '/memory', label: 'Memory' }] });
+assert.ok(side.includes('<a href="/chat/1" aria-current="page" data-title="plan <b>a</b> weekend"><span class="ovt-dot" aria-hidden="true"></span><span>Plan <b>a</b> weekend</span></a>'), 'escaped, current, live');
+assert.ok(side.includes('class="ovt-new" href="/chat"') && side.includes('data-ovt-find') && side.includes('<a href="/memory">Memory</a>'));
+assert.ok(t.sidebar({ groups: [] }).includes('Your chats show here.'), 'the empty line');
+assert.ok(t.head({ title: 'A <title>', newHref: '/chat' }).includes('<h1 class="ovt-title">A <title></h1>'));
+
+const turn = t.turn({ id: 'tsk_1', state: 'running', open: true, you: { text: 'What <is> it?', files: [{ href: '/f/1', name: 'a.png', image: true }, { href: '/f/2', name: 'notes.txt', size: 2048 }] }, reply: t.work({ open: true, live: 'Reading the page', rows: [{ kind: 'step', text: 'Plan' }, { kind: 'tool', name: 'Page reader', input: 'https://x', running: true }] }) });
+assert.ok(turn.startsWith('<li class="ovt-turn state-running" id="t-tsk_1" data-turn="tsk_1" data-state="running" data-open>'), turn.slice(0, 120));
+assert.ok(turn.includes('<p class="ovt-bubble">What <is> it?</p>'), 'the person\'s words, escaped');
+assert.ok(turn.includes('<img src="/f/1" alt="a.png" loading="lazy">') && turn.includes('<small>2 KB</small>'), 'a picture and a file');
+assert.ok(turn.includes('<span class="ovt-w-live" data-ovt-live>Reading the page…</span>'), 'the live line while it works');
+assert.ok(turn.includes('<details class="ovt-work" open data-open>'), 'open while it works');
+assert.ok(turn.includes('class="ovt-w-tool is-run"'), 'a running tool');
+
+const done = t.work({ took: '12 s', rows: [{ kind: 'tool', name: 'MX', output: 'ok', ok: true }, { kind: 'tool', name: 'DMARC', output: 'no', ok: false }, { kind: 'note', text: 'Checked', level: 'warn' }] });
+assert.ok(done.includes('<span>Worked for 12 s · 2 tools</span>'), 'the folded line');
+assert.ok(done.includes('ovt-w-tool is-ok') && done.includes('ovt-w-tool is-bad') && done.includes('ovt-w-note is-warn'));
+assert.strictEqual(t.work({ rows: [] }), '', 'nothing to fold, nothing drawn');
+
+const ans = t.answer({ html: '<p>The <b>answer</b></p>', sources: ['https://www.example.com/a', 'not a url'], meta: ['Balanced · DeepSeek', '$0.002'], actions: [{ label: 'Details', href: '/tasks/1?view=details' }] });
+assert.ok(ans.includes('<div class="ovt-answer" data-ovt-answer><p>The <b>answer</b></p></div>'), 'the answer is the caller\'s safe markup');
+assert.ok(ans.includes('>example.com</a>') && ans.includes('>not a url</a>'), 'sources by host');
+assert.ok(ans.includes('<button type="button" class="ovt-act" data-ovt-copy hidden>'), 'Copy waits for the script');
+assert.ok(ans.includes('<span>Balanced · DeepSeek</span><span>$0.002</span>') && ans.includes('href="/tasks/1?view=details"'));
+assert.ok(t.card({ tone: 'bad', title: 'Could not finish', text: 'It <ran> too long.' }).includes('<div class="ovt-card is-bad"><p><b>Could not finish</b> It <ran> too long.</p>'));
+
+const comp = t.composer({ action: '/chat', placeholder: 'Ask anything', hidden: { parent: 'tsk_1', empty: '' }, starters: ['Plan a weekend'], busy: true, stopAction: '/chat/tsk_1/stop', attach: { name: 'files', accept: 'image/*' }, note: 'Steers the turn' });
+assert.ok(comp.includes('<input type="hidden" name="parent" value="tsk_1">') && !comp.includes('name="empty"'), 'hidden fields, empty ones left out');
+assert.ok(comp.includes('data-ovt-starter="Plan a weekend"'));
+assert.ok(comp.includes('formaction="/chat/tsk_1/stop"') && comp.includes('class="ovt-composer is-busy"'), 'Stop while it works');
+assert.ok(comp.includes('enctype="multipart/form-data"') && comp.includes('accept="image/*"'), 'attach');
+assert.ok(!/data-ovt-text required/.test(comp), 'a busy composer may send nothing (it steers)');
+assert.ok(/<button type="submit" class="ovt-stop" hidden data-ovt-stop>/.test(t.composer({ action: '/chat', stopAction: '' })), 'Stop drawn hidden for a script to show');
+assert.ok(!t.composer({ action: '/chat' }).includes('ovt-stop'), 'no Stop when the site has none');
+assert.ok(/data-ovt-text required/.test(t.composer({ action: '/chat' })), 'otherwise it needs words');
+const topped = t.composer({ action: '/chat', top: '<ul class="picked"></ul>', maxlength: 20000 });
+assert.ok(topped.includes('<ul class="picked"></ul><label class="ovt-sr" for="ovt-text">') && topped.includes('maxlength="20000"'), 'a slot above the box, and its limit');
+
+const page = t.page({ sidebar: side, head: t.head({ title: 'Chat' }), turns: [], empty: '<h2>What can I do for you?</h2>', composer: t.composer({ action: '/chat', starters: ['One'] }) });
+assert.ok(page.startsWith('<div class="ovt" data-ovt>') && page.includes('<div class="ovt-empty-state"><h2>What can I do for you?</h2></div><ol class="ovt-thread" data-ovt-thread></ol>'));
+
+// ── The script ─────────────────────────────────────────────────────────────────────────────────────────────────
+const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-thread.js'), 'utf8');
+const html = `<!doctype html><html><body>${t.page({ sidebar: side, head: t.head({ title: 'Chat' }), turns: [turn, t.turn({ id: 'tsk_0', state: 'succeeded', you: { text: 'Hi' }, reply: ans })], composer: t.composer({ action: '/chat', starters: ['Plan a weekend'] }) })}</body></html>`;
+const { window, document } = parseHTML(html);
+delete globalThis.OVThread;
+const copied = [];
+// Node has a global navigator of its own (no clipboard): the script gets one with a clipboard to write to.
+const nav = { clipboard: { writeText: (x) => { copied.push(x); return Promise.resolve(); } } };
+new Function('window', 'document', 'navigator', 'location', 'setTimeout', SRC)(window, document, nav, { hash: '' }, setTimeout);
+const OV = window.OVThread;
+assert.ok(OV && typeof OV.add === 'function', 'window.OVThread');
+const box = document.querySelector('[data-ovt]');
+assert.strictEqual(box.dataset.ovtReady, '1');
+
+// The drawer.
+document.querySelector('[data-ovt-open]').dispatchEvent(new window.Event('click'));
+assert.ok(box.classList.contains('is-drawer') && document.querySelector('.ovt-scrim').hidden === false, 'opens');
+assert.strictEqual(document.querySelector('[data-ovt-open]').getAttribute('aria-expanded'), 'true');
+const esc = new window.Event('keydown'); esc.key = 'Escape'; document.dispatchEvent(esc);
+assert.ok(!box.classList.contains('is-drawer'), 'Esc closes');
+
+// Find a chat.
+const find = document.querySelector('[data-ovt-find]');
+find.value = 'lisbon'; find.dispatchEvent(new window.Event('input'));
+const items = [...document.querySelectorAll('.ovt-list li')];
+assert.deepStrictEqual(items.map((li) => li.hidden), [true, false], 'only the matching chat shows');
+
+// The composer: Send waits for words; a starter fills it.
+const send = document.querySelector('[data-ovt-send]');
+assert.strictEqual(send.disabled, true, 'nothing to send');
+document.querySelector('[data-ovt-starter]').dispatchEvent(new window.Event('click'));
+assert.strictEqual(document.querySelector('[data-ovt-text]').value, 'Plan a weekend');
+assert.strictEqual(send.disabled, false);
+
+// Copy shows where the clipboard can be written, and copies the answer.
+const copyBtn = document.querySelector('[data-turn="tsk_0"] [data-ovt-copy]');
+assert.strictEqual(copyBtn.hidden, false, 'Copy shows');
+copyBtn.dispatchEvent(new window.Event('click'));
+assert.strictEqual(copied.length, 1);
+assert.ok(!/Copy$/.test(copied[0]), 'not the Copy buttons\' own words');
+
+// Streaming helpers.
+OV.live('tsk_1', 'Checking the answer');
+assert.strictEqual(document.querySelector('[data-turn="tsk_1"] [data-ovt-live]').textContent, 'Checking the answer…');
+OV.work('tsk_1', '<li class="ovt-w-step"><span>Read two pages</span></li>');
+assert.strictEqual(document.querySelectorAll('[data-turn="tsk_1"] [data-ovt-work] > li').length, 3);
+const swapped = OV.replace('tsk_1', t.turn({ id: 'tsk_1', state: 'succeeded', you: { text: 'What <is> it?' }, reply: t.answer({ html: '<p>Done</p>' }) }));
+const typed = swapped.querySelector('[data-ovt-answer]');
+assert.ok(typed.classList.contains('is-revealing') || swapped.classList.contains('is-fresh'), 'a finished turn\'s answer types in (or fades in)');
+if (typed.classList.contains('is-revealing')) assert.ok(typed.querySelectorAll('.ovt-sw').length >= 1 && typed.textContent.includes('Done'), 'word by word, every word still in the text');
+assert.strictEqual(document.querySelector('[data-turn="tsk_1"] [data-ovt-copy]').hidden, false, 'the new turn has Copy');
+const added = OV.add(t.turn({ id: 'tsk_2', open: true, you: { text: 'Next' }, reply: t.work({ open: true }) }));
+assert.ok(added.classList.contains('is-new') && document.querySelectorAll('[data-ovt-thread] > li').length === 3, 'a new turn joins the thread');
+
+console.log('thread: all passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,168 @@
@@ -0,0 +1,168 @@
+/* openvibe-shared/thread.css — one conversation UI for the network (plan L55 C4), drawn by openvibe-shared/thread and
+ ov-thread.js. The page scrolls as a whole (no inner box to trap a thumb); the composer stays at the bottom of the
+ screen, above a phone's app bar; the chats are a column on a wide screen and a drawer on a phone. Theme tokens only,
+ so every OpenVibe look applies. Nothing spins: a working turn breathes. */
+.ovt { --ovt-top: 56px; --ovt-rail: 272px; --ovt-line: color-mix(in srgb, var(--border, #1f2d47) 80%, transparent);
+ --ovt-accent: var(--accent, #3b82f6);
+ display: grid; grid-template-columns: minmax(0, 1fr); min-height: calc(100dvh - var(--ovt-top)); color: var(--text-primary, #e6edf7); }
+@media (min-width: 900px) { .ovt { grid-template-columns: var(--ovt-rail) minmax(0, 1fr); } }
+/* The hidden attribute always wins inside the kit (a rule's display never shows what a page hid). */
+.ovt [hidden] { display: none !important; }
+.ovt-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+.ovt-muted { color: var(--text-muted, #7386a3); }
+.ovt-ic { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+
+/* ── The chats ──────────────────────────────────────────────────────────────────────────────────────────────── */
+.ovt-side { display: flex; flex-direction: column; gap: 10px; padding: 12px 10px 14px; background: var(--bg-secondary, #0f1729); border-right: 1px solid var(--ovt-line); }
+@media (min-width: 900px) { .ovt-side { position: sticky; top: var(--ovt-top); height: calc(100dvh - var(--ovt-top)); overflow-y: auto; overscroll-behavior: contain; } }
+@media (max-width: 899px) {
+ .ovt-side { position: fixed; z-index: 60; inset: 0 auto 0 0; width: min(86vw, 320px); padding-top: calc(12px + env(safe-area-inset-top, 0px)); padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
+ overflow-y: auto; overscroll-behavior: contain; transform: translateX(-104%); visibility: hidden; transition: transform .26s cubic-bezier(.2, .8, .2, 1), visibility 0s .26s;
+ box-shadow: 0 0 40px rgba(0, 0, 0, .45); }
+ .ovt.is-drawer .ovt-side { transform: none; visibility: visible; transition: transform .26s cubic-bezier(.2, .8, .2, 1); }
+ .ovt-scrim { position: fixed; inset: 0; z-index: 59; background: rgba(0, 0, 0, .5); }
+ .ovt-side-close { display: grid !important; }
+}
+.ovt-scrim[hidden] { display: none; }
+.ovt-side-head { display: flex; align-items: center; gap: 8px; }
+.ovt-new { flex: 1; display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--ovt-line);
+ background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); font-weight: 650; text-decoration: none; }
+.ovt-new:hover, .ovt-new:focus-visible { border-color: var(--ovt-accent); }
+.ovt-side-close { display: none; place-items: center; width: 42px; height: 42px; border-radius: 12px; border: 1px solid var(--ovt-line); background: none; color: var(--text-secondary, #96a7c2); cursor: pointer; }
+.ovt-find input { width: 100%; box-sizing: border-box; min-height: 40px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--ovt-line); background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: inherit; font-size: 14px; }
+.ovt-list { flex: 1; min-height: 0; }
+.ovt-list ul { list-style: none; margin: 0 0 6px; padding: 0; }
+.ovt-group { margin: 12px 8px 4px; font-size: 11.5px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.ovt-list a { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 10px; border-radius: 10px; color: var(--text-secondary, #b4c0d4); text-decoration: none; font-size: 14.5px; }
+.ovt-list a span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovt-list a:hover, .ovt-list a:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 5%, transparent)); color: var(--text-primary, #e6edf7); }
+.ovt-list a[aria-current=page] { background: color-mix(in srgb, var(--ovt-accent) 16%, transparent); color: var(--text-primary, #e6edf7); font-weight: 600; }
+.ovt-list li[hidden], .ovt-group[hidden] { display: none; }
+.ovt-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--ovt-accent); animation: ovt-breathe 1.6s ease-in-out infinite; }
+.ovt-empty { margin: 14px 10px; font-size: 14px; color: var(--text-muted, #7386a3); }
+.ovt-side-foot { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 0; padding: 10px 8px 0; border-top: 1px solid var(--ovt-line); font-size: 13px; }
+.ovt-side-foot a { color: var(--text-secondary, #96a7c2); text-decoration: none; }
+.ovt-side-foot a:hover { color: var(--text-primary, #e6edf7); }
+
+/* ── The conversation ───────────────────────────────────────────────────────────────────────────────────────── */
+.ovt-main { display: flex; flex-direction: column; min-width: 0; min-height: calc(100dvh - var(--ovt-top) - var(--ovnav-appbar-h, 0px)); }
+.ovt-head { position: sticky; top: var(--ovt-top); z-index: 20; display: flex; align-items: center; gap: 8px; min-height: 52px;
+ padding: 6px max(12px, env(safe-area-inset-right, 0px)) 6px max(12px, env(safe-area-inset-left, 0px));
+ background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 88%, transparent); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-bottom: 1px solid transparent; }
+.ovt-head.is-stuck { border-bottom-color: var(--ovt-line); }
+.ovt-title { flex: 1; min-width: 0; margin: 0; font-size: 16px; font-weight: 650; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovt-btn { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; border: 0; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; text-decoration: none; }
+.ovt-btn:hover, .ovt-btn:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 6%, transparent)); color: var(--text-primary, #e6edf7); }
+.ovt-btn .ovt-ic { width: 22px; height: 22px; }
+@media (min-width: 900px) { .ovt-menu { display: none; } }
+.ovt-scroll { flex: 1; padding: 8px max(16px, env(safe-area-inset-right, 0px)) 16px max(16px, env(safe-area-inset-left, 0px)); }
+.ovt-thread { list-style: none; max-width: 780px; margin: 0 auto; padding: 0; display: grid; gap: 30px; }
+.ovt-empty-state { max-width: 780px; margin: 8vh auto 0; text-align: center; }
+.ovt-empty-state:empty { display: none; }
+
+.ovt-turn { display: grid; gap: 14px; scroll-margin-top: calc(var(--ovt-top) + 60px); }
+.ovt-you { display: grid; justify-items: end; gap: 8px; }
+.ovt-bubble { max-width: min(85%, 620px); margin: 0; padding: 10px 15px; border-radius: 20px 20px 6px 20px; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.5;
+ background: color-mix(in srgb, var(--ovt-accent) 14%, var(--bg-card, #131c2e)); color: var(--text-primary, #e6edf7); }
+.ovt-turn.is-new .ovt-bubble { animation: ovt-rise .32s cubic-bezier(.2, .8, .2, 1) both; }
+.ovt-bot { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 12px; align-items: start; }
+.ovt-bot:not(:has(.ovt-avatar)) { grid-template-columns: minmax(0, 1fr); }
+.ovt-avatar { width: 28px; height: 28px; margin-top: 2px; border-radius: 9px;
+ background: radial-gradient(circle at 30% 30%, color-mix(in srgb, var(--ovt-accent) 70%, #fff), var(--ovt-accent) 55%, color-mix(in srgb, var(--ovt-accent) 60%, #000));
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--ovt-accent) 35%, transparent); }
+.ovt-turn[data-open] .ovt-avatar { animation: ovt-breathe 1.6s ease-in-out infinite; }
+.ovt-reply { min-width: 0; display: grid; gap: 10px; }
+/* Grid items size to their content by default: a tool row's long input would widen the page. */
+.ovt-reply > *, .ovt-work, .ovt-w-tool > details { min-width: 0; max-width: 100%; }
+.ovt-w-tool > details > summary { min-width: 0; }
+
+/* The work, folded: "Worked for 12 s · 3 tools". Open while the turn works, so its live line shows. */
+.ovt-work > summary { list-style: none; display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-height: 32px; padding: 0 10px 0 2px; border-radius: 10px; cursor: pointer;
+ color: var(--text-secondary, #96a7c2); font-size: 14px; }
+.ovt-work > summary::-webkit-details-marker { display: none; }
+.ovt-work > summary:hover { color: var(--text-primary, #e6edf7); }
+.ovt-chev { width: 16px; height: 16px; transition: transform .2s; }
+.ovt-work[open] > summary .ovt-chev { transform: rotate(180deg); }
+.ovt-w-live { background: linear-gradient(90deg, var(--text-secondary, #96a7c2) 40%, var(--text-primary, #e6edf7) 50%, var(--text-secondary, #96a7c2) 60%) 0 0 / 300% 100%;
+ -webkit-background-clip: text; background-clip: text; color: transparent; animation: ovt-shimmer 2.4s linear infinite; }
+.ovt-w-list { list-style: none; margin: 6px 0 0; padding: 0 0 0 12px; border-left: 2px solid var(--ovt-line); display: grid; gap: 6px; font-size: 14px; color: var(--text-secondary, #96a7c2); }
+.ovt-w-list > li { display: flex; gap: 8px; align-items: flex-start; min-width: 0; }
+.ovt-w-list > li > .ovt-ic { width: 16px; height: 16px; margin-top: 2px; }
+.ovt-w-list p { margin: 0; min-width: 0; overflow-wrap: anywhere; }
+.ovt-w-note.is-warn { color: var(--warning-text, #fbbf24); }
+.ovt-w-tool { display: block !important; }
+.ovt-w-tool > details > summary { list-style: none; display: flex; align-items: center; gap: 8px; cursor: pointer; min-height: 28px; }
+.ovt-w-tool > details > summary::-webkit-details-marker { display: none; }
+.ovt-w-tool b { flex: none; font-weight: 600; color: var(--text-primary, #e6edf7); white-space: nowrap; }
+.ovt-w-in { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #7386a3); }
+.ovt-w-st { margin-left: auto; display: inline-flex; }
+.ovt-w-tool.is-ok .ovt-w-st { color: var(--success-text, #4ade80); }
+.ovt-w-tool.is-bad .ovt-w-st { color: var(--danger-text, #f87171); }
+.ovt-w-out { margin: 6px 0 4px 24px; max-height: 260px; overflow: auto; padding: 8px 10px; border-radius: 8px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--ovt-line);
+ font: 12.5px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
+
+/* The answer: comfortable reading, code with Copy, tables that scroll inside themselves. */
+.ovt-answer { line-height: 1.65; font-size: 16px; overflow-wrap: anywhere; }
+.ovt-answer > :first-child { margin-top: 0; }
+.ovt-answer > :last-child { margin-bottom: 0; }
+.ovt-answer pre { position: relative; overflow-x: auto; padding: 12px 14px; border-radius: 12px; background: var(--bg-secondary, #0f1729); border: 1px solid var(--ovt-line); font-size: 13.5px; }
+.ovt-answer table { display: block; overflow-x: auto; border-collapse: collapse; max-width: 100%; }
+.ovt-answer th, .ovt-answer td { padding: 6px 10px; border-bottom: 1px solid var(--ovt-line); text-align: left; }
+.ovt-answer a { color: var(--accent-light, var(--ovt-accent)); }
+.ovt-turn.is-fresh .ovt-answer { animation: ovt-fade .4s ease-out both; }
+/* An answer that types in (ov-thread.js reveal): only opacity, so a screen reader reads it all at once. */
+.ovt-answer.is-revealing .ovt-sw { opacity: 0; transition: opacity .28s ease-out; }
+.ovt-answer.is-revealing .ovt-sw.on { opacity: 1; }
+html[data-ov-motion=reduced] .ovt-answer.is-revealing .ovt-sw { opacity: 1 !important; transition: none !important; }
+.ovt-answer pre:has(.ovt-code-copy) { padding-right: 68px; }
+.ovt-code-copy { position: absolute; top: 6px; right: 6px; padding: 2px 8px; border-radius: 8px; border: 1px solid var(--ovt-line); background: var(--bg-card, #131c2e); color: var(--text-secondary, #96a7c2); font-size: 12px; cursor: pointer; }
+.ovt-files { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
+.ovt-files img { display: block; max-width: 220px; max-height: 180px; border-radius: 12px; border: 1px solid var(--ovt-line); object-fit: cover; }
+.ovt-file { display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 12px; border: 1px solid var(--ovt-line); background: var(--bg-card, #131c2e); color: inherit; text-decoration: none; }
+.ovt-file span { display: grid; }
+.ovt-file small { color: var(--text-muted, #7386a3); }
+.ovt-sources { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; }
+.ovt-sources a { padding: 2px 10px; border-radius: 999px; border: 1px solid var(--ovt-line); color: var(--text-secondary, #96a7c2); font-size: 13px; text-decoration: none; }
+.ovt-sources a:hover { color: var(--text-primary, #e6edf7); border-color: var(--ovt-accent); }
+.ovt-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; font-size: 13px; color: var(--text-muted, #7386a3); }
+.ovt-acts { display: inline-flex; gap: 4px; margin-left: auto; }
+.ovt-act { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 10px; border-radius: 9px; border: 0; background: none; color: var(--text-secondary, #96a7c2); font: inherit; font-size: 13px; cursor: pointer; text-decoration: none; }
+.ovt-act:hover, .ovt-act:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 6%, transparent)); color: var(--text-primary, #e6edf7); }
+.ovt-act .ovt-ic { width: 16px; height: 16px; }
+.ovt-card { padding: 12px 14px; border-radius: 14px; border: 1px solid var(--ovt-line); background: var(--bg-card, #131c2e); }
+.ovt-card p { margin: 0 0 6px; }
+.ovt-card > :last-child { margin-bottom: 0; }
+.ovt-card.is-wait { border-color: color-mix(in srgb, var(--ovt-accent) 50%, var(--ovt-line)); }
+.ovt-card.is-bad { border-color: color-mix(in srgb, var(--danger-text, #f87171) 50%, var(--ovt-line)); }
+
+/* ── The composer ───────────────────────────────────────────────────────────────────────────────────────────── */
+.ovt-dock { position: sticky; bottom: var(--ovnav-appbar-h, 0px); z-index: 15; padding: 8px max(12px, env(safe-area-inset-right, 0px)) calc(10px + env(safe-area-inset-bottom, 0px)) max(12px, env(safe-area-inset-left, 0px));
+ background: linear-gradient(to top, var(--bg-primary, #0a0f1c) 70%, transparent); }
+.ovt-starters { display: flex; flex-wrap: wrap; gap: 8px; max-width: 780px; margin: 0 auto 10px; }
+.ovt-starter { padding: 8px 12px; border-radius: 12px; border: 1px solid var(--ovt-line); background: var(--bg-card, #131c2e); color: var(--text-secondary, #b4c0d4); font: inherit; font-size: 14px; text-align: left; cursor: pointer; }
+.ovt-starter:hover, .ovt-starter:focus-visible { border-color: var(--ovt-accent); color: var(--text-primary, #e6edf7); }
+.ovt-composer { max-width: 780px; margin: 0 auto; padding: 10px 10px 8px 14px; border-radius: 22px; border: 1px solid var(--ovt-line); background: var(--bg-card, #131c2e);
+ box-shadow: 0 8px 30px rgba(0, 0, 0, .25); }
+.ovt-composer:focus-within { border-color: color-mix(in srgb, var(--ovt-accent) 60%, var(--ovt-line)); }
+.ovt-composer textarea { display: block; width: 100%; box-sizing: border-box; min-height: 26px; max-height: 40vh; padding: 4px 0; border: 0; outline: 0; resize: none; background: none;
+ color: var(--text-primary, #e6edf7); font: inherit; font-size: 16px; line-height: 1.5; }
+.ovt-c-row { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
+.ovt-c-tools { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
+.ovt-send, .ovt-stop { flex: none; display: inline-grid; place-items: center; min-width: 40px; height: 40px; padding: 0 10px; border-radius: 14px; border: 0; cursor: pointer; font: inherit; font-weight: 650; }
+.ovt-send { background: var(--ovt-accent); color: var(--on-accent, #fff); }
+.ovt-send:disabled { opacity: .45; cursor: default; }
+.ovt-stop { display: inline-flex; gap: 6px; background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 8%, transparent)); color: var(--text-primary, #e6edf7); }
+.ovt-send .ovt-ic, .ovt-stop .ovt-ic { width: 18px; height: 18px; }
+.ovt-c-note { margin: 6px 4px 0; font-size: 12.5px; color: var(--text-muted, #7386a3); }
+.ovt-c-note:empty { display: none; }
+
+@keyframes ovt-breathe { 50% { opacity: .45; } }
+@keyframes ovt-rise { from { opacity: 0; transform: translateY(8px); } }
+@keyframes ovt-fade { from { opacity: 0; } }
+@keyframes ovt-shimmer { to { background-position: -300% 0; } }
+@media (prefers-reduced-motion: reduce) {
+ .ovt-dot, .ovt-turn[data-open] .ovt-avatar, .ovt-w-live, .ovt-turn.is-new .ovt-bubble, .ovt-turn.is-fresh .ovt-answer { animation: none; }
+ .ovt-answer.is-revealing .ovt-sw { opacity: 1 !important; transition: none !important; }
+ .ovt-w-live { color: var(--text-secondary, #96a7c2); background: none; }
+ .ovt-side, .ovt-chev { transition: none; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,183 @@
@@ -0,0 +1,183 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════
+// openvibe-shared/thread — one conversation UI for the network (plan L55 C4): the chats in a sidebar, the thread of
+// turns, the work an agent did folded under its answer, and the composer. Actor's chat proved the shape (L55 C1–C3);
+// Vibe (openvibe.chat), Codes and the Dock use the same parts, so a conversation looks and behaves the same on every
+// site. Server-rendered and complete without script; ov-thread.js adds the drawer, the search, the growing composer,
+// Enter to send, copy, staying at the bottom, and helpers to add and update turns as they stream. Draw with
+// thread.css. Theme tokens only.
+//
+// const thread = require('openvibe-shared/thread');
+// thread.page({
+// sidebar: thread.sidebar({ newHref: '/chat', groups: thread.group(chats, (c) => c.at) .map(...) }),
+// head: thread.head({ title: 'Plan a weekend' }),
+// turns: [thread.turn({ id, you: { text }, reply: thread.work({...}) + thread.answer({...}) })],
+// composer: thread.composer({ action: '/chat', placeholder: 'Ask anything' }),
+// });
+//
+// Every string is escaped; a field named html is markup the caller already made safe (an answer's rendered Markdown).
+// ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+const attr = (name, v) => (v == null || v === false ? '' : v === true ? ` ${name}` : ` ${name}="${esc(v)}"`);
+const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
+
+// Stroke icons, 24-unit grid, drawn in currentColor (thread.css sizes them).
+const P = {
+ plus: 'M12 5v14M5 12h14',
+ close: 'M6 6l12 12M18 6L6 18',
+ menu: 'M4 7h16M4 12h16M4 17h16',
+ chev: 'M8 10l4 4 4-4',
+ copy: 'M9 9h10v10H9zM5 15V5h10',
+ more: 'M5 12h.01M12 12h.01M19 12h.01',
+ tool: 'M14.7 6.3a4 4 0 0 0-5.4 5.4L4 17l3 3 5.3-5.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2.4-.6-.6-2.4z',
+ ok: 'M5 12l5 5 9-10',
+ bad: 'M6 6l12 12M18 6L6 18',
+ step: 'M9 6l6 6-6 6',
+ note: 'M5 5h14v10H9l-4 4z',
+ you: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 20a8 8 0 0 1 16 0',
+ file: 'M7 3h7l5 5v13H7zM14 3v5h5',
+ send: 'M5 12l14-7-5 14-2.5-5.5z',
+ stop: 'M7 7h10v10H7z',
+ attach: 'M16.5 6.5l-8 8a2.5 2.5 0 0 0 3.5 3.5l8-8a4.5 4.5 0 0 0-6.4-6.4l-8 8',
+};
+const icon = (name, cls = '') => `<svg class="ovt-ic${cls ? ` ${cls}` : ''}" viewBox="0 0 24 24" aria-hidden="true"><path d="${P[name] || P.note}"/></svg>`;
+
+/** Today / Yesterday / This week / Earlier, from an ISO time (or ms) and now. */
+function groupOf(when, now = Date.now()) {
+ const t = typeof when === 'number' ? when : Date.parse(when);
+ if (!Number.isFinite(t)) return 'Earlier';
+ const day = (x) => { const d = new Date(x); return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); };
+ const days = Math.round((day(now) - day(t)) / 864e5);
+ return days <= 0 ? 'Today' : days === 1 ? 'Yesterday' : days < 7 ? 'This week' : 'Earlier';
+}
+
+/** Items (newest first) into the sidebar's groups: [{ name, items }]. `whenOf(item)` gives each one's time. */
+function group(items, whenOf, now = Date.now()) {
+ const out = [];
+ for (const it of items || []) {
+ const name = groupOf(whenOf(it), now);
+ if (!out.length || out[out.length - 1].name !== name) out.push({ name, items: [] });
+ out[out.length - 1].items.push(it);
+ }
+ return out;
+}
+
+/**
+ * The chats: New chat, a search box, the groups of links, and a footer of links. A column on a wide screen, a drawer
+ * on a phone (opened by the head's menu button).
+ * groups: [{ name, items: [{ href, title, current, live }] }]
+ */
+function sidebar({ label = 'Your chats', newHref = null, newLabel = 'New chat', find = true, groups = [], empty = 'Your chats show here.', foot = [] } = {}) {
+ const list = groups.filter((g) => g && g.items && g.items.length);
+ return `<aside class="ovt-side" id="ovt-chats" aria-label="${esc(label)}" data-ovt-side>
+<div class="ovt-side-head">${newHref ? `<a class="ovt-new" href="${esc(newHref)}">${icon('plus')}<span>${esc(newLabel)}</span></a>` : ''}<button type="button" class="ovt-side-close" data-ovt-close aria-label="Close the chats" hidden>${icon('close')}</button></div>
+${find ? `<label class="ovt-find"><span class="ovt-sr">Find a chat</span><input type="search" placeholder="Find a chat" data-ovt-find autocomplete="off"></label>` : ''}
+<nav class="ovt-list">${list.length ? list.map((g) => `<p class="ovt-group">${esc(g.name)}</p><ul>${g.items.map((c) => `<li><a href="${esc(c.href)}"${c.current ? ' aria-current="page"' : ''} data-title="${esc(String(c.title || '').toLowerCase())}">${c.live ? '<span class="ovt-dot" aria-hidden="true"></span>' : ''}<span>${esc(clip(c.title, 80))}</span></a></li>`).join('')}</ul>`).join('') : `<p class="ovt-empty">${esc(empty)}</p>`}</nav>
+${foot.length ? `<p class="ovt-side-foot">${foot.map((f) => `<a href="${esc(f.href)}">${esc(f.label)}</a>`).join('')}</p>` : ''}
+</aside>
+<div class="ovt-scrim" data-ovt-close hidden></div>`;
+}
+
+/** The sticky head over the thread: the drawer's button (phones), the title, New chat, and the caller's actions. */
+function head({ title = '', newHref = null, menu = true, actions = '' } = {}) {
+ return `<header class="ovt-head" data-ovt-head>${menu ? `<button type="button" class="ovt-btn ovt-menu" data-ovt-open aria-controls="ovt-chats" aria-expanded="false" aria-label="Your chats">${icon('menu')}</button>` : ''}<h1 class="ovt-title">${esc(title)}</h1>${actions || ''}${newHref ? `<a class="ovt-btn" href="${esc(newHref)}" aria-label="New chat">${icon('plus')}</a>` : ''}</header>`;
+}
+
+const bytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${Number(n) || 0} B`);
+/** Files beside a message: a picture as a thumbnail, anything else as a named chip. files: [{ href, name, size, image }] */
+function files(list = []) {
+ if (!list || !list.length) return '';
+ return `<ul class="ovt-files">${list.map((f) => `<li>${f.image
+ ? `<a href="${esc(f.href)}" target="_blank" rel="noopener"><img src="${esc(f.href)}" alt="${esc(f.name || '')}" loading="lazy"></a>`
+ : `<a class="ovt-file" href="${esc(f.href)}" target="_blank" rel="noopener">${icon('file')}<span><b>${esc(f.name || 'File')}</b>${f.size != null ? `<small>${bytes(f.size)}</small>` : ''}</span></a>`}</li>`).join('')}</ul>`;
+}
+
+/**
+ * One turn: what the person wrote (a bubble, with any files), and the reply (the caller's markup: work(), answer(),
+ * card()…). open marks a turn still working (its avatar breathes; ov-thread.js follows it).
+ */
+function turn({ id = null, state = null, open = false, you = {}, reply = '', avatar = true, attrs = {} } = {}) {
+ const extra = Object.entries(attrs || {}).map(([k, v]) => attr(k, v)).join('');
+ const mine = you && (you.html != null || you.text != null || (you.files && you.files.length))
+ ? `<div class="ovt-you">${files(you.files)}${you.html != null ? `<div class="ovt-bubble">${you.html}</div>` : you.text != null ? `<p class="ovt-bubble">${esc(you.text)}</p>` : ''}</div>` : '';
+ return `<li class="ovt-turn${state ? ` state-${esc(state)}` : ''}"${attr('id', id ? `t-${id}` : null)}${attr('data-turn', id)}${attr('data-state', state)}${open ? ' data-open' : ''}${extra}>
+${mine}<div class="ovt-bot">${avatar ? '<span class="ovt-avatar" aria-hidden="true"></span>' : ''}<div class="ovt-reply">${reply}</div></div>
+</li>`;
+}
+
+/**
+ * The work, folded under the answer: "Worked for 12 s · 3 tools", or, while it works, the live line ("Reading the
+ * page…"), open. rows: [{ kind: 'step' | 'note' | 'you' | 'tool', text, level, name, input, output, ok, running }].
+ */
+function work({ open = false, live = 'Working', took = null, rows = [] } = {}) {
+ const tools = rows.filter((r) => r.kind === 'tool').length;
+ if (!open && !rows.length) return '';
+ const items = rows.map((r) => {
+ if (r.kind === 'tool') {
+ const st = r.running || r.ok == null ? 'is-run' : r.ok ? 'is-ok' : 'is-bad';
+ return `<li class="ovt-w-tool ${st}"><details><summary>${icon('tool')}<b>${esc(r.name || 'Tool')}</b>${r.input ? `<span class="ovt-w-in">${esc(clip(r.input, 90))}</span>` : ''}<span class="ovt-w-st">${st === 'is-run' ? '<span class="ovt-dot" aria-hidden="true"></span>' : icon(st === 'is-ok' ? 'ok' : 'bad')}</span></summary>
+${r.output != null ? `<pre class="ovt-w-out">${esc(clip(r.output, 1500))}</pre>` : '<p class="ovt-w-out ovt-muted">Running…</p>'}</details></li>`;
+ }
+ if (r.kind === 'you') return `<li class="ovt-w-you">${icon('you')}<p>${esc(clip(r.text, 300))}</p></li>`;
+ if (r.kind === 'note') return `<li class="ovt-w-note${r.level ? ` is-${esc(r.level)}` : ''}">${icon('note')}<p>${esc(clip(r.text, 700))}</p></li>`;
+ return `<li class="ovt-w-step">${icon('step')}<span>${esc(clip(r.text, 300))}</span></li>`;
+ });
+ const line = open
+ ? `<span class="ovt-w-live" data-ovt-live>${esc(live)}…</span>`
+ : `<span>${took ? `Worked for ${esc(took)}` : 'Worked'}${tools ? ` · ${tools} tool${tools === 1 ? '' : 's'}` : ''}</span>`;
+ return `<details class="ovt-work"${open ? ' open data-open' : ''}><summary>${line}${icon('chev', 'ovt-chev')}</summary><ol class="ovt-w-list" data-ovt-work>${items.join('')}</ol></details>`;
+}
+
+/**
+ * The answer: rendered Markdown (html, already safe), files and charts, sources as quiet links, and a footer with what
+ * it ran on and the actions (Copy is drawn hidden and shown by ov-thread.js, which can copy).
+ * meta: ['Balanced · DeepSeek', '$0.002', '14 s']; actions: [{ label, href, icon }]
+ */
+function answer({ html = '', files: att = [], sources = [], meta = [], actions = [], copy = true } = {}) {
+ const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return clip(u, 40); } };
+ const acts = [
+ copy ? `<button type="button" class="ovt-act" data-ovt-copy hidden>${icon('copy')}<span>Copy</span></button>` : '',
+ ...actions.map((a) => `<a class="ovt-act" href="${esc(a.href)}">${icon(a.icon || 'more')}<span>${esc(a.label)}</span></a>`),
+ ].join('');
+ return `<div class="ovt-answer" data-ovt-answer>${html}</div>
+${files(att)}
+${sources.length ? `<p class="ovt-sources">${sources.slice(0, 8).map((u) => `<a href="${esc(u)}" rel="nofollow ugc noopener" target="_blank">${esc(host(u))}</a>`).join('')}</p>` : ''}
+${meta.length || acts ? `<div class="ovt-meta">${meta.filter(Boolean).map((m) => `<span>${esc(m)}</span>`).join('')}<span class="ovt-acts">${acts}</span></div>` : ''}`;
+}
+
+/** A card in the reply: a question waiting for the person (wait), a failure (bad), or a note (info). html is safe markup. */
+function card({ tone = 'info', title = '', text = null, html = '' } = {}) {
+ return `<div class="ovt-card is-${esc(tone)}">${title ? `<p><b>${esc(title)}</b>${text != null ? ` ${esc(text)}` : ''}</p>` : text != null ? `<p>${esc(text)}</p>` : ''}${html}</div>`;
+}
+
+/**
+ * The composer, at the bottom of the screen: a box that grows as you type, the caller's tools (a mode chip, a model
+ * chip, attach), and Send, which becomes Stop while a turn works (busy). Works as a plain form without script.
+ * hidden: { parent: 'tsk_…' }; starters: ['Plan a weekend in Lisbon', …] (shown on an empty chat)
+ * top: the site's safe markup above the box (picked files, a problem line); maxlength: the box's limit
+ * stopAction: where Stop posts while a turn works; '' draws Stop hidden, for a page whose script starts turns itself
+ */
+function composer({ action = '', method = 'post', name = 'text', placeholder = 'Message', value = '', busy = false, sendLabel = 'Send', stopAction = null, hidden = {}, tools = '', starters = [], note = '', attach = null, label = 'Message', top = '', maxlength = null } = {}) {
+ const hid = Object.entries(hidden || {}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');
+ return `${starters.length ? `<div class="ovt-starters">${starters.map((s) => `<button type="button" class="ovt-starter" data-ovt-starter="${esc(s)}">${esc(s)}</button>`).join('')}</div>` : ''}
+<form class="ovt-composer${busy ? ' is-busy' : ''}" method="${esc(method)}" action="${esc(action)}" data-ovt-composer${attach ? ' enctype="multipart/form-data"' : ''}>${hid}
+${top || ''}<label class="ovt-sr" for="ovt-text">${esc(label)}</label>
+<textarea id="ovt-text" name="${esc(name)}" rows="1" placeholder="${esc(placeholder)}"${maxlength ? ` maxlength="${Number(maxlength) || ''}"` : ''} data-ovt-text${busy ? '' : ' required'}>${esc(value)}</textarea>
+<div class="ovt-c-row"><div class="ovt-c-tools">${attach ? `<label class="ovt-btn ovt-attach" title="Attach files">${icon('attach')}<span class="ovt-sr">Attach files</span><input type="file" name="${esc(attach.name || 'files')}"${attach.accept ? ` accept="${esc(attach.accept)}"` : ''} multiple data-ovt-files hidden></label>` : ''}${tools || ''}</div>
+${stopAction != null ? `<button type="submit" class="ovt-stop"${stopAction ? ` formaction="${esc(stopAction)}" formnovalidate` : ''}${busy && stopAction ? '' : ' hidden'} data-ovt-stop>${icon('stop')}<span>Stop</span></button>` : ''}<button type="submit" class="ovt-send" data-ovt-send aria-label="${esc(sendLabel)}">${icon('send')}<span class="ovt-sr">${esc(sendLabel)}</span></button></div>
+${note ? `<p class="ovt-c-note" data-ovt-note>${esc(note)}</p>` : ''}
+</form>`;
+}
+
+/**
+ * The whole screen: the sidebar and the conversation (head, the turns or the empty state, the composer). Wrap it in
+ * the site's page; on a phone the composer stays at the bottom above the app bar.
+ */
+function page({ sidebar: side = '', head: top = '', turns = [], empty = '', composer: comp = '', label = 'Conversation' } = {}) {
+ return `<div class="ovt" data-ovt>${side}<section class="ovt-main" aria-label="${esc(label)}">${top}
+<div class="ovt-scroll">${turns.length ? `<ol class="ovt-thread" data-ovt-thread>${turns.join('')}</ol>` : `<div class="ovt-empty-state">${empty}</div><ol class="ovt-thread" data-ovt-thread></ol>`}</div>
+<div class="ovt-dock">${comp}</div></section></div>`;
+}
+
+module.exports = { page, sidebar, head, turn, work, answer, card, composer, files, group, groupOf, icon, esc };