openvibers/openvibe.shared Commits

Shared: the updates log no longer jumps while it loads (one standalone loading line, own chips up front) (#69)

OP OpenVibers committed 12 Oct 2026 · 8138e18c71 · 3 files, +29 −2

On a phone, openvibe.live/updates scored CLS 0.17, the only public Live route still above Chrome's 0.1 line after Live#114. The shared log view (`shipped.js` log) caused it: it showed "Loading…" inside the days list, then the site chips and the patch notes arrived above it and pushed it down by about 370 px. Every site's `/updates` page uses this view.

**What changes**
- The first load shows one standalone "Loading…" line instead of a placeholder inside the days.
- On a site's own log, the chips (the site and "All of OpenVibe") don't depend on the data, so they are drawn before it.
- The chips, patch notes and days now land together; nothing visible moves.
- Later reloads (switching site with a chip) behave as before.

**Release.** No version bump: the change is under "Unreleased" in the CHANGELOG and ships with the next tagged release, so it doesn't need a 39-repo pin round of its own.

**Tests:** `test/shipped.test.js` holds the fetch open and checks:
- the site's own chips are there before the data;
- the loading line stands alone and the days stay empty while loading;
- the loading line is gone once the data arrives.

The existing log, paging and pin-bump checks pass.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 71a5bda..2291b14 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,13 @@ 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).
++## Unreleased
++
++- **The updates log no longer jumps while it loads.** `shipped.js` log showed "Loading…" inside the days, then the site
++ chips and the patch notes arrived above it and pushed it down (CLS 0.17 on openvibe.live/updates on a phone). The
++ first load now shows one standalone "Loading…" line, a site's own chips are drawn before the data, and the chips,
++ patch notes and days land together.
++
## 4.1.2 — 2026-10-12
- **"Recently shipped" tells visitors what changed, not which library was bumped.** The one-liner and the short
ddiff --git a/shipped.js b/shipped.js
iindex 63e4052..21b0223 100644
---- a/shipped.js
++++ b/shipped.js
@@@ -300,7 +300,10 @@
const days = el('div', { class: 'ov-shipped-days' });
days.style.cssText = 'display:flex;flex-direction:column;gap:24px;min-width:0';
const more = el('button', { type: 'button', class: 'ov-shipped-more', hidden: '' }, 'Load more');
-- node.append(bar, posts, days, more);
++ // The first load shows one "Loading…" line on its own (and a site's own chips, known up front), so the chips,
++ // the patch notes and the days arrive together instead of pushing a visible line down the page (layout shift).
++ const loading = el('p', { class: 'ov-shipped-empty' }, 'Loading…');
++ node.append(bar, posts, loading, days, more);
let next = null;
let seq = 0;
@@@ -342,7 +345,7 @@
if (reset) {
next = null;
days.textContent = '';
-- days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'Loading…'));
++ if (!loading.parentNode) days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'Loading…'));
for (const b of bar.querySelectorAll('.ov-shipped-chip')) b.setAttribute('aria-pressed', 'false');
}
more.disabled = true;
@@@ -350,6 +353,7 @@
if (mine !== seq) return;
if (reset) {
days.textContent = '';
++ loading.remove();
if (data && data.sites) sitesSeen = data.sites;
if (!own && !sitesSeen) {
const all = await feed({ service: null, limit: 1, api: o.api });
@@@ -373,6 +377,7 @@
tick();
}
more.addEventListener('click', () => load(false));
++ if (own) drawBar(null);
await load(true);
return node;
}
ddiff --git a/test/shipped.test.js b/test/shipped.test.js
iindex 0e37595..51e35c8 100644
---- a/test/shipped.test.js
++++ b/test/shipped.test.js
@@@ -123,6 +123,21 @@ const data = {
assert.ok(logEl.textContent.includes('four'));
assert.strictEqual(more.hidden, true, 'no next cursor: no button');
++ // log()'s first load: one standalone "Loading…" line and the site's own chips up front, nothing inside the days
++ // that the chips and patch notes would push down; all of it lands together.
++ let release;
++ globalThis.fetch = async () => { await new Promise((r) => { release = r; }); return { ok: true, json: async () => page2 }; };
++ const steady = document.createElement('div');
++ const done = shipped.log(steady, { service: 'wiki', api: 'https://example.test/api/v1/changelog-steady' });
++ await new Promise((r) => setTimeout(r, 0));
++ assert.deepStrictEqual([...steady.querySelectorAll('.ov-shipped-chip')].map((b) => b.textContent), ['Wiki', 'All of OpenVibe'], 'own chips before the data');
++ assert.deepStrictEqual([...steady.children].map((c) => c.className), ['ov-shipped-bar', 'ov-shipped-posts', 'ov-shipped-empty', 'ov-shipped-days', 'ov-shipped-more']);
++ assert.strictEqual(steady.querySelector('.ov-shipped-days').children.length, 0, 'the days stay empty while loading');
++ release();
++ await done;
++ assert.strictEqual(steady.querySelectorAll('.ov-shipped-empty').length, 0, 'the loading line is gone once the data is in');
++ assert.ok(steady.textContent.includes('four'));
++
// scan(): markup mounts once.
globalThis.fetch = async () => ({ ok: true, json: async () => data });
const autoEl = document.createElement('section');