openvibers/openvibe.shared Commits
4.97.0: the conversation UI uses the network's one sidebar (kit.sidebar) (#256)
Changes
@@@ -4,6 +4,18 @@ 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.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@@@ -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 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.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)",@@@ -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 isescaped. 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, stayingat 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 |@@@ -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 () {@@@ -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": {@@@ -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 <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>'));++// 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 <b>a</b> 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 <title></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="ovconst 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]');@@@ -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); }@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>`;}
@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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.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
## 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
- **One conversation UI for the network** (plan L55 C4). Actor's chat proved the shape; Vibe, Codes and the Dock use
@@ -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.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
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.96.0",
+ "version": "4.97.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)",
@@ -5,7 +5,8 @@ so a conversation looks and behaves the same on every OpenVibe site.
@@ -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
- **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).
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
- **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.
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.
@@ -14,7 +15,7 @@ so a conversation looks and behaves the same on every OpenVibe site.
| Function | What it draws |
| Function | What it draws |
|---|---|
|---|---|
| `page({ sidebar, head, turns, empty, composer })` | The screen: the chats beside the conversation; the composer stays at the bottom |
| `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 |
| `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 |
| `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 |
| `turn({ id, state, open, you, reply })` | One turn: the person's bubble (with files) and the reply |
@@ -213,6 +213,17 @@
@@ -213,6 +213,17 @@
qa(box, '[data-ovt-close]').forEach(function (el) { el.addEventListener('click', function () { drawer(false); }); });
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); });
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.
// Find a chat.
var find = q(box, '[data-ovt-find]');
var find = q(box, '[data-ovt-find]');
if (find) find.addEventListener('input', function () {
if (find) find.addEventListener('input', function () {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -20,10 +20,14 @@ assert.deepStrictEqual(groups.map((g) => [g.name, g.items.length]), [['Today', 2
@@ -20,10 +20,14 @@ assert.deepStrictEqual(groups.map((g) => [g.name, g.items.length]), [['Today', 2
// ── The markup ─────────────────────────────────────────────────────────────────────────────────────────────────
// ── 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' }] });
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>'));
+// 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 <b>a</b> 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 <title></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 }] }) });
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.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
@@ -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'] }) });
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.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 ─────────────────────────────────────────────────────────────────────────────────────────────────
// ── The script ─────────────────────────────────────────────────────────────────────────────────────────────────
const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-thread.js'), 'utf8');
const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-thread.js'), 'utf8');
@@ -74,18 +79,14 @@ assert.ok(OV && typeof OV.add === 'function', 'window.OVThread');
@@ -74,18 +79,14 @@ assert.ok(OV && typeof OV.add === 'function', 'window.OVThread');
const box = document.querySelector('[data-ovt]');
const box = document.querySelector('[data-ovt]');
assert.strictEqual(box.dataset.ovtReady, '1');
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'));
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.
// The composer: Send waits for words; a starter fills it.
const send = document.querySelector('[data-ovt-send]');
const send = document.querySelector('[data-ovt-send]');
@@ -6,6 +6,13 @@
@@ -6,6 +6,13 @@
--ovt-accent: var(--accent, #3b82f6);
--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); }
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); } }
@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). */
/* The hidden attribute always wins inside the kit (a rule's display never shows what a page hid). */
.ovt [hidden] { display: none !important; }
.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-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@@ -40,6 +47,7 @@
@@ -40,6 +47,7 @@
.ovt-list li[hidden], .ovt-group[hidden] { display: none; }
.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-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-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 { 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 { color: var(--text-secondary, #96a7c2); text-decoration: none; }
.ovt-side-foot a:hover { color: var(--text-primary, #e6edf7); }
.ovt-side-foot a:hover { color: var(--text-primary, #e6edf7); }
@@ -18,6 +18,8 @@
@@ -18,6 +18,8 @@
// Every string is escaped; a field named html is markup the caller already made safe (an answer's rendered Markdown).
// 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
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 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; };
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()) {
@@ -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`);
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>` : ''}
@@ -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.
* 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' } = {}) {
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-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>`;
<div class="ovt-dock">${comp}</div></section></div>`;
}
}