openvibers/openvibe.shared Commits

4.97.0: the conversation UI uses the network's one sidebar (kit.sidebar) (#256)

OP OpenVibers committed 11 days ago · 8e015529e8 · 9 files, +83 −36

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9660051..f872b16 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,18 @@ 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.97.0 — 2026-11-02
++
++- **The conversation UI uses the network's one sidebar.** 4.96.0's `thread.sidebar` drew a second sidebar beside
++ `kit.sidebar`, which is the network's one (plan L35, the owner 10-31: "the navigation makes more sense in a
++ vertical way… shared styling across everything") and which Codes already uses. Now:
++ - `thread.sidebar` returns `kit.sidebar`: New chat is its action, the chats are its sections (the open one marked, a
++ working one with a dot), and the site's places can be its `nav` rows.
++ - Find a chat filters as you type (ov-thread.js); without script it is the kit's GET form.
++ - `thread.page` lays the kit sidebar out as its first column from 761 px, with the kit's scrim.
++ - `thread.head` carries `kit.sidebarButton`, so kit-web.js opens the drawer on a phone.
++ - Draw with kit.css and kit-web.js beside thread.css and ov-thread.js.
++
## 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
ddiff --git a/README.md b/README.md
iindex e2522ab..741fd88 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.96.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.97.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 b26c4e4..d97ec5b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.96.0",
++ "version": "4.97.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
iindex 3b0e10c..500ba2a 100644
---- a/docs/thread.md
++++ b/docs/thread.md
@@@ -5,7 +5,8 @@ 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.
++- **Look:** `/shared/thread.css`, theme tokens only. The chats are the network's one sidebar (`kit.sidebar`), so a
++ page also loads `/shared/kit.css` and `/shared/kit-web.js` (4.97.0).
- **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.
@@@ -14,7 +15,7 @@ so a conversation looks and behaves the same on every OpenVibe site.
| 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 |
++| `sidebar({ newHref, groups, nav, 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 |
ddiff --git a/ov-thread.js b/ov-thread.js
iindex 30ddb10..e3917f6 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -213,6 +213,17 @@
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, in the network's sidebar (kit.sidebar): it filters as you type, and Enter keeps you here.
++ var kfind = q(box, '.ovk-sidebar-find input');
++ if (kfind) {
++ var kform = kfind.closest('form');
++ if (kform) kform.addEventListener('submit', function (e) { e.preventDefault(); });
++ kfind.addEventListener('input', function () {
++ var term = kfind.value.trim().toLowerCase();
++ qa(box, '.ovk-sidebar-secs .ovk-sidebar-item').forEach(function (a) { var tt = q(a, '.ovk-sidebar-t'); a.hidden = !!term && (!tt || tt.textContent.toLowerCase().indexOf(term) === -1); });
++ qa(box, '.ovk-sidebar-secs .ovk-sidebar-sec').forEach(function (sec) { sec.hidden = !!term && !sec.querySelector('.ovk-sidebar-item:not([hidden])'); });
++ });
++ }
// Find a chat.
var find = q(box, '[data-ovt-find]');
if (find) find.addEventListener('input', function () {
ddiff --git a/package.json b/package.json
iindex f179a03..21b8cc5 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.96.0",
++ "version": "4.97.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/thread.test.js b/test/thread.test.js
iindex 6af182d..0930a87 100644
---- a/test/thread.test.js
++++ b/test/thread.test.js
@@@ -20,10 +20,14 @@ assert.deepStrictEqual(groups.map((g) => [g.name, g.items.length]), [['Today', 2
// ── 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>'));
++// The chats live in the network's one sidebar (kit.sidebar, L35), not a second one.
++assert.ok(side.startsWith('<aside class="ovk-sidebar" id="ovt-chats" aria-label="Your chats" data-ovk-sidebar>'), 'kit.sidebar');
++assert.ok(side.includes('<a class="ovk-sidebar-item is-here" href="/chat/1" aria-current="page"><span class="ovk-sidebar-t">Plan &lt;b&gt;a&lt;/b&gt; weekend</span><i class="ovk-sidebar-dot is-busy" aria-hidden="true"></i></a>'), 'escaped, current, live');
++assert.ok(side.includes('<a class="ovk-sidebar-action" href="/chat">') && side.includes('name="q" placeholder="Find a chat"') && side.includes('<p class="ovt-side-foot"><a href="/memory">Memory</a></p>'));
++assert.ok(t.sidebar({ groups: [] }).includes('<p class="ovk-sidebar-empty">Your chats show here.</p>'), 'the empty line');
++assert.ok(t.sidebar({ nav: [{ href: '/console', label: 'Console', icon: 'grid' }] }).includes('<nav class="ovk-sidebar-nav"'), 'the site\'s places as rows');
++const headHtml = t.head({ title: 'A <title>', newHref: '/chat' });
++assert.ok(headHtml.includes('<h1 class="ovt-title">A &lt;title&gt;</h1>') && headHtml.includes('data-ovk-sidebar-open aria-controls="ovt-chats"'), 'the kit\'s button opens the chats on a phone');
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));
@@@ -59,6 +63,7 @@ assert.ok(topped.includes('<ul class="picked"></ul><label class="ovt-sr" for="ov
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>'));
++assert.ok(page.includes('</aside><div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden></div>'), 'the kit\'s scrim closes the drawer');
// ── The script ─────────────────────────────────────────────────────────────────────────────────────────────────
const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-thread.js'), 'utf8');
@@@ -74,18 +79,14 @@ 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 a chat, in the kit sidebar: it filters as you type (the drawer is kit-web.js's).
++const find = document.querySelector('.ovk-sidebar-find input');
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');
++const items = [...document.querySelectorAll('.ovk-sidebar-secs .ovk-sidebar-item')];
++assert.deepStrictEqual(items.map((a) => a.hidden), [true, false], 'only the matching chat shows');
++const submitted = new window.Event('submit', { cancelable: true });
++find.closest('form').dispatchEvent(submitted);
++assert.ok(submitted.defaultPrevented, 'Enter keeps you on the page');
// The composer: Send waits for words; a starter fills it.
const send = document.querySelector('[data-ovt-send]');
ddiff --git a/thread.css b/thread.css
iindex 28760e9..5902c38 100644
---- a/thread.css
++++ b/thread.css
@@@ -6,6 +6,13 @@
--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 network's one sidebar (kit.sidebar): a column from 761 px, where kit.css makes it one, and sticky under the navbar. */
++@media (min-width: 761px) {
++ .ovt:has(> .ovk-sidebar) { grid-template-columns: var(--ovk-sidebar-w, var(--ovt-rail)) minmax(0, 1fr); }
++ .ovt > .ovk-sidebar { position: sticky; top: var(--ovt-top); height: calc(100dvh - var(--ovt-top)); overflow-y: auto; overscroll-behavior: contain; }
++}
++@media (max-width: 760px) { .ovt > .ovk-sidebar-scrim:not([hidden]) { position: fixed; inset: 0; z-index: 59; } }
++.ovt-head .ovk-sidebar-open { flex: none; }
/* 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; }
@@@ -40,6 +47,7 @@
.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); }
++.ovk-sidebar-foot .ovt-side-foot { border-top: 0; padding-top: 0; }
.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); }
ddiff --git a/thread.js b/thread.js
iindex bc12d34..2f53d43 100644
---- a/thread.js
++++ b/thread.js
@@@ -18,6 +18,8 @@
// Every string is escaped; a field named html is markup the caller already made safe (an answer's rendered Markdown).
// ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════
++const kit = require('./kit');
++
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; };
@@@ -64,24 +66,34 @@ function group(items, whenOf, now = Date.now()) {
}
/**
-- * 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 }] }]
++ * The chats, in the network's one sidebar (kit.sidebar, plan L35: "the navigation makes more sense in a vertical way…
++ * shared styling across everything"): New chat on top, Find a chat (it filters as you type with ov-thread.js; without
++ * script it is a GET to `findAction`), the site's places as vertical rows (nav), then the chats in their groups. A column
++ * beside the conversation on a wide screen, a drawer on a phone (kit-web.js opens it from head()'s button).
++ * groups: [{ name, items: [{ href, title, current, live, mark }] }]; nav: kit.sidebar's [{ href, label, icon, here, count }]
++ * Draw with kit.css and kit-web.js beside thread.css and ov-thread.js.
*/
--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>`;
++function sidebar({ id = 'ovt-chats', label = 'Your chats', newHref = null, newLabel = 'New chat', find = true, findAction = '', groups = [], empty = 'Your chats show here.', nav = [], foot = [] } = {}) {
++ const sections = groups.filter((g) => g && g.items && g.items.length).map((g) => ({
++ title: g.name,
++ items: g.items.map((c) => ({ href: c.href, label: clip(c.title, 80), here: !!c.current, dot: c.live ? 'busy' : null, mark: c.mark || '' })),
++ }));
++ return kit.sidebar({
++ id, label,
++ action: newHref ? { href: newHref, label: newLabel, icon: 'plus' } : null,
++ find: find ? { action: findAction || newHref || '', name: 'q', placeholder: 'Find a chat', label: 'Find a chat' } : null,
++ nav,
++ sections: sections.length ? sections : [{ title: '', items: [], empty }],
++ foot: foot.length ? `<p class="ovt-side-foot">${foot.map((f) => `<a href="${esc(f.href)}">${esc(f.label)}</a>`).join('')}</p>` : '',
++ });
}
--/** 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>`;
++/**
++ * The sticky head over the thread: the button that opens the chats on a phone (kit.sidebarButton), the title, New chat,
++ * and the caller's actions. `sidebar` names the sidebar's id (false for none).
++ */
++function head({ title = '', newHref = null, sidebar: sid = 'ovt-chats', actions = '' } = {}) {
++ return `<header class="ovt-head" data-ovt-head>${sid ? kit.sidebarButton({ id: sid, label: 'Your chats' }) : ''}<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`);
@@@ -175,7 +187,9 @@ ${note ? `<p class="ovt-c-note" data-ovt-note>${esc(note)}</p>` : ''}
* 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}
++ // The kit's sidebar closes on its scrim (kit-web.js).
++ const scrim = side && side.includes('data-ovk-sidebar') ? '<div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden></div>' : '';
++ return `<div class="ovt" data-ovt>${side}${scrim}<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>`;
}