openvibers/openvibe.shared Commits

4.96.0: one conversation UI for the network (openvibe-shared/thread) (#255)

OP OpenVibers committed 11 days ago · 474db35308 · 10 files, +794 −4

* 4.96.0: one conversation UI for the network (openvibe-shared/thread, thread.css, ov-thread.js)

* A finished turn's answer types in, word by word (OVThread.reveal)

* The composer's top slot and maxlength; Copy leaves out data-ovt-nocopy

* Stop can be drawn hidden, for a page whose script starts turns itself

* Copy: the space before a removed citation goes when punctuation follows (from Actor)

* The hidden attribute always wins inside the kit

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 760efee..9660051 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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
ddiff --git a/README.md b/README.md
iindex 5cb215c..e2522ab 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 287dc54..b26c4e4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/docs/thread.md b/docs/thread.md
nnew file mode 100644
iindex 0000000..3b0e10c
---- /dev/null
++++ b/docs/thread.md
@@@ -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`.
ddiff --git a/files.js b/files.js
iindex 10dd83f..6140478 100644
---- a/files.js
++++ b/files.js
@@@ -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)
ddiff --git a/ov-thread.js b/ov-thread.js
nnew file mode 100644
iindex 0000000..30ddb10
---- /dev/null
++++ b/ov-thread.js
@@@ -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);
ddiff --git a/package.json b/package.json
iindex 93ea5f2..f179a03 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/test/thread.test.js b/test/thread.test.js
nnew file mode 100644
iindex 0000000..6af182d
---- /dev/null
++++ b/test/thread.test.js
@@@ -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 &lt;b&gt;a&lt;/b&gt; weekend"><span class="ovt-dot" aria-hidden="true"></span><span>Plan &lt;b&gt;a&lt;/b&gt; 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 &lt;title&gt;</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 &lt;is&gt; 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 &lt;ran&gt; 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');
ddiff --git a/thread.css b/thread.css
nnew file mode 100644
iindex 0000000..28760e9
---- /dev/null
++++ b/thread.css
@@@ -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; }
++}
ddiff --git a/thread.js b/thread.js
nnew file mode 100644
iindex 0000000..bc12d34
---- /dev/null
++++ b/thread.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };