openvibers/openvibe.shared Commits

4.102.0: sidebar rows with a second line; boost lets parsed pages go (#264)

OP OpenVibers committed 11 days ago · 1c743df51f · 10 files, +60 −7

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 15a008c..2f1dad3 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.102.0 — 2026-11-02
++
++- **A sidebar row with two lines** (`kit.sidebar` items): `sub` is a quiet second line under the name, a
++ conversation's last message or where a search matched, with the mark beside both lines. `attrs` gives a row `data-*`
++ attributes so a page's script finds it again (Chat's support inbox, `data-conv`); any other name is dropped. A
++ one-line row's markup is unchanged.
++- **Boost lets each parsed page go.** After a swap, boost empties the page it parsed. Chrome held that page's own
++ `<video>` (the swap shows a copy) as a pending activity, and with it the whole parsed page: one document and every
++ node more on each visit. On openvibe.cam, `/` ↔ `/app` grew by 3 documents and 1,729 nodes in three laps and failed
++ the release check's navigation test. `boost-chrome.test.js` visits a page with a video five times in place and checks
++ the document count stays put.
++
## 4.101.0 — 2026-11-02
- **A puzzle glyph** (`puzzle`, with a solid drawing): a jigsaw piece, knobs out on top and right, notches in, for a
ddiff --git a/README.md b/README.md
iindex 0d28002..8d92409 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.101.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.102.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 1137abb..7694228 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.101.0",
++ "version": "4.102.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/boost.js b/boost.js
iindex e5f99a2..e72bb0f 100644
---- a/boost.js
++++ b/boost.js
@@@ -132,10 +132,11 @@
const Fx = await fx(mainEl);
const h = Fx ? Fx.start(mainEl, { prefix }) : null;
emit('before', { url: target.href });
++ let doc = null;
try {
const page = await fetchPage(target.href);
if (inflight !== mine) return; // a newer move won
-- const doc = new root.DOMParser().parseFromString(page.html, 'text/html');
++ doc = new root.DOMParser().parseFromString(page.html, 'text/html');
const next = doc.querySelector(mainSel);
// A different release, other external scripts, or no <main>: this page cannot be swapped safely.
if (!next || marker(doc) !== here.marker || externalScripts(doc) !== here.scripts) throw new Error('not swappable');
@@@ -172,6 +173,12 @@
s.fallbacks++;
if (h) h.done();
root.location.assign(target.href); // any doubt: a normal page load
++ } finally {
++ // Let the parsed page go. Its own copy of a <video> or <audio> (the swap imported a copy) is held by
++ // Chrome as a pending activity (RemotePlayback), and with it the whole parsed page: one more document
++ // and every node of the page on each visit (openvibe.cam/app, the release check's navigation test,
++ // 2026-11-02). Emptying the parsed page releases it.
++ if (doc && doc.documentElement) doc.documentElement.replaceChildren();
}
}
ddiff --git a/docs/kit.md b/docs/kit.md
iindex c35078a..2d71240 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -466,6 +466,9 @@ kit.withSidebar({ sidebar: kit.sidebar({
dot: `true`, `'busy'`, `'needs'` or `'bad'`);
- then the sections, which scroll on their own;
- and an optional foot.
++- **A row with two lines (4.102.0):** `sub` is a quiet second line under the name (a conversation's last message, where
++ a search matched), and the mark sits beside both lines. `attrs` gives the row `data-*` attributes, so a page's script
++ can find a row again (`{ 'data-conv': 12 }`); any other name is dropped.
- **On a computer:** a 272 px column beside the page (`--ovk-sidebar-w`), sticky under the navbar.
- **On a phone (760 px and under):** a drawer over everything.
- `kit.sidebarButton` opens it.
ddiff --git a/kit.css b/kit.css
iindex 41cb7c8..1e4ba70 100644
---- a/kit.css
++++ b/kit.css
@@@ -360,6 +360,12 @@ div.ovk-set-row { cursor: default; }
.ovk-sidebar-item.is-here { background: var(--ovk-panel-2); font-weight: 600; }
.ovk-sidebar-t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovk-sidebar-m { flex: none; color: var(--ovk-muted); font-size: 12px; }
++/* A row with a quiet second line (4.102.0): a conversation's last message under its name, the mark beside both lines. */
++.ovk-sidebar-item.has-sub { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; column-gap: 9px; row-gap: 1px; padding-block: 6px; }
++.ovk-sidebar-item.has-sub.has-mark { grid-template-columns: auto minmax(0, 1fr) auto auto; }
++.ovk-sidebar-item.has-sub.has-mark > :first-child { grid-row: 1 / span 2; }
++.ovk-sidebar-s { grid-column: 1 / -1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ovk-muted); font-size: 12.5px; font-weight: 400; }
++.ovk-sidebar-item.has-sub.has-mark .ovk-sidebar-s { grid-column: 2 / -1; }
.ovk-sidebar-empty { margin: 8px 10px; color: var(--ovk-muted); font-size: 13.5px; }
.ovk-sidebar-foot { padding-top: 8px; border-top: 1px solid var(--ovk-line); }
@media (max-width: 760px) {
ddiff --git a/kit.js b/kit.js
iindex b3bc11e..917d62c 100644
---- a/kit.js
++++ b/kit.js
@@@ -211,8 +211,11 @@
const link = (x) => `<a class="ovk-sidebar-link${x.here ? ' is-here' : ''}" href="${esc(url(x.href))}"${x.here ? ' aria-current="page"' : ''}>`
+ `${x.iconHtml != null ? x.iconHtml : icon(x.icon || 'chevron')}<span>${esc(x.label)}</span>`
+ `${Number(x.count) > 0 ? `<b class="ovk-sidebar-count">${Number(x.count) > 99 ? '99+' : Number(x.count)}</b>` : dotHtml(x.dot)}</a>`;
-- const item = (x) => `<a class="ovk-sidebar-item${x.here ? ' is-here' : ''}" href="${esc(url(x.href))}"${x.here ? ' aria-current="page"' : ''}>`
-- + `${x.mark || ''}<span class="ovk-sidebar-t">${esc(x.label)}</span>${x.meta ? `<span class="ovk-sidebar-m">${esc(x.meta)}</span>` : ''}${dotHtml(x.dot)}</a>`;
++ // A row's data-* attributes (a site finds its rows by them: a conversation's id), nothing else.
++ const data = (a) => Object.entries(a || {}).filter(([k, v]) => /^data-[a-z0-9-]+$/.test(k) && v != null && v !== false).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
++ const item = (x) => `<a class="ovk-sidebar-item${x.sub ? ' has-sub' : ''}${x.sub && x.mark ? ' has-mark' : ''}${x.here ? ' is-here' : ''}" href="${esc(url(x.href))}"${x.here ? ' aria-current="page"' : ''}${data(x.attrs)}>`
++ + `${x.mark || ''}<span class="ovk-sidebar-t">${esc(x.label)}</span>${x.meta ? `<span class="ovk-sidebar-m">${esc(x.meta)}</span>` : ''}${dotHtml(x.dot)}`
++ + `${x.sub ? `<span class="ovk-sidebar-s">${esc(x.sub)}</span>` : ''}</a>`;
const secs = (sections || []).map((sec) => {
const items = sec.items || [];
return `<section class="ovk-sidebar-sec">${sec.title ? `<h2 class="ovk-sidebar-h">${esc(sec.title)}</h2>` : ''}${items.map(item).join('')}`
ddiff --git a/package.json b/package.json
iindex 685e8bf..b8648fe 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.101.0",
++ "version": "4.102.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/boost-chrome.test.js b/test/boost-chrome.test.js
iindex ca16f37..7fd820a 100644
---- a/test/boost-chrome.test.js
++++ b/test/boost-chrome.test.js
@@@ -21,11 +21,12 @@ const page = ({ title, marker = 'site@r1', css = [], scripts = ['/js/site.js'],
<link rel="icon" href="data:,">${css.map((c) => `<link rel="stylesheet" href="${c}">`).join('')}
${scripts.map((s) => `<script src="${s}"></script>`).join('')}<script src="/boost.js" data-main="#main" defer></script></head>
<body class="page-${title.replace(/\W+/g, '-').toLowerCase()}"><header>header</header><main id="main" data-page="${title}">${body}</main><footer>footer</footer></body></html>`;
--const LINKS = '<a id="to-a" href="/a">A</a> <a id="to-b" href="/b">B</a> <a id="to-x" href="/x">X</a> <a id="to-y" href="/y">Y</a> <a id="to-api" href="/api/thing">api</a> <a id="to-new" href="/a" target="_blank">new</a>';
++const LINKS = '<a id="to-v" href="/v">V</a> <a id="to-home" href="/">Home</a> <a id="to-a" href="/a">A</a> <a id="to-b" href="/b">B</a> <a id="to-x" href="/x">X</a> <a id="to-y" href="/y">Y</a> <a id="to-api" href="/api/thing">api</a> <a id="to-new" href="/a" target="_blank">new</a>';
const PAGES = {
'/': page({ title: 'Home', canonical: '/', body: `<h1>Home</h1><a id="to-part" href="#part">part</a><div style="height:3000px">${LINKS}</div><p id="bottom">bottom</p><div style="height:900px"></div><p id="part">part</p><div style="height:900px"></div>` }),
'/a': page({ title: 'Page A', canonical: '/a', css: ['/css/a.css'], body: `<h1>A</h1>${LINKS}<script>window.inlineRuns = (window.inlineRuns || 0) + 1;</script><script type="application/ld+json">{"@type":"WebPage","name":"A"}</script>` }),
'/b': page({ title: 'Page B', canonical: '/b', body: `<h1>B</h1>${LINKS}` }),
++ '/v': page({ title: 'Page V', canonical: '/v', body: `<h1>V</h1><video muted playsinline aria-label="preview"></video><canvas hidden></canvas>${LINKS}` }),
'/x': page({ title: 'Page X', canonical: '/x', marker: 'site@r2', body: `<h1>X</h1>${LINKS}` }),
'/y': page({ title: 'Page Y', canonical: '/y', scripts: ['/js/site.js', '/js/other.js'], body: `<h1>Y</h1>${LINKS}` }),
'/api/thing': page({ title: 'API', canonical: '/api/thing', body: '<h1>api</h1>' }),
@@@ -97,6 +98,18 @@ const server = http.createServer((req, res) => {
assert.ok(Math.abs(v.y - 40) < 5, `and returns to where the person was (${v.y})`);
await p.evaluate('window.alive = 1; 1');
++ // A page with a <video> visited again and again in place lets each parsed page go (4.102.0). Chrome held the parsed
++ // page's own <video> as a pending activity, and with it the whole page: a document more on every visit.
++ const docs = async () => { await p.send('HeapProfiler.collectGarbage'); await p.send('HeapProfiler.collectGarbage'); return (await p.metrics()).Documents; };
++ const swap = (id) => p.evaluate(`new Promise((done) => { document.addEventListener('ov:boost:load', () => done(document.getElementById('main').dataset.page), { once: true }); document.getElementById('${id}').click(); })`);
++ assert.strictEqual(await swap('to-v'), 'Page V');
++ assert.strictEqual(await swap('to-home'), 'Home');
++ const docs0 = await docs();
++ for (let i = 0; i < 4; i++) { await swap('to-v'); await swap('to-home'); }
++ const docs1 = await docs();
++ assert.strictEqual(await p.evaluate('window.alive'), 1, 'all in place');
++ assert.ok(docs1 <= docs0, `the parsed pages were let go (documents ${docs0} → ${docs1})`);
++
// Not swappable: an excluded path and a new tab are left to the browser; another release or other scripts load normally.
// Did boost claim the click? A window listener runs after boost's document listener: it records the answer and
// then cancels the browser's own navigation so the test stays on this page.
ddiff --git a/test/kit.test.js b/test/kit.test.js
iindex 9f173e7..9419f5a 100644
---- a/test/kit.test.js
++++ b/test/kit.test.js
@@@ -278,6 +278,15 @@ const kit = require('../kit');
assert.ok(html.includes('<h2 class="ovk-sidebar-h">Today</h2>') && html.includes('Fix &lt;it&gt;') && html.includes('<p class="ovk-sidebar-empty">Nothing yet</p>'), 'sections, and an empty one says so');
assert.ok(html.includes('data-ovk-sidebar-close') && html.includes('<label class="ovk-sr" for="side-q">'), 'a close button and a labelled search');
assert.match(kit.sidebarButton({ id: 'side' }), /data-ovk-sidebar-open aria-controls="side" aria-expanded="false"/);
++ // 4.102.0: a second line, and data attributes a page's script finds the row by (only data-*).
++ const two = kit.sidebar({ sections: [{ items: [
++ { href: '/inbox/1?c=7', label: 'Ana', meta: '2m', dot: 'needs', sub: 'Where is <my> order?', mark: '<span class="m">A</span>', attrs: { 'data-conv': 7, onclick: 'x()', 'data-x"y': 1, 'data-off': false } },
++ { href: '/inbox/1?c=8', label: 'Bo', sub: 'Thanks' },
++ ] }] });
++ assert.ok(two.includes('<a class="ovk-sidebar-item has-sub has-mark" href="/inbox/1?c=7" data-conv="7">'), 'a mark beside two lines, the row findable by data-conv');
++ assert.ok(two.includes('<span class="ovk-sidebar-s">Where is &lt;my&gt; order?</span></a>') && !two.includes('onclick') && !two.includes('data-x') && !two.includes('data-off'), 'the line escaped, only data-* attributes kept');
++ assert.ok(two.includes('<a class="ovk-sidebar-item has-sub" href="/inbox/1?c=8">'), 'no mark: no mark column');
++ assert.ok(!html.includes('has-sub') && !html.includes('class="ovk-sidebar-s"'), 'a one-line row is unchanged');
assert.match(kit.withSidebar({ sidebar: html, body: '<p>x</p>' }), /^<div class="ovk-with-sidebar"><aside[\s\S]*<div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden><\/div><div class="ovk-with-sidebar-main"><p>x<\/p><\/div><\/div>$/);
const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'kit.css'), 'utf8');
assert.ok(/@media \(max-width: 760px\) \{\s*\.ovk-with-sidebar \{ grid-template-columns: minmax\(0, 1fr\); \}\s*\.ovk-sidebar \{ position: fixed; z-index: 10060;/.test(css), 'a drawer over everything on a phone');